Initial commit: Echoes of the Ashes - Telegram RPG Bot
83
.gitignore
vendored
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Claude Sonnet Logs - Development documentation
|
||||||
|
claude_sonnet_logs/
|
||||||
|
|
||||||
|
# Development Scripts - Migration and update scripts
|
||||||
|
dev_scripts/
|
||||||
|
|
||||||
|
# Backups - Old files and backup folders
|
||||||
|
backups/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Virtual Environment
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
*.backup
|
||||||
|
*.bak
|
||||||
|
*~
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Compiled files
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# Editor backup files
|
||||||
|
*.py.backup
|
||||||
|
*.html.backup
|
||||||
|
*.js.backup
|
||||||
|
|
||||||
|
# Old/deprecated files
|
||||||
|
*_old.py
|
||||||
|
*_old.js
|
||||||
|
*_old.html
|
||||||
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Use an official Python runtime as a parent image
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set the working directory in the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the requirements file into the container at /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install any needed packages specified in requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy the rest of the application's code into the container at /app
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Command to run the application
|
||||||
|
CMD ["python", "main.py"]
|
||||||
25
Dockerfile.map
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Docker CLI for container restart functionality
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
ca-certificates && \
|
||||||
|
curl -fsSL https://get.docker.com | sh && \
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy all application code (needed for bot module access)
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Install requirements (web-map and main requirements)
|
||||||
|
RUN pip install --no-cache-dir -r /app/web-map/requirements.txt && \
|
||||||
|
pip install --no-cache-dir -r /app/requirements.txt || true
|
||||||
|
|
||||||
|
WORKDIR /app/web-map
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["python", "server_enhanced.py"]
|
||||||
321
README.md
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
# Echoes of the Ashes - Telegram RPG Bot
|
||||||
|
|
||||||
|
A post-apocalyptic survival RPG Telegram bot built with Python, featuring turn-based exploration, resource management, and a persistent world.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## 🎮 Features
|
||||||
|
|
||||||
|
### Core Gameplay
|
||||||
|
- **🗺️ Exploration**: Navigate through 7 interconnected locations
|
||||||
|
- **👀 Interact**: Search and interact with 24+ unique objects
|
||||||
|
- **🎒 Inventory**: Collect, use, and manage 28 different items
|
||||||
|
- **⚡️ Stamina System**: Actions require stamina management with automatic regeneration
|
||||||
|
- **❤️ Survival**: Heal using consumables, avoid damage
|
||||||
|
- **🔄 Cooldowns**: Per-action cooldown system prevents spam
|
||||||
|
- **♻️ Auto-Recovery**: Stamina regenerates over time (1+ per 5 minutes based on endurance)
|
||||||
|
|
||||||
|
### Visual Experience
|
||||||
|
- **📸 Location Images**: Every location has a unique image
|
||||||
|
- **🖼️ Smart Caching**: Images cached in database for instant loading
|
||||||
|
- **✨ Smooth Transitions**: Uses `edit_message_media` for seamless navigation
|
||||||
|
- **🧭 Context-Aware**: Location images persist across menus
|
||||||
|
|
||||||
|
### Game World
|
||||||
|
- **7 Locations**: Downtown, Gas Station, Residential, Clinic, Plaza, Park, Overpass
|
||||||
|
- **5 Interactable Types**: Rubble, Sedans, Houses, Medical Cabinets, Tool Sheds, Dumpsters, Vending Machines
|
||||||
|
- **28 Items**: Resources, consumables, weapons, equipment, quest items
|
||||||
|
- **Risk vs Reward**: Higher risk actions can cause damage but yield better loot
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- Telegram Bot Token (from [@BotFather](https://t.me/botfather))
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
cd /opt/dockers/telegram-rpg
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `.env` file:
|
||||||
|
```env
|
||||||
|
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||||
|
DATABASE_URL=postgresql+psycopg://user:password@echoes_of_the_ashes_db:5432/telegram_rpg
|
||||||
|
POSTGRES_USER=user
|
||||||
|
POSTGRES_PASSWORD=password
|
||||||
|
POSTGRES_DB=telegram_rpg
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the bot:
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Check logs:
|
||||||
|
```bash
|
||||||
|
docker logs echoes_of_the_ashes_bot -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 How to Play
|
||||||
|
|
||||||
|
### Basic Commands
|
||||||
|
- `/start` - Start your journey or return to main menu
|
||||||
|
|
||||||
|
### Main Menu
|
||||||
|
- **🗺️ Move** - Travel to connected locations
|
||||||
|
- **👀 Inspect Area** - View and interact with objects
|
||||||
|
- **👤 Profile** - View your character stats
|
||||||
|
- **🎒 Inventory** - Manage your items
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
- **Search/Loot** - Find items in the environment (costs stamina)
|
||||||
|
- **Use Items** - Consume food/medicine to restore HP/stamina
|
||||||
|
- **Drop Items** - Leave items at current location
|
||||||
|
- **Pick Up** - Collect items from the ground
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
- **HP**: Health Points (die at 0)
|
||||||
|
- **Stamina**: Required for actions (regenerates over time)
|
||||||
|
- **Weight/Volume**: Inventory capacity limits
|
||||||
|
|
||||||
|
## 🗺️ World Map
|
||||||
|
|
||||||
|
```
|
||||||
|
🛣️ Highway Overpass
|
||||||
|
|
|
||||||
|
🏥 Clinic --- ⛽️ Gas Station
|
||||||
|
| |
|
||||||
|
🏘️ Residential --- 🌆 Downtown --- 🏬 Plaza
|
||||||
|
| |
|
||||||
|
+------------ 🌳 Park ------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Items
|
||||||
|
|
||||||
|
### Consumables
|
||||||
|
| Item | Effect | Emoji |
|
||||||
|
|------|--------|-------|
|
||||||
|
| First Aid Kit | +50 HP | 🩹 |
|
||||||
|
| Mystery Pills | +30 HP | 💊 |
|
||||||
|
| Canned Beans | +20 HP, +5 Stamina | 🥫 |
|
||||||
|
| Energy Bar | +15 Stamina | 🍫 |
|
||||||
|
| Bottled Water | +10 Stamina | 💧 |
|
||||||
|
|
||||||
|
### Resources
|
||||||
|
- ⚙️ Scrap Metal
|
||||||
|
- 🪵 Wood Planks
|
||||||
|
- 📌 Rusty Nails
|
||||||
|
- 🧵 Cloth Scraps
|
||||||
|
- 🍶 Plastic Bottles
|
||||||
|
|
||||||
|
### Equipment
|
||||||
|
- 🎒 Hiking Backpack (+20 capacity)
|
||||||
|
- 🔦 Flashlight
|
||||||
|
- 🔧 Tire Iron
|
||||||
|
- ⚾ Baseball Bat
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
- **Language**: Python 3.11
|
||||||
|
- **Bot Framework**: python-telegram-bot 21.0.1
|
||||||
|
- **Database**: PostgreSQL 15 (async with SQLAlchemy)
|
||||||
|
- **Deployment**: Docker Compose
|
||||||
|
- **Scheduler**: APScheduler (for stamina regeneration)
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
```
|
||||||
|
telegram-rpg/
|
||||||
|
├── bot/
|
||||||
|
│ ├── database.py # Database operations
|
||||||
|
│ ├── handlers.py # Telegram event handlers
|
||||||
|
│ ├── keyboards.py # Inline keyboard layouts
|
||||||
|
│ └── logic.py # Game logic
|
||||||
|
├── data/
|
||||||
|
│ ├── items.py # Item definitions
|
||||||
|
│ ├── models.py # Game world models
|
||||||
|
│ └── world_loader.py # World construction
|
||||||
|
├── docs/ # Comprehensive documentation
|
||||||
|
├── images/ # Location and interactable images
|
||||||
|
├── main.py # Entry point
|
||||||
|
└── docker-compose.yml # Container orchestration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
- **players**: Character stats and state
|
||||||
|
- **inventory**: Player item storage
|
||||||
|
- **dropped_items**: World item storage
|
||||||
|
- **cooldowns**: Per-action cooldown tracking
|
||||||
|
- **image_cache**: Telegram file_id caching
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
Detailed documentation in `docs/`:
|
||||||
|
- **INVENTORY_USE.md** - Item usage system
|
||||||
|
- **EXPANDED_WORLD.md** - All locations and items
|
||||||
|
- **WORLD_MAP.md** - Map visualization and strategy
|
||||||
|
- **IMAGE_SYSTEM.md** - Image caching implementation
|
||||||
|
- **UX_IMPROVEMENTS.md** - Clean chat mechanics
|
||||||
|
- **ACTION_FEEDBACK.md** - Action result display
|
||||||
|
- **SMOOTH_TRANSITIONS.md** - Message editing system
|
||||||
|
- **UPDATE_SUMMARY.md** - Latest changes
|
||||||
|
|
||||||
|
## 🎨 Adding Content
|
||||||
|
|
||||||
|
### New Item
|
||||||
|
Edit `data/items.py`:
|
||||||
|
```python
|
||||||
|
"new_item": {
|
||||||
|
"name": "New Item",
|
||||||
|
"weight": 1.0,
|
||||||
|
"volume": 0.5,
|
||||||
|
"type": "consumable",
|
||||||
|
"effects": {"hp": 20},
|
||||||
|
"emoji": "🎁"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Interactable
|
||||||
|
Edit `data/world_loader.py`:
|
||||||
|
```python
|
||||||
|
NEW_TEMPLATE = Interactable(
|
||||||
|
id="new_object",
|
||||||
|
name="New Object",
|
||||||
|
image_path="images/interactables/new.png"
|
||||||
|
)
|
||||||
|
action = Action(id="search", label="🔎 Search", stamina_cost=2)
|
||||||
|
action.add_outcome("success", Outcome(
|
||||||
|
text="You find something!",
|
||||||
|
items_reward={"new_item": 1}
|
||||||
|
))
|
||||||
|
NEW_TEMPLATE.add_action(action)
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Location
|
||||||
|
```python
|
||||||
|
new_location = Location(
|
||||||
|
id="new_place",
|
||||||
|
name="🏛️ New Place",
|
||||||
|
description="Description here",
|
||||||
|
image_path="images/locations/new_place.png"
|
||||||
|
)
|
||||||
|
new_location.add_interactable("new_place_object", NEW_TEMPLATE)
|
||||||
|
new_location.add_exit("north", "other_location")
|
||||||
|
world.add_location(new_location)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Development
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Run bot
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Management
|
||||||
|
```bash
|
||||||
|
# Access database
|
||||||
|
docker exec -it echoes_of_the_ashes_db psql -U user -d telegram_rpg
|
||||||
|
|
||||||
|
# Backup database
|
||||||
|
docker exec echoes_of_the_ashes_db pg_dump -U user telegram_rpg > backup.sql
|
||||||
|
|
||||||
|
# Restore database
|
||||||
|
docker exec -i echoes_of_the_ashes_db psql -U user telegram_rpg < backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
```bash
|
||||||
|
# Follow bot logs
|
||||||
|
docker logs echoes_of_the_ashes_bot -f
|
||||||
|
|
||||||
|
# Database logs
|
||||||
|
docker logs echoes_of_the_ashes_db -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎲 Game Mechanics
|
||||||
|
|
||||||
|
### Outcome Probability
|
||||||
|
- **Critical Failure**: Rare, negative effects
|
||||||
|
- **Failure**: Common, no reward
|
||||||
|
- **Success**: Common, standard rewards
|
||||||
|
|
||||||
|
Configured in `bot/logic.py`:
|
||||||
|
```python
|
||||||
|
def roll_outcome(action: Action):
|
||||||
|
roll = random.random()
|
||||||
|
if roll < 0.1: return "critical_failure"
|
||||||
|
elif roll < 0.5: return "failure"
|
||||||
|
else: return "success"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stamina Regeneration
|
||||||
|
- **Rate**: 1 stamina per 5 minutes
|
||||||
|
- **Maximum**: Defined by player stats
|
||||||
|
- **Automatic**: Background scheduler
|
||||||
|
|
||||||
|
### Cooldowns
|
||||||
|
- **Per-Action**: Each action has independent cooldown
|
||||||
|
- **Duration**: Configured per action (30-60 minutes typical)
|
||||||
|
- **Storage**: Composite key `instance_id:action_id`
|
||||||
|
|
||||||
|
## 🚧 Future Plans
|
||||||
|
|
||||||
|
### Planned Features
|
||||||
|
- [ ] Combat system
|
||||||
|
- [ ] Crafting mechanics
|
||||||
|
- [ ] Quest system
|
||||||
|
- [ ] NPC interactions
|
||||||
|
- [ ] Base building
|
||||||
|
- [ ] Equipment slots
|
||||||
|
- [ ] Status effects
|
||||||
|
- [ ] Day/night cycle
|
||||||
|
- [ ] Weather system
|
||||||
|
- [ ] Trading economy
|
||||||
|
|
||||||
|
### Balance Improvements
|
||||||
|
- [ ] Dynamic difficulty
|
||||||
|
- [ ] Rare item spawns
|
||||||
|
- [ ] Location-based dangers
|
||||||
|
- [ ] Resource scarcity tuning
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please:
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Test thoroughly
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
|
|
||||||
|
This project is open source and available under the MIT License.
|
||||||
|
|
||||||
|
## 🙏 Acknowledgments
|
||||||
|
|
||||||
|
- Built with [python-telegram-bot](https://python-telegram-bot.org/)
|
||||||
|
- Inspired by classic post-apocalyptic RPGs
|
||||||
|
- Community feedback and testing
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
For issues or questions:
|
||||||
|
- Open a GitHub issue
|
||||||
|
- Check the documentation in `docs/`
|
||||||
|
- Review error logs with `docker logs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Current Version**: 1.1.0 (Expanded World Update)
|
||||||
|
**Last Updated**: October 16, 2025
|
||||||
|
**Status**: ✅ Active Development
|
||||||
0
bot/__init__.py
Normal file
495
bot/combat.py
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
"""
|
||||||
|
Combat system logic for turn-based NPC encounters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Dict, List, Tuple, Optional
|
||||||
|
from bot import database
|
||||||
|
from data.npcs import NPCS, STATUS_EFFECTS
|
||||||
|
from data.items import ITEMS
|
||||||
|
|
||||||
|
|
||||||
|
# XP curve for leveling
|
||||||
|
def xp_for_level(level: int) -> int:
|
||||||
|
"""Calculate XP needed to reach a level."""
|
||||||
|
if level <= 1:
|
||||||
|
return 0 # Level 1 starts at 0 XP
|
||||||
|
return int(100 * (level ** 1.5))
|
||||||
|
|
||||||
|
|
||||||
|
async def calculate_player_damage(player: dict) -> int:
|
||||||
|
"""Calculate player's damage output based on stats and equipped weapon."""
|
||||||
|
base_damage = 5
|
||||||
|
strength_bonus = player['strength'] // 2
|
||||||
|
level_bonus = player['level']
|
||||||
|
|
||||||
|
# Check for equipped weapon
|
||||||
|
inventory = await database.get_inventory(player['telegram_id'])
|
||||||
|
weapon_damage = 0
|
||||||
|
|
||||||
|
for item in inventory:
|
||||||
|
if item.get('is_equipped'):
|
||||||
|
item_def = ITEMS.get(item['item_id'], {})
|
||||||
|
if item_def.get('type') == 'weapon':
|
||||||
|
# Get weapon damage range
|
||||||
|
damage_min = item_def.get('damage_min', 0)
|
||||||
|
damage_max = item_def.get('damage_max', 0)
|
||||||
|
weapon_damage = random.randint(damage_min, damage_max)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Random variance
|
||||||
|
variance = random.randint(-2, 2)
|
||||||
|
|
||||||
|
return max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_npc_damage(npc_def, npc_hp: int, npc_max_hp: int) -> int:
|
||||||
|
"""Calculate NPC's damage output."""
|
||||||
|
base_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
||||||
|
|
||||||
|
# Enraged bonus if low HP
|
||||||
|
hp_percent = npc_hp / npc_max_hp
|
||||||
|
if hp_percent < 0.3:
|
||||||
|
base_damage = int(base_damage * 1.5)
|
||||||
|
|
||||||
|
return max(1, base_damage)
|
||||||
|
|
||||||
|
|
||||||
|
async def initiate_combat(player_id: int, npc_id: str, location_id: str, from_wandering_enemy: bool = False) -> Dict:
|
||||||
|
"""
|
||||||
|
Start a new combat encounter.
|
||||||
|
Args:
|
||||||
|
player_id: Telegram user ID
|
||||||
|
npc_id: NPC definition ID
|
||||||
|
location_id: Where combat is happening
|
||||||
|
from_wandering_enemy: If True, enemy will respawn if player flees or dies
|
||||||
|
Returns combat state dict.
|
||||||
|
"""
|
||||||
|
npc_def = NPCS.get(npc_id)
|
||||||
|
if not npc_def:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Randomize NPC HP
|
||||||
|
npc_hp = random.randint(npc_def.hp_min, npc_def.hp_max)
|
||||||
|
|
||||||
|
# Create combat in database
|
||||||
|
combat_id = await database.create_combat(
|
||||||
|
player_id=player_id,
|
||||||
|
npc_id=npc_id,
|
||||||
|
npc_hp=npc_hp,
|
||||||
|
npc_max_hp=npc_hp,
|
||||||
|
location_id=location_id,
|
||||||
|
from_wandering_enemy=from_wandering_enemy
|
||||||
|
)
|
||||||
|
|
||||||
|
return await database.get_combat(player_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def player_attack(player_id: int) -> Tuple[str, bool, bool]:
|
||||||
|
"""
|
||||||
|
Player attacks the NPC.
|
||||||
|
Returns: (message, npc_died, player_turn_ended)
|
||||||
|
"""
|
||||||
|
combat = await database.get_combat(player_id)
|
||||||
|
if not combat or combat['turn'] != 'player':
|
||||||
|
return ("It's not your turn!", False, False)
|
||||||
|
|
||||||
|
player = await database.get_player(player_id)
|
||||||
|
npc_def = NPCS.get(combat['npc_id'])
|
||||||
|
|
||||||
|
if not player or not npc_def:
|
||||||
|
return ("Combat error!", False, False)
|
||||||
|
|
||||||
|
# Check if player is stunned
|
||||||
|
player_effects = json.loads(combat['player_status_effects'])
|
||||||
|
is_stunned = any(effect['name'] == 'Stunned' for effect in player_effects)
|
||||||
|
if is_stunned:
|
||||||
|
# Update status effects
|
||||||
|
player_effects = update_status_effects(player_effects)
|
||||||
|
await database.update_combat(player_id, {
|
||||||
|
'turn': 'npc',
|
||||||
|
'turn_started_at': time.time(),
|
||||||
|
'player_status_effects': json.dumps(player_effects)
|
||||||
|
})
|
||||||
|
return ("⚠️ You're stunned and cannot attack! The enemy seizes the opportunity!", False, True)
|
||||||
|
|
||||||
|
# Calculate damage
|
||||||
|
raw_damage = await calculate_player_damage(player)
|
||||||
|
actual_damage = max(1, raw_damage - npc_def.defense)
|
||||||
|
|
||||||
|
new_npc_hp = max(0, combat['npc_hp'] - actual_damage)
|
||||||
|
|
||||||
|
# Check for critical hit (10% chance)
|
||||||
|
is_crit = random.random() < 0.1
|
||||||
|
if is_crit:
|
||||||
|
actual_damage = int(actual_damage * 1.5)
|
||||||
|
new_npc_hp = max(0, combat['npc_hp'] - actual_damage)
|
||||||
|
|
||||||
|
message = f"⚔️ You attack the {npc_def.name} for {actual_damage} damage!"
|
||||||
|
if is_crit:
|
||||||
|
message += " 💥 CRITICAL HIT!"
|
||||||
|
|
||||||
|
# Check for status effect infliction (5% chance to stun)
|
||||||
|
npc_effects = json.loads(combat['npc_status_effects'])
|
||||||
|
if random.random() < 0.05:
|
||||||
|
npc_effects.append({
|
||||||
|
'name': 'Stunned',
|
||||||
|
'turns_remaining': 1,
|
||||||
|
'damage_per_turn': 0
|
||||||
|
})
|
||||||
|
message += f"\n🌟 You stunned the {npc_def.name}!"
|
||||||
|
|
||||||
|
# Apply status effect damage to player
|
||||||
|
player_effects, status_damage, status_messages = apply_status_effects(player_effects)
|
||||||
|
if status_damage > 0:
|
||||||
|
new_player_hp = max(0, player['hp'] - status_damage)
|
||||||
|
await database.update_player(player_id, {'hp': new_player_hp})
|
||||||
|
message += f"\n{status_messages}"
|
||||||
|
|
||||||
|
if new_player_hp <= 0:
|
||||||
|
await handle_player_death(player_id)
|
||||||
|
return (message + "\n\n💀 You have died from your wounds...", True, True)
|
||||||
|
|
||||||
|
# Check if NPC died
|
||||||
|
if new_npc_hp <= 0:
|
||||||
|
await database.update_combat(player_id, {
|
||||||
|
'npc_hp': 0,
|
||||||
|
'npc_status_effects': json.dumps(npc_effects),
|
||||||
|
'player_status_effects': json.dumps(player_effects)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Handle victory
|
||||||
|
victory_msg = await handle_npc_death(player_id, combat, npc_def)
|
||||||
|
return (message + "\n\n" + victory_msg, True, True)
|
||||||
|
|
||||||
|
# Update combat - switch to NPC turn
|
||||||
|
await database.update_combat(player_id, {
|
||||||
|
'npc_hp': new_npc_hp,
|
||||||
|
'turn': 'npc',
|
||||||
|
'turn_started_at': time.time(),
|
||||||
|
'npc_status_effects': json.dumps(npc_effects),
|
||||||
|
'player_status_effects': json.dumps(player_effects)
|
||||||
|
})
|
||||||
|
|
||||||
|
message += f"\n{npc_def.emoji} {npc_def.name}: {new_npc_hp}/{combat['npc_max_hp']} HP"
|
||||||
|
|
||||||
|
return (message, False, True)
|
||||||
|
|
||||||
|
|
||||||
|
async def npc_attack(player_id: int) -> Tuple[str, bool]:
|
||||||
|
"""
|
||||||
|
NPC attacks the player.
|
||||||
|
Returns: (message, player_died)
|
||||||
|
"""
|
||||||
|
combat = await database.get_combat(player_id)
|
||||||
|
if not combat or combat['turn'] != 'npc':
|
||||||
|
return ("", False)
|
||||||
|
|
||||||
|
player = await database.get_player(player_id)
|
||||||
|
npc_def = NPCS.get(combat['npc_id'])
|
||||||
|
|
||||||
|
if not player or not npc_def:
|
||||||
|
return ("Combat error!", False)
|
||||||
|
|
||||||
|
# Check if NPC is stunned
|
||||||
|
npc_effects = json.loads(combat['npc_status_effects'])
|
||||||
|
is_stunned = any(effect['name'] == 'Stunned' for effect in npc_effects)
|
||||||
|
if is_stunned:
|
||||||
|
# Update status effects
|
||||||
|
npc_effects = update_status_effects(npc_effects)
|
||||||
|
await database.update_combat(player_id, {
|
||||||
|
'turn': 'player',
|
||||||
|
'turn_started_at': time.time(),
|
||||||
|
'npc_status_effects': json.dumps(npc_effects)
|
||||||
|
})
|
||||||
|
return (f"⚠️ The {npc_def.name} is stunned and cannot attack!", False)
|
||||||
|
|
||||||
|
# Calculate damage
|
||||||
|
damage = calculate_npc_damage(npc_def, combat['npc_hp'], combat['npc_max_hp'])
|
||||||
|
|
||||||
|
# Apply damage to player
|
||||||
|
new_player_hp = max(0, player['hp'] - damage)
|
||||||
|
await database.update_player(player_id, {'hp': new_player_hp})
|
||||||
|
|
||||||
|
message = f"💥 The {npc_def.name} attacks you for {damage} damage!"
|
||||||
|
|
||||||
|
# Check for status effect infliction
|
||||||
|
player_effects = json.loads(combat['player_status_effects'])
|
||||||
|
if random.random() < npc_def.status_inflict_chance:
|
||||||
|
# Bleeding is most common
|
||||||
|
player_effects.append({
|
||||||
|
'name': 'Bleeding',
|
||||||
|
'turns_remaining': 3,
|
||||||
|
'damage_per_turn': 2
|
||||||
|
})
|
||||||
|
message += "\n🩸 You're bleeding!"
|
||||||
|
|
||||||
|
# Apply status effect damage to NPC
|
||||||
|
npc_effects, status_damage, status_messages = apply_status_effects(npc_effects)
|
||||||
|
if status_damage > 0:
|
||||||
|
new_npc_hp = max(0, combat['npc_hp'] - status_damage)
|
||||||
|
await database.update_combat(player_id, {'npc_hp': new_npc_hp})
|
||||||
|
message += f"\n{status_messages}"
|
||||||
|
|
||||||
|
if new_npc_hp <= 0:
|
||||||
|
victory_msg = await handle_npc_death(player_id, combat, npc_def)
|
||||||
|
return (message + "\n\n" + victory_msg, False)
|
||||||
|
|
||||||
|
# Check if player died
|
||||||
|
if new_player_hp <= 0:
|
||||||
|
await handle_player_death(player_id)
|
||||||
|
return (message + "\n\n💀 You have been slain...", True)
|
||||||
|
|
||||||
|
# Update combat - switch to player turn
|
||||||
|
await database.update_combat(player_id, {
|
||||||
|
'turn': 'player',
|
||||||
|
'turn_started_at': time.time(),
|
||||||
|
'player_status_effects': json.dumps(player_effects),
|
||||||
|
'npc_status_effects': json.dumps(npc_effects)
|
||||||
|
})
|
||||||
|
|
||||||
|
message += f"\n❤️ Your HP: {new_player_hp}/{player['max_hp']}"
|
||||||
|
message += f"\n{npc_def.emoji} {npc_def.name}: {combat['npc_hp']}/{combat['npc_max_hp']} HP"
|
||||||
|
|
||||||
|
return (message, False)
|
||||||
|
|
||||||
|
|
||||||
|
async def flee_attempt(player_id: int) -> Tuple[str, bool, bool]:
|
||||||
|
"""
|
||||||
|
Player attempts to flee from combat.
|
||||||
|
Returns: (message, fled_successfully, turn_ended)
|
||||||
|
"""
|
||||||
|
combat = await database.get_combat(player_id)
|
||||||
|
if not combat or combat['turn'] != 'player':
|
||||||
|
return ("It's not your turn!", False, False)
|
||||||
|
|
||||||
|
player = await database.get_player(player_id)
|
||||||
|
npc_def = NPCS.get(combat['npc_id'])
|
||||||
|
|
||||||
|
# Base flee chance is 50%, modified by agility
|
||||||
|
flee_chance = 0.5 + (player['agility'] / 100)
|
||||||
|
|
||||||
|
if random.random() < flee_chance:
|
||||||
|
# Success! Check if we need to respawn the wandering enemy
|
||||||
|
if combat.get('from_wandering_enemy', False):
|
||||||
|
# Respawn the enemy at the same location
|
||||||
|
await database.spawn_wandering_enemy(
|
||||||
|
npc_id=combat['npc_id'],
|
||||||
|
location_id=combat['location_id'],
|
||||||
|
lifetime_seconds=600 # 10 minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
await database.end_combat(player_id)
|
||||||
|
return (f"🏃 You successfully flee from the {npc_def.name}!", True, True)
|
||||||
|
else:
|
||||||
|
# Failed - lose turn and NPC attacks
|
||||||
|
message = f"❌ You failed to escape! The {npc_def.name} takes advantage!"
|
||||||
|
|
||||||
|
# NPC gets a free attack
|
||||||
|
await database.update_combat(player_id, {
|
||||||
|
'turn': 'npc',
|
||||||
|
'turn_started_at': time.time()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (message, False, True)
|
||||||
|
|
||||||
|
|
||||||
|
def update_status_effects(effects: List[Dict]) -> List[Dict]:
|
||||||
|
"""Decrease turn counters on status effects."""
|
||||||
|
new_effects = []
|
||||||
|
for effect in effects:
|
||||||
|
effect['turns_remaining'] -= 1
|
||||||
|
if effect['turns_remaining'] > 0:
|
||||||
|
new_effects.append(effect)
|
||||||
|
return new_effects
|
||||||
|
|
||||||
|
|
||||||
|
def apply_status_effects(effects: List[Dict]) -> Tuple[List[Dict], int, str]:
|
||||||
|
"""
|
||||||
|
Apply status effect damage.
|
||||||
|
Returns: (updated_effects, total_damage, message)
|
||||||
|
"""
|
||||||
|
total_damage = 0
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
for effect in effects:
|
||||||
|
if effect['damage_per_turn'] > 0:
|
||||||
|
total_damage += effect['damage_per_turn']
|
||||||
|
if effect['name'] == 'Bleeding':
|
||||||
|
messages.append(f"🩸 Bleeding: -{effect['damage_per_turn']} HP")
|
||||||
|
elif effect['name'] == 'Infected':
|
||||||
|
messages.append(f"🦠 Infection: -{effect['damage_per_turn']} HP")
|
||||||
|
|
||||||
|
return effects, total_damage, "\n".join(messages)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_npc_death(player_id: int, combat: Dict, npc_def) -> str:
|
||||||
|
"""Handle NPC death - give XP, drop loot, create corpse."""
|
||||||
|
player = await database.get_player(player_id)
|
||||||
|
|
||||||
|
# Give XP
|
||||||
|
new_xp = player['xp'] + npc_def.xp_reward
|
||||||
|
level_up_msg = ""
|
||||||
|
|
||||||
|
# Check for level up
|
||||||
|
current_level = player['level']
|
||||||
|
xp_needed = xp_for_level(current_level + 1)
|
||||||
|
|
||||||
|
if new_xp >= xp_needed:
|
||||||
|
new_level = current_level + 1
|
||||||
|
# Give stat points instead of auto-allocating
|
||||||
|
# Players get 5 points per level to spend as they wish
|
||||||
|
points_gained = 5
|
||||||
|
new_unspent_points = player.get('unspent_points', 0) + points_gained
|
||||||
|
|
||||||
|
await database.update_player(player_id, {
|
||||||
|
'xp': new_xp,
|
||||||
|
'level': new_level,
|
||||||
|
'hp': player['max_hp'], # Heal on level up
|
||||||
|
'stamina': player['max_stamina'], # Restore stamina on level up
|
||||||
|
'unspent_points': new_unspent_points
|
||||||
|
})
|
||||||
|
|
||||||
|
level_up_msg = f"\n\n🎉 LEVEL UP! You are now level {new_level}!"
|
||||||
|
level_up_msg += f"\n⭐ You have {points_gained} stat points to spend!"
|
||||||
|
level_up_msg += f"\n❤️ Fully healed and stamina restored!"
|
||||||
|
level_up_msg += f"\n\n💡 Check your profile to spend your points!"
|
||||||
|
else:
|
||||||
|
await database.update_player(player_id, {'xp': new_xp})
|
||||||
|
|
||||||
|
# Drop loot
|
||||||
|
loot_msg = "\n\n💰 Loot dropped:"
|
||||||
|
loot_items = []
|
||||||
|
for loot_item in npc_def.loot_table:
|
||||||
|
if random.random() < loot_item.drop_chance:
|
||||||
|
quantity = random.randint(loot_item.quantity_min, loot_item.quantity_max)
|
||||||
|
await database.drop_item_to_world(
|
||||||
|
loot_item.item_id,
|
||||||
|
quantity,
|
||||||
|
combat['location_id']
|
||||||
|
)
|
||||||
|
item_def = ITEMS.get(loot_item.item_id, {})
|
||||||
|
loot_msg += f"\n{item_def.get('emoji', '❔')} {item_def.get('name', 'Unknown')} x{quantity}"
|
||||||
|
loot_items.append(loot_item.item_id)
|
||||||
|
|
||||||
|
if not loot_items:
|
||||||
|
loot_msg += "\nNothing..."
|
||||||
|
|
||||||
|
# Create corpse if it has corpse loot
|
||||||
|
if npc_def.corpse_loot:
|
||||||
|
corpse_loot_json = json.dumps([{
|
||||||
|
'item_id': cl.item_id,
|
||||||
|
'quantity_min': cl.quantity_min,
|
||||||
|
'quantity_max': cl.quantity_max,
|
||||||
|
'required_tool': cl.required_tool
|
||||||
|
} for cl in npc_def.corpse_loot])
|
||||||
|
|
||||||
|
await database.create_npc_corpse(
|
||||||
|
npc_id=combat['npc_id'],
|
||||||
|
location_id=combat['location_id'],
|
||||||
|
loot_remaining=corpse_loot_json
|
||||||
|
)
|
||||||
|
loot_msg += f"\n\n{npc_def.emoji} The corpse can be scavenged for more resources."
|
||||||
|
|
||||||
|
# End combat
|
||||||
|
await database.end_combat(player_id)
|
||||||
|
|
||||||
|
message = f"🏆 Victory! {npc_def.death_message}"
|
||||||
|
message += f"\n+{npc_def.xp_reward} XP"
|
||||||
|
message += level_up_msg
|
||||||
|
message += loot_msg
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_player_death(player_id: int):
|
||||||
|
"""Handle player death - create corpse bag with all items."""
|
||||||
|
player = await database.get_player(player_id)
|
||||||
|
inventory_items = await database.get_inventory(player_id)
|
||||||
|
|
||||||
|
# Check if combat was with a wandering enemy that should respawn
|
||||||
|
combat = await database.get_combat(player_id)
|
||||||
|
if combat and combat.get('from_wandering_enemy', False):
|
||||||
|
# Respawn the enemy at the same location
|
||||||
|
await database.spawn_wandering_enemy(
|
||||||
|
npc_id=combat['npc_id'],
|
||||||
|
location_id=combat['location_id'],
|
||||||
|
lifetime_seconds=600 # 10 minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create corpse bag if player has items
|
||||||
|
if inventory_items:
|
||||||
|
items_json = json.dumps([{
|
||||||
|
'item_id': item['item_id'],
|
||||||
|
'quantity': item['quantity']
|
||||||
|
} for item in inventory_items])
|
||||||
|
|
||||||
|
await database.create_player_corpse(
|
||||||
|
player_name=player['name'],
|
||||||
|
location_id=player['location_id'],
|
||||||
|
items=items_json
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove all items from player
|
||||||
|
for item in inventory_items:
|
||||||
|
await database.remove_item_from_inventory(item['id'], item['quantity'])
|
||||||
|
|
||||||
|
# Mark player as dead and end any combat
|
||||||
|
await database.update_player(player_id, {'is_dead': True, 'hp': 0})
|
||||||
|
await database.end_combat(player_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def use_item_in_combat(player_id: int, item_db_id: int) -> Tuple[str, bool]:
|
||||||
|
"""
|
||||||
|
Use a consumable item during combat.
|
||||||
|
Returns: (message, turn_ended)
|
||||||
|
"""
|
||||||
|
combat = await database.get_combat(player_id)
|
||||||
|
if not combat or combat['turn'] != 'player':
|
||||||
|
return ("It's not your turn!", False)
|
||||||
|
|
||||||
|
item_data = await database.get_inventory_item(item_db_id)
|
||||||
|
if not item_data or item_data['player_id'] != player_id:
|
||||||
|
return ("You don't have that item!", False)
|
||||||
|
|
||||||
|
item_def = ITEMS.get(item_data['item_id'])
|
||||||
|
if not item_def or item_def.get('type') != 'consumable':
|
||||||
|
return ("That item cannot be used in combat!", False)
|
||||||
|
|
||||||
|
player = await database.get_player(player_id)
|
||||||
|
|
||||||
|
# Apply consumable effects
|
||||||
|
message = f"💊 Used {item_def['name']}!"
|
||||||
|
|
||||||
|
hp_restore = item_def.get('hp_restore', 0)
|
||||||
|
stamina_restore = item_def.get('stamina_restore', 0)
|
||||||
|
|
||||||
|
updates = {}
|
||||||
|
if hp_restore > 0:
|
||||||
|
new_hp = min(player['hp'] + hp_restore, player['max_hp'])
|
||||||
|
updates['hp'] = new_hp
|
||||||
|
message += f"\n❤️ +{hp_restore} HP"
|
||||||
|
|
||||||
|
if stamina_restore > 0:
|
||||||
|
new_stamina = min(player['stamina'] + stamina_restore, player['max_stamina'])
|
||||||
|
updates['stamina'] = new_stamina
|
||||||
|
message += f"\n⚡ +{stamina_restore} Stamina"
|
||||||
|
|
||||||
|
if updates:
|
||||||
|
await database.update_player(player_id, updates)
|
||||||
|
|
||||||
|
# Remove item from inventory
|
||||||
|
if item_data['quantity'] > 1:
|
||||||
|
await database.update_inventory_item(item_db_id, item_data['quantity'] - 1)
|
||||||
|
else:
|
||||||
|
await database.remove_item_from_inventory(item_db_id, 1)
|
||||||
|
|
||||||
|
# Using an item ends your turn
|
||||||
|
await database.update_combat(player_id, {
|
||||||
|
'turn': 'npc',
|
||||||
|
'turn_started_at': time.time()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (message, True)
|
||||||
539
bot/database.py
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
import time
|
||||||
|
import os
|
||||||
|
from typing import Set
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlalchemy import (
|
||||||
|
MetaData, Table, Column, Integer, String, Boolean, ForeignKey, Float, UniqueConstraint,
|
||||||
|
)
|
||||||
|
|
||||||
|
DB_USER, DB_PASS, DB_NAME, DB_HOST, DB_PORT = os.getenv("POSTGRES_USER"), os.getenv("POSTGRES_PASSWORD"), os.getenv("POSTGRES_DB"), os.getenv("POSTGRES_HOST"), os.getenv("POSTGRES_PORT")
|
||||||
|
DATABASE_URL = f"postgresql+psycopg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||||
|
engine = create_async_engine(DATABASE_URL)
|
||||||
|
metadata = MetaData()
|
||||||
|
|
||||||
|
# ... (players, inventory, dropped_items tables are unchanged) ...
|
||||||
|
players = Table("players", metadata, Column("telegram_id", Integer, primary_key=True), Column("name", String, default="Survivor"), Column("hp", Integer, default=100), Column("max_hp", Integer, default=100), Column("stamina", Integer, default=20), Column("max_stamina", Integer, default=20), Column("strength", Integer, default=5), Column("agility", Integer, default=5), Column("endurance", Integer, default=5), Column("intellect", Integer, default=5), Column("location_id", String, default="start_point"), Column("is_dead", Boolean, default=False), Column("level", Integer, default=1), Column("xp", Integer, default=0), Column("unspent_points", Integer, default=0))
|
||||||
|
inventory = Table("inventory", metadata, Column("id", Integer, primary_key=True, autoincrement=True), Column("player_id", Integer, ForeignKey("players.telegram_id", ondelete="CASCADE")), Column("item_id", String), Column("quantity", Integer, default=1), Column("is_equipped", Boolean, default=False))
|
||||||
|
dropped_items = Table("dropped_items", metadata, Column("id", Integer, primary_key=True, autoincrement=True), Column("item_id", String), Column("quantity", Integer, default=1), Column("location_id", String), Column("drop_timestamp", Float))
|
||||||
|
|
||||||
|
# Combat-related tables
|
||||||
|
active_combats = Table(
|
||||||
|
"active_combats",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("player_id", Integer, ForeignKey("players.telegram_id", ondelete="CASCADE"), unique=True),
|
||||||
|
Column("npc_id", String, nullable=False),
|
||||||
|
Column("npc_hp", Integer, nullable=False),
|
||||||
|
Column("npc_max_hp", Integer, nullable=False),
|
||||||
|
Column("turn", String, nullable=False), # "player" or "npc"
|
||||||
|
Column("turn_started_at", Float, nullable=False),
|
||||||
|
Column("player_status_effects", String, default=""), # JSON string
|
||||||
|
Column("npc_status_effects", String, default=""), # JSON string
|
||||||
|
Column("location_id", String, nullable=False),
|
||||||
|
Column("from_wandering_enemy", Boolean, default=False), # If True, respawn on flee/death
|
||||||
|
)
|
||||||
|
|
||||||
|
player_corpses = Table(
|
||||||
|
"player_corpses",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("player_name", String, nullable=False),
|
||||||
|
Column("location_id", String, nullable=False),
|
||||||
|
Column("items", String, nullable=False), # JSON string of items
|
||||||
|
Column("death_timestamp", Float, nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
npc_corpses = Table(
|
||||||
|
"npc_corpses",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("npc_id", String, nullable=False),
|
||||||
|
Column("location_id", String, nullable=False),
|
||||||
|
Column("loot_remaining", String, nullable=False), # JSON string
|
||||||
|
Column("death_timestamp", Float, nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
interactable_cooldowns = Table(
|
||||||
|
"interactable_cooldowns",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("interactable_instance_id", String, nullable=False, unique=True), # Renamed for clarity
|
||||||
|
Column("expiry_timestamp", Float, nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Table to cache Telegram file IDs for images
|
||||||
|
image_cache = Table(
|
||||||
|
"image_cache",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("image_path", String, nullable=False, unique=True), # Local file path
|
||||||
|
Column("telegram_file_id", String, nullable=False), # Telegram's file_id for reuse
|
||||||
|
Column("uploaded_at", Float, nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wandering enemies table - managed by spawn system
|
||||||
|
wandering_enemies = Table(
|
||||||
|
"wandering_enemies",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("npc_id", String, nullable=False),
|
||||||
|
Column("location_id", String, nullable=False),
|
||||||
|
Column("spawn_timestamp", Float, nullable=False),
|
||||||
|
Column("despawn_timestamp", Float, nullable=False), # When this enemy should despawn
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_tables():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(metadata.create_all)
|
||||||
|
|
||||||
|
# ... (All other database functions are unchanged except the cooldown ones) ...
|
||||||
|
async def get_player(telegram_id: int):
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
result = await conn.execute(players.select().where(players.c.telegram_id == telegram_id))
|
||||||
|
row = result.first()
|
||||||
|
return row._asdict() if row else None
|
||||||
|
async def create_player(telegram_id: int, name: str):
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(players.insert().values(telegram_id=telegram_id, name=name))
|
||||||
|
await conn.execute(inventory.insert().values(player_id=telegram_id, item_id="tattered_rucksack", is_equipped=True))
|
||||||
|
await conn.commit()
|
||||||
|
return await get_player(telegram_id)
|
||||||
|
async def update_player(telegram_id: int, updates: dict):
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(players.update().where(players.c.telegram_id == telegram_id).values(**updates))
|
||||||
|
await conn.commit()
|
||||||
|
async def get_inventory(player_id: int):
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
result = await conn.execute(inventory.select().where(inventory.c.player_id == player_id))
|
||||||
|
return [row._asdict() for row in result.fetchall()]
|
||||||
|
async def get_inventory_item(item_db_id: int):
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
result = await conn.execute(inventory.select().where(inventory.c.id == item_db_id))
|
||||||
|
row = result.first()
|
||||||
|
return row._asdict() if row else None
|
||||||
|
async def add_item_to_inventory(player_id: int, item_id: str, quantity: int = 1):
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
result = await conn.execute(inventory.select().where(inventory.c.player_id == player_id, inventory.c.item_id == item_id))
|
||||||
|
existing_item = result.first()
|
||||||
|
if existing_item: stmt = inventory.update().where(inventory.c.id == existing_item.id).values(quantity=inventory.c.quantity + quantity)
|
||||||
|
else: stmt = inventory.insert().values(player_id=player_id, item_id=item_id, quantity=quantity)
|
||||||
|
await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
async def add_equipped_item_to_inventory(player_id: int, item_id: str) -> int:
|
||||||
|
"""Add a single equipped item to inventory and return its ID."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = inventory.insert().values(
|
||||||
|
player_id=player_id,
|
||||||
|
item_id=item_id,
|
||||||
|
quantity=1,
|
||||||
|
is_equipped=True
|
||||||
|
)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
return result.inserted_primary_key[0]
|
||||||
|
|
||||||
|
async def update_inventory_item(item_db_id: int, quantity: int = None, is_equipped: bool = None):
|
||||||
|
"""Update inventory item properties."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
updates = {}
|
||||||
|
if quantity is not None:
|
||||||
|
updates['quantity'] = quantity
|
||||||
|
if is_equipped is not None:
|
||||||
|
updates['is_equipped'] = is_equipped
|
||||||
|
|
||||||
|
if updates:
|
||||||
|
stmt = inventory.update().where(inventory.c.id == item_db_id).values(**updates)
|
||||||
|
await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
async def remove_item_from_inventory(item_db_id: int, quantity: int = 1):
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
result = await conn.execute(inventory.select().where(inventory.c.id == item_db_id))
|
||||||
|
item_data = result.first()
|
||||||
|
if not item_data: return
|
||||||
|
if item_data.quantity > quantity: stmt = inventory.update().where(inventory.c.id == item_db_id).values(quantity=inventory.c.quantity - quantity)
|
||||||
|
else: stmt = inventory.delete().where(inventory.c.id == item_db_id)
|
||||||
|
await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
async def drop_item_to_world(item_id: str, quantity: int, location_id: str):
|
||||||
|
"""Drop item to world. Combines with existing stacks of same item in same location."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
# Check if this item already exists in this location
|
||||||
|
result = await conn.execute(
|
||||||
|
dropped_items.select().where(
|
||||||
|
(dropped_items.c.item_id == item_id) &
|
||||||
|
(dropped_items.c.location_id == location_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_item = result.first()
|
||||||
|
|
||||||
|
if existing_item:
|
||||||
|
# Stack exists, add to it
|
||||||
|
new_quantity = existing_item.quantity + quantity
|
||||||
|
stmt = dropped_items.update().where(dropped_items.c.id == existing_item.id).values(
|
||||||
|
quantity=new_quantity,
|
||||||
|
drop_timestamp=time.time() # Update timestamp
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Create new stack
|
||||||
|
stmt = dropped_items.insert().values(
|
||||||
|
item_id=item_id,
|
||||||
|
quantity=quantity,
|
||||||
|
location_id=location_id,
|
||||||
|
drop_timestamp=time.time()
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
async def get_dropped_items_in_location(location_id: str):
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
result = await conn.execute(dropped_items.select().where(dropped_items.c.location_id == location_id).limit(10))
|
||||||
|
return [row._asdict() for row in result.fetchall()]
|
||||||
|
async def get_dropped_item(dropped_item_id: int):
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
result = await conn.execute(dropped_items.select().where(dropped_items.c.id == dropped_item_id))
|
||||||
|
row = result.first()
|
||||||
|
return row._asdict() if row else None
|
||||||
|
async def remove_dropped_item(dropped_item_id: int):
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(dropped_items.delete().where(dropped_items.c.id == dropped_item_id))
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
async def update_dropped_item(dropped_item_id: int, new_quantity: int):
|
||||||
|
"""Update the quantity of a dropped item."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = dropped_items.update().where(dropped_items.c.id == dropped_item_id).values(quantity=new_quantity)
|
||||||
|
await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
async def remove_expired_dropped_items(timestamp_limit: float) -> int:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = dropped_items.delete().where(dropped_items.c.drop_timestamp < timestamp_limit)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
return result.rowcount
|
||||||
|
|
||||||
|
async def regenerate_all_players_stamina() -> int:
|
||||||
|
"""
|
||||||
|
Regenerate stamina for all active players.
|
||||||
|
|
||||||
|
Recovery formula:
|
||||||
|
- Base recovery: 1 stamina per cycle (5 minutes)
|
||||||
|
- Endurance bonus: +1 stamina per 10 endurance points
|
||||||
|
- Example: 5 endurance = 1 stamina, 15 endurance = 2 stamina, 25 endurance = 3 stamina
|
||||||
|
- Only regenerates up to max_stamina
|
||||||
|
- Only regenerates for living players
|
||||||
|
"""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
# Get all living players who are below max stamina
|
||||||
|
result = await conn.execute(
|
||||||
|
players.select().where(
|
||||||
|
(players.c.is_dead == False) &
|
||||||
|
(players.c.stamina < players.c.max_stamina)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
players_to_update = result.fetchall()
|
||||||
|
|
||||||
|
updated_count = 0
|
||||||
|
for player in players_to_update:
|
||||||
|
# Calculate stamina recovery
|
||||||
|
base_recovery = 1
|
||||||
|
endurance_bonus = player.endurance // 10 # +1 per 10 endurance
|
||||||
|
total_recovery = base_recovery + endurance_bonus
|
||||||
|
|
||||||
|
# Calculate new stamina (capped at max)
|
||||||
|
new_stamina = min(player.stamina + total_recovery, player.max_stamina)
|
||||||
|
|
||||||
|
# Only update if there's actually a change
|
||||||
|
if new_stamina > player.stamina:
|
||||||
|
await conn.execute(
|
||||||
|
players.update()
|
||||||
|
.where(players.c.telegram_id == player.telegram_id)
|
||||||
|
.values(stamina=new_stamina)
|
||||||
|
)
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
|
return updated_count
|
||||||
|
|
||||||
|
COOLDOWN_DURATION = 300
|
||||||
|
async def set_cooldown(instance_id: str):
|
||||||
|
expiry_time = time.time() + COOLDOWN_DURATION
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
update_stmt = interactable_cooldowns.update().where(interactable_cooldowns.c.interactable_instance_id == instance_id).values(expiry_timestamp=expiry_time)
|
||||||
|
result = await conn.execute(update_stmt)
|
||||||
|
if result.rowcount == 0:
|
||||||
|
insert_stmt = interactable_cooldowns.insert().values(interactable_instance_id=instance_id, expiry_timestamp=expiry_time)
|
||||||
|
await conn.execute(insert_stmt)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
# --- Combat Functions ---
|
||||||
|
async def create_combat(player_id: int, npc_id: str, npc_hp: int, npc_max_hp: int, location_id: str, from_wandering_enemy: bool = False):
|
||||||
|
"""Start a new combat encounter."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = active_combats.insert().values(
|
||||||
|
player_id=player_id,
|
||||||
|
npc_id=npc_id,
|
||||||
|
npc_hp=npc_hp,
|
||||||
|
npc_max_hp=npc_max_hp,
|
||||||
|
turn="player",
|
||||||
|
turn_started_at=time.time(),
|
||||||
|
location_id=location_id,
|
||||||
|
player_status_effects="[]",
|
||||||
|
npc_status_effects="[]",
|
||||||
|
from_wandering_enemy=from_wandering_enemy
|
||||||
|
)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
return result.inserted_primary_key[0]
|
||||||
|
|
||||||
|
async def get_combat(player_id: int):
|
||||||
|
"""Get active combat for a player."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = active_combats.select().where(active_combats.c.player_id == player_id)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
row = result.first()
|
||||||
|
return row._asdict() if row else None
|
||||||
|
|
||||||
|
async def update_combat(player_id: int, updates: dict):
|
||||||
|
"""Update combat state."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = active_combats.update().where(active_combats.c.player_id == player_id).values(**updates)
|
||||||
|
await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
async def end_combat(player_id: int):
|
||||||
|
"""Remove active combat."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = active_combats.delete().where(active_combats.c.player_id == player_id)
|
||||||
|
await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
async def get_all_idle_combats(idle_threshold: float):
|
||||||
|
"""Get all combats where the turn has been idle too long."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = active_combats.select().where(active_combats.c.turn_started_at < idle_threshold)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
return [row._asdict() for row in result.fetchall()]
|
||||||
|
|
||||||
|
async def create_player_corpse(player_name: str, location_id: str, items: str):
|
||||||
|
"""Create a player corpse bag."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = player_corpses.insert().values(
|
||||||
|
player_name=player_name,
|
||||||
|
location_id=location_id,
|
||||||
|
items=items,
|
||||||
|
death_timestamp=time.time()
|
||||||
|
)
|
||||||
|
await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
async def get_player_corpses_in_location(location_id: str):
|
||||||
|
"""Get all player corpses in a location."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = player_corpses.select().where(player_corpses.c.location_id == location_id)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
return [row._asdict() for row in result.fetchall()]
|
||||||
|
|
||||||
|
async def get_player_corpse(corpse_id: int):
|
||||||
|
"""Get a specific player corpse."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = player_corpses.select().where(player_corpses.c.id == corpse_id)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
row = result.first()
|
||||||
|
return row._asdict() if row else None
|
||||||
|
|
||||||
|
async def update_player_corpse(corpse_id: int, items: str):
|
||||||
|
"""Update items in a player corpse."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = player_corpses.update().where(player_corpses.c.id == corpse_id).values(items=items)
|
||||||
|
await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
async def remove_player_corpse(corpse_id: int):
|
||||||
|
"""Remove a player corpse."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = player_corpses.delete().where(player_corpses.c.id == corpse_id)
|
||||||
|
await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
async def remove_expired_player_corpses(timestamp_limit: float) -> int:
|
||||||
|
"""Remove old player corpses."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = player_corpses.delete().where(player_corpses.c.death_timestamp < timestamp_limit)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
return result.rowcount
|
||||||
|
|
||||||
|
async def create_npc_corpse(npc_id: str, location_id: str, loot_remaining: str):
|
||||||
|
"""Create an NPC corpse for scavenging."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = npc_corpses.insert().values(
|
||||||
|
npc_id=npc_id,
|
||||||
|
location_id=location_id,
|
||||||
|
loot_remaining=loot_remaining,
|
||||||
|
death_timestamp=time.time()
|
||||||
|
)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
return result.inserted_primary_key[0]
|
||||||
|
|
||||||
|
async def get_npc_corpses_in_location(location_id: str):
|
||||||
|
"""Get all NPC corpses in a location."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = npc_corpses.select().where(npc_corpses.c.location_id == location_id)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
return [row._asdict() for row in result.fetchall()]
|
||||||
|
|
||||||
|
async def get_npc_corpse(corpse_id: int):
|
||||||
|
"""Get a specific NPC corpse."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = npc_corpses.select().where(npc_corpses.c.id == corpse_id)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
row = result.first()
|
||||||
|
return row._asdict() if row else None
|
||||||
|
|
||||||
|
async def update_npc_corpse(corpse_id: int, loot_remaining: str):
|
||||||
|
"""Update loot in an NPC corpse."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = npc_corpses.update().where(npc_corpses.c.id == corpse_id).values(loot_remaining=loot_remaining)
|
||||||
|
await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
async def remove_npc_corpse(corpse_id: int):
|
||||||
|
"""Remove an NPC corpse."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = npc_corpses.delete().where(npc_corpses.c.id == corpse_id)
|
||||||
|
await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
async def remove_expired_npc_corpses(timestamp_limit: float) -> int:
|
||||||
|
"""Remove old NPC corpses."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = npc_corpses.delete().where(npc_corpses.c.death_timestamp < timestamp_limit)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
await conn.commit()
|
||||||
|
return result.rowcount
|
||||||
|
|
||||||
|
async def get_cooldown(instance_id: str) -> int:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = interactable_cooldowns.select().where(interactable_cooldowns.c.interactable_instance_id == instance_id)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
cooldown = result.first()
|
||||||
|
if cooldown and cooldown.expiry_timestamp > time.time():
|
||||||
|
return int(cooldown.expiry_timestamp - time.time())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def get_cooldowns_for_location(location_id: str) -> Set[str]:
|
||||||
|
"""Get all active cooldown instance IDs for a location by checking the prefix."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = interactable_cooldowns.select().where(
|
||||||
|
interactable_cooldowns.c.interactable_instance_id.startswith(location_id + "_"),
|
||||||
|
interactable_cooldowns.c.expiry_timestamp > time.time()
|
||||||
|
)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
return {row.interactable_instance_id for row in result.fetchall()}
|
||||||
|
|
||||||
|
# --- Image Cache Functions ---
|
||||||
|
async def get_cached_image(image_path: str):
|
||||||
|
"""Get the Telegram file_id for a cached image."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
stmt = image_cache.select().where(image_cache.c.image_path == image_path)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
row = result.first()
|
||||||
|
return row.telegram_file_id if row else None
|
||||||
|
|
||||||
|
async def cache_image(image_path: str, telegram_file_id: str):
|
||||||
|
"""Store a Telegram file_id for an image path."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
# Check if already exists
|
||||||
|
stmt = image_cache.select().where(image_cache.c.image_path == image_path)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
existing = result.first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update existing entry
|
||||||
|
update_stmt = image_cache.update().where(
|
||||||
|
image_cache.c.image_path == image_path
|
||||||
|
).values(telegram_file_id=telegram_file_id, uploaded_at=time.time())
|
||||||
|
await conn.execute(update_stmt)
|
||||||
|
else:
|
||||||
|
# Insert new entry
|
||||||
|
insert_stmt = image_cache.insert().values(
|
||||||
|
image_path=image_path,
|
||||||
|
telegram_file_id=telegram_file_id,
|
||||||
|
uploaded_at=time.time()
|
||||||
|
)
|
||||||
|
await conn.execute(insert_stmt)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Wandering Enemies Functions ---
|
||||||
|
async def spawn_wandering_enemy(npc_id: str, location_id: str, lifetime_seconds: int = 600):
|
||||||
|
"""Spawn a wandering enemy at a location. Lifetime defaults to 10 minutes."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
current_time = time.time()
|
||||||
|
despawn_time = current_time + lifetime_seconds
|
||||||
|
|
||||||
|
await conn.execute(wandering_enemies.insert().values(
|
||||||
|
npc_id=npc_id,
|
||||||
|
location_id=location_id,
|
||||||
|
spawn_timestamp=current_time,
|
||||||
|
despawn_timestamp=despawn_time
|
||||||
|
))
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_wandering_enemies_in_location(location_id: str):
|
||||||
|
"""Get all active wandering enemies at a location."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
current_time = time.time()
|
||||||
|
stmt = wandering_enemies.select().where(
|
||||||
|
wandering_enemies.c.location_id == location_id,
|
||||||
|
wandering_enemies.c.despawn_timestamp > current_time
|
||||||
|
)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
return [row._asdict() for row in result.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_wandering_enemy(enemy_id: int):
|
||||||
|
"""Remove a wandering enemy (when engaged in combat or manually despawned)."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(wandering_enemies.delete().where(wandering_enemies.c.id == enemy_id))
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_expired_wandering_enemies():
|
||||||
|
"""Remove all expired wandering enemies."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
current_time = time.time()
|
||||||
|
result = await conn.execute(
|
||||||
|
wandering_enemies.delete().where(wandering_enemies.c.despawn_timestamp <= current_time)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return result.rowcount # Number of enemies despawned
|
||||||
|
|
||||||
|
|
||||||
|
async def get_wandering_enemy_count_in_location(location_id: str) -> int:
|
||||||
|
"""Count active wandering enemies at a location."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
current_time = time.time()
|
||||||
|
from sqlalchemy import func
|
||||||
|
stmt = wandering_enemies.select().where(
|
||||||
|
wandering_enemies.c.location_id == location_id,
|
||||||
|
wandering_enemies.c.despawn_timestamp > current_time
|
||||||
|
)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
return len(result.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_active_wandering_enemies():
|
||||||
|
"""Get all active wandering enemies across all locations."""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
current_time = time.time()
|
||||||
|
stmt = wandering_enemies.select().where(
|
||||||
|
wandering_enemies.c.despawn_timestamp > current_time
|
||||||
|
)
|
||||||
|
result = await conn.execute(stmt)
|
||||||
|
return [row._asdict() for row in result.fetchall()]
|
||||||
1268
bot/handlers.py
Normal file
603
bot/keyboards.py
Normal file
@@ -0,0 +1,603 @@
|
|||||||
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
from data.world_loader import game_world
|
||||||
|
from data.items import ITEMS
|
||||||
|
|
||||||
|
# ... (main_menu_keyboard, move_keyboard are unchanged) ...
|
||||||
|
def main_menu_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
keyboard = [[InlineKeyboardButton("🗺️ Move", callback_data="move_menu"), InlineKeyboardButton("👀 Inspect Area", callback_data="inspect_area")], [InlineKeyboardButton("👤 Profile", callback_data="profile"), InlineKeyboardButton("🎒 Inventory", callback_data="inventory_menu")]]
|
||||||
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
async def move_keyboard(current_location_id: str, player_id: int) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Create a movement keyboard with stamina costs.
|
||||||
|
Layout:
|
||||||
|
[ North (⚡5) ]
|
||||||
|
[ West (⚡5) ] [ East (⚡5) ]
|
||||||
|
[ South (⚡5) ]
|
||||||
|
[ Other exits (inside, down, etc.) ]
|
||||||
|
[ Back ]
|
||||||
|
"""
|
||||||
|
from bot import database, logic
|
||||||
|
|
||||||
|
keyboard = []
|
||||||
|
location = game_world.get_location(current_location_id)
|
||||||
|
player = await database.get_player(player_id)
|
||||||
|
inventory = await database.get_inventory(player_id)
|
||||||
|
|
||||||
|
if location and player:
|
||||||
|
# Dictionary to hold direction buttons
|
||||||
|
compass_directions = {}
|
||||||
|
other_exits = []
|
||||||
|
|
||||||
|
for direction, destination_id in location.exits.items():
|
||||||
|
destination = game_world.get_location(destination_id)
|
||||||
|
if destination:
|
||||||
|
# Calculate stamina cost for this specific route
|
||||||
|
stamina_cost = logic.calculate_travel_stamina_cost(player, inventory, location, destination)
|
||||||
|
|
||||||
|
# Map direction to emoji and label
|
||||||
|
direction_lower = direction.lower()
|
||||||
|
if direction_lower == "north":
|
||||||
|
emoji = "⬆️"
|
||||||
|
compass_directions["north"] = InlineKeyboardButton(
|
||||||
|
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||||
|
callback_data=f"move:{destination_id}"
|
||||||
|
)
|
||||||
|
elif direction_lower == "south":
|
||||||
|
emoji = "⬇️"
|
||||||
|
compass_directions["south"] = InlineKeyboardButton(
|
||||||
|
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||||
|
callback_data=f"move:{destination_id}"
|
||||||
|
)
|
||||||
|
elif direction_lower == "east":
|
||||||
|
emoji = "➡️"
|
||||||
|
compass_directions["east"] = InlineKeyboardButton(
|
||||||
|
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||||
|
callback_data=f"move:{destination_id}"
|
||||||
|
)
|
||||||
|
elif direction_lower == "west":
|
||||||
|
emoji = "⬅️"
|
||||||
|
compass_directions["west"] = InlineKeyboardButton(
|
||||||
|
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||||
|
callback_data=f"move:{destination_id}"
|
||||||
|
)
|
||||||
|
elif direction_lower == "northeast":
|
||||||
|
emoji = "↗️"
|
||||||
|
compass_directions["northeast"] = InlineKeyboardButton(
|
||||||
|
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||||
|
callback_data=f"move:{destination_id}"
|
||||||
|
)
|
||||||
|
elif direction_lower == "northwest":
|
||||||
|
emoji = "↖️"
|
||||||
|
compass_directions["northwest"] = InlineKeyboardButton(
|
||||||
|
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||||
|
callback_data=f"move:{destination_id}"
|
||||||
|
)
|
||||||
|
elif direction_lower == "southeast":
|
||||||
|
emoji = "↘️"
|
||||||
|
compass_directions["southeast"] = InlineKeyboardButton(
|
||||||
|
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||||
|
callback_data=f"move:{destination_id}"
|
||||||
|
)
|
||||||
|
elif direction_lower == "southwest":
|
||||||
|
emoji = "↙️"
|
||||||
|
compass_directions["southwest"] = InlineKeyboardButton(
|
||||||
|
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||||
|
callback_data=f"move:{destination_id}"
|
||||||
|
)
|
||||||
|
elif direction_lower == "inside":
|
||||||
|
emoji = "🚪"
|
||||||
|
other_exits.append(InlineKeyboardButton(
|
||||||
|
f"{emoji} Enter {destination.name} (⚡{stamina_cost})",
|
||||||
|
callback_data=f"move:{destination_id}"
|
||||||
|
))
|
||||||
|
elif direction_lower == "outside":
|
||||||
|
emoji = "🚪"
|
||||||
|
other_exits.append(InlineKeyboardButton(
|
||||||
|
f"{emoji} Exit to {destination.name} (⚡{stamina_cost})",
|
||||||
|
callback_data=f"move:{destination_id}"
|
||||||
|
))
|
||||||
|
elif direction_lower == "down":
|
||||||
|
emoji = "⬇️"
|
||||||
|
other_exits.append(InlineKeyboardButton(
|
||||||
|
f"{emoji} Descend to {destination.name} (⚡{stamina_cost})",
|
||||||
|
callback_data=f"move:{destination_id}"
|
||||||
|
))
|
||||||
|
elif direction_lower == "up":
|
||||||
|
emoji = "⬆️"
|
||||||
|
other_exits.append(InlineKeyboardButton(
|
||||||
|
f"{emoji} Ascend to {destination.name} (⚡{stamina_cost})",
|
||||||
|
callback_data=f"move:{destination_id}"
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# Generic fallback for any other direction
|
||||||
|
emoji = "🔀"
|
||||||
|
other_exits.append(InlineKeyboardButton(
|
||||||
|
f"{emoji} {destination.name} (⚡{stamina_cost})",
|
||||||
|
callback_data=f"move:{destination_id}"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Build compass layout
|
||||||
|
# Row 1: Northwest, North, Northeast
|
||||||
|
top_row = []
|
||||||
|
if "northwest" in compass_directions:
|
||||||
|
top_row.append(compass_directions["northwest"])
|
||||||
|
if "north" in compass_directions:
|
||||||
|
top_row.append(compass_directions["north"])
|
||||||
|
if "northeast" in compass_directions:
|
||||||
|
top_row.append(compass_directions["northeast"])
|
||||||
|
if top_row:
|
||||||
|
keyboard.append(top_row)
|
||||||
|
|
||||||
|
# Row 2: West and/or East
|
||||||
|
middle_row = []
|
||||||
|
if "west" in compass_directions:
|
||||||
|
middle_row.append(compass_directions["west"])
|
||||||
|
if "east" in compass_directions:
|
||||||
|
middle_row.append(compass_directions["east"])
|
||||||
|
if middle_row:
|
||||||
|
keyboard.append(middle_row)
|
||||||
|
|
||||||
|
# Row 3: Southwest, South, Southeast
|
||||||
|
bottom_row = []
|
||||||
|
if "southwest" in compass_directions:
|
||||||
|
bottom_row.append(compass_directions["southwest"])
|
||||||
|
if "south" in compass_directions:
|
||||||
|
bottom_row.append(compass_directions["south"])
|
||||||
|
if "southeast" in compass_directions:
|
||||||
|
bottom_row.append(compass_directions["southeast"])
|
||||||
|
if bottom_row:
|
||||||
|
keyboard.append(bottom_row)
|
||||||
|
|
||||||
|
# Add other exits (inside, outside, up, down, etc.)
|
||||||
|
for exit_button in other_exits:
|
||||||
|
keyboard.append([exit_button])
|
||||||
|
|
||||||
|
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="main_menu")])
|
||||||
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
async def inspect_keyboard(location_id: str, dropped_items: list, wandering_enemies: list = None) -> InlineKeyboardMarkup:
|
||||||
|
from bot import database
|
||||||
|
from data.npcs import NPCS
|
||||||
|
|
||||||
|
keyboard = []
|
||||||
|
location = game_world.get_location(location_id)
|
||||||
|
|
||||||
|
# Show wandering enemies first if present (in pairs, emoji only)
|
||||||
|
if wandering_enemies:
|
||||||
|
row = []
|
||||||
|
for enemy in wandering_enemies:
|
||||||
|
npc_def = NPCS.get(enemy['npc_id'])
|
||||||
|
if npc_def:
|
||||||
|
button = InlineKeyboardButton(
|
||||||
|
f"⚠️ {npc_def.emoji} {npc_def.name}",
|
||||||
|
callback_data=f"attack_wandering:{enemy['id']}"
|
||||||
|
)
|
||||||
|
row.append(button)
|
||||||
|
if len(row) == 2:
|
||||||
|
keyboard.append(row)
|
||||||
|
row = []
|
||||||
|
if row: # Add remaining enemy if odd number
|
||||||
|
keyboard.append(row)
|
||||||
|
if wandering_enemies:
|
||||||
|
keyboard.append([InlineKeyboardButton("--- Environment ---", callback_data="no_op")])
|
||||||
|
|
||||||
|
# Show interactables in pairs when text is short enough
|
||||||
|
if location:
|
||||||
|
row = []
|
||||||
|
for instance_id, interactable in location.interactables.items():
|
||||||
|
label = interactable.name
|
||||||
|
# Check if ANY action is available (not on cooldown)
|
||||||
|
has_available_action = False
|
||||||
|
for action_id in interactable.actions.keys():
|
||||||
|
cooldown_key = f"{instance_id}:{action_id}"
|
||||||
|
if await database.get_cooldown(cooldown_key) == 0:
|
||||||
|
has_available_action = True
|
||||||
|
break
|
||||||
|
if not has_available_action and len(interactable.actions) > 0:
|
||||||
|
label += " ⏳"
|
||||||
|
|
||||||
|
# Include location_id in callback data for efficient lookup
|
||||||
|
button = InlineKeyboardButton(label, callback_data=f"inspect:{location_id}:{instance_id}")
|
||||||
|
|
||||||
|
# If text is short (< 20 chars), try to pair it
|
||||||
|
if len(label) < 20:
|
||||||
|
row.append(button)
|
||||||
|
if len(row) == 2:
|
||||||
|
keyboard.append(row)
|
||||||
|
row = []
|
||||||
|
else:
|
||||||
|
# Long text, add any pending row first, then add this one alone
|
||||||
|
if row:
|
||||||
|
keyboard.append(row)
|
||||||
|
row = []
|
||||||
|
keyboard.append([button])
|
||||||
|
|
||||||
|
# Add remaining button if odd number
|
||||||
|
if row:
|
||||||
|
keyboard.append(row)
|
||||||
|
|
||||||
|
# Show player corpse bags
|
||||||
|
player_corpses = await database.get_player_corpses_in_location(location_id)
|
||||||
|
if player_corpses:
|
||||||
|
keyboard.append([InlineKeyboardButton("--- Fallen survivors ---", callback_data="no_op")])
|
||||||
|
row = []
|
||||||
|
for corpse in player_corpses:
|
||||||
|
button = InlineKeyboardButton(
|
||||||
|
f"🎒 {corpse['player_name']}'s bag",
|
||||||
|
callback_data=f"loot_player_corpse:{corpse['id']}"
|
||||||
|
)
|
||||||
|
row.append(button)
|
||||||
|
if len(row) == 2:
|
||||||
|
keyboard.append(row)
|
||||||
|
row = []
|
||||||
|
if row:
|
||||||
|
keyboard.append(row)
|
||||||
|
|
||||||
|
# Show NPC corpses
|
||||||
|
npc_corpses = await database.get_npc_corpses_in_location(location_id)
|
||||||
|
if npc_corpses:
|
||||||
|
if not player_corpses: # Only add separator if not already added
|
||||||
|
keyboard.append([InlineKeyboardButton("--- Corpses ---", callback_data="no_op")])
|
||||||
|
row = []
|
||||||
|
for corpse in npc_corpses:
|
||||||
|
from data.npcs import NPCS
|
||||||
|
npc_def = NPCS.get(corpse['npc_id'])
|
||||||
|
if npc_def:
|
||||||
|
button = InlineKeyboardButton(
|
||||||
|
f"{npc_def.emoji} {npc_def.name}",
|
||||||
|
callback_data=f"scavenge_npc_corpse:{corpse['id']}"
|
||||||
|
)
|
||||||
|
row.append(button)
|
||||||
|
if len(row) == 2:
|
||||||
|
keyboard.append(row)
|
||||||
|
row = []
|
||||||
|
if row:
|
||||||
|
keyboard.append(row)
|
||||||
|
|
||||||
|
if dropped_items:
|
||||||
|
keyboard.append([InlineKeyboardButton("--- Items on the ground ---", callback_data="no_op")])
|
||||||
|
row = []
|
||||||
|
for item in dropped_items:
|
||||||
|
item_def = ITEMS.get(item['item_id'], {})
|
||||||
|
emoji = item_def.get('emoji', '❔')
|
||||||
|
quantity_text = f" x{item['quantity']}" if item['quantity'] > 1 else ""
|
||||||
|
button = InlineKeyboardButton(
|
||||||
|
f"{emoji} {item_def.get('name', 'Unknown')}{quantity_text}",
|
||||||
|
callback_data=f"pickup_menu:{item['id']}"
|
||||||
|
)
|
||||||
|
row.append(button)
|
||||||
|
if len(row) == 2:
|
||||||
|
keyboard.append(row)
|
||||||
|
row = []
|
||||||
|
if row:
|
||||||
|
keyboard.append(row)
|
||||||
|
|
||||||
|
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="main_menu")])
|
||||||
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
def pickup_options_keyboard(item_id: int, item_name: str, quantity: int) -> InlineKeyboardMarkup:
|
||||||
|
"""Create pickup options keyboard with x1, x5, x10, and All options."""
|
||||||
|
keyboard = []
|
||||||
|
|
||||||
|
if quantity == 1:
|
||||||
|
# Just show a single "Pick" button for single items
|
||||||
|
keyboard.append([InlineKeyboardButton("📦 Pick", callback_data=f"pickup:{item_id}:1")])
|
||||||
|
else:
|
||||||
|
# Build pickup row with available options
|
||||||
|
pickup_row = [InlineKeyboardButton("📦 Pick x1", callback_data=f"pickup:{item_id}:1")]
|
||||||
|
|
||||||
|
if quantity >= 5:
|
||||||
|
pickup_row.append(InlineKeyboardButton("📦 Pick x5", callback_data=f"pickup:{item_id}:5"))
|
||||||
|
if quantity >= 10:
|
||||||
|
pickup_row.append(InlineKeyboardButton("📦 Pick x10", callback_data=f"pickup:{item_id}:10"))
|
||||||
|
|
||||||
|
# Split into rows if more than 2 buttons
|
||||||
|
if len(pickup_row) > 2:
|
||||||
|
keyboard.append(pickup_row[:2])
|
||||||
|
keyboard.append(pickup_row[2:])
|
||||||
|
else:
|
||||||
|
keyboard.append(pickup_row)
|
||||||
|
|
||||||
|
# Add "Pick All" option
|
||||||
|
keyboard.append([InlineKeyboardButton(f"📦 Pick All ({quantity})", callback_data=f"pickup:{item_id}:all")])
|
||||||
|
|
||||||
|
# Back button
|
||||||
|
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="inspect_area")])
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
async def actions_keyboard(location_id: str, instance_id: str) -> InlineKeyboardMarkup:
|
||||||
|
from bot import database
|
||||||
|
keyboard = []
|
||||||
|
|
||||||
|
location = game_world.get_location(location_id)
|
||||||
|
|
||||||
|
if location:
|
||||||
|
interactable = location.get_interactable(instance_id)
|
||||||
|
if interactable:
|
||||||
|
for action_id, action in interactable.actions.items():
|
||||||
|
cooldown_key = f"{instance_id}:{action_id}"
|
||||||
|
cooldown = await database.get_cooldown(cooldown_key)
|
||||||
|
label = action.label
|
||||||
|
# Add stamina cost to the label
|
||||||
|
if action.stamina_cost > 0:
|
||||||
|
label += f" (⚡{action.stamina_cost})"
|
||||||
|
if cooldown > 0:
|
||||||
|
label += " ⏳"
|
||||||
|
keyboard.append([InlineKeyboardButton(label, callback_data=f"action:{location_id}:{instance_id}:{action_id}")])
|
||||||
|
|
||||||
|
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data=f"inspect_area_menu:{location_id}")])
|
||||||
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
# ... (inventory_keyboard, inventory_item_actions_keyboard are unchanged) ...
|
||||||
|
def inventory_keyboard(inventory_items: list) -> InlineKeyboardMarkup:
|
||||||
|
keyboard = []
|
||||||
|
if inventory_items:
|
||||||
|
# Categorize and sort items
|
||||||
|
# Group items by item_id and equipped status to handle stacking properly
|
||||||
|
item_groups = {}
|
||||||
|
|
||||||
|
for item in inventory_items:
|
||||||
|
item_def = ITEMS.get(item['item_id'], {})
|
||||||
|
item_type = item_def.get('type', 'resource')
|
||||||
|
item_name = item_def.get('name', 'Unknown')
|
||||||
|
is_equipped = item.get('is_equipped', False)
|
||||||
|
|
||||||
|
# Create a unique key for grouping: item_id + equipped status
|
||||||
|
group_key = (item['item_id'], is_equipped)
|
||||||
|
|
||||||
|
if group_key not in item_groups:
|
||||||
|
item_groups[group_key] = {
|
||||||
|
'name': item_name,
|
||||||
|
'def': item_def,
|
||||||
|
'type': item_type,
|
||||||
|
'is_equipped': is_equipped,
|
||||||
|
'items': []
|
||||||
|
}
|
||||||
|
item_groups[group_key]['items'].append(item)
|
||||||
|
|
||||||
|
# Categorize groups
|
||||||
|
equipped = []
|
||||||
|
consumables = []
|
||||||
|
weapons = []
|
||||||
|
equipment = []
|
||||||
|
resources = []
|
||||||
|
quest_items = []
|
||||||
|
|
||||||
|
for group_key, group_data in item_groups.items():
|
||||||
|
item_name = group_data['name']
|
||||||
|
item_def = group_data['def']
|
||||||
|
item_type = group_data['type']
|
||||||
|
is_equipped = group_data['is_equipped']
|
||||||
|
items_list = group_data['items']
|
||||||
|
|
||||||
|
# Calculate total quantity and weight/volume for this group
|
||||||
|
total_quantity = sum(itm['quantity'] for itm in items_list)
|
||||||
|
weight_per_item = item_def.get('weight', 0)
|
||||||
|
volume_per_item = item_def.get('volume', 0)
|
||||||
|
total_weight = weight_per_item * total_quantity
|
||||||
|
total_volume = volume_per_item * total_quantity
|
||||||
|
|
||||||
|
# Use the first item's ID for the callback (they're all the same item type)
|
||||||
|
first_item_id = items_list[0]['id']
|
||||||
|
|
||||||
|
# Create item data tuple: (name, item_def, first_item_id, quantity, weight, volume, is_equipped)
|
||||||
|
item_tuple = (item_name, item_def, first_item_id, total_quantity, total_weight, total_volume, is_equipped)
|
||||||
|
|
||||||
|
# Only equipped items go to equipped section
|
||||||
|
if is_equipped:
|
||||||
|
equipped.append(item_tuple)
|
||||||
|
elif item_type == 'consumable':
|
||||||
|
consumables.append(item_tuple)
|
||||||
|
elif item_type == 'weapon':
|
||||||
|
weapons.append(item_tuple)
|
||||||
|
elif item_type == 'equipment':
|
||||||
|
equipment.append(item_tuple)
|
||||||
|
elif item_type == 'quest':
|
||||||
|
quest_items.append(item_tuple)
|
||||||
|
else:
|
||||||
|
resources.append(item_tuple)
|
||||||
|
|
||||||
|
# Sort each category alphabetically by name
|
||||||
|
equipped.sort(key=lambda x: x[0])
|
||||||
|
consumables.sort(key=lambda x: x[0])
|
||||||
|
weapons.sort(key=lambda x: x[0])
|
||||||
|
equipment.sort(key=lambda x: x[0])
|
||||||
|
resources.sort(key=lambda x: x[0])
|
||||||
|
quest_items.sort(key=lambda x: x[0])
|
||||||
|
|
||||||
|
# Build keyboard sections
|
||||||
|
def add_section(section_name, items_list):
|
||||||
|
if items_list:
|
||||||
|
keyboard.append([InlineKeyboardButton(f"--- {section_name} ---", callback_data="no_op")])
|
||||||
|
row = []
|
||||||
|
for item_name, item_def, item_id, quantity, weight, volume, is_equipped in items_list:
|
||||||
|
emoji = item_def.get('emoji', '❔')
|
||||||
|
quantity_text = f" x{quantity}" if quantity > 1 else ""
|
||||||
|
equipped_marker = " ✓" if is_equipped else ""
|
||||||
|
# Round to 2 decimals
|
||||||
|
weight_vol_text = f" ({weight:.2f}kg, {volume:.2f}vol)" if quantity > 0 else ""
|
||||||
|
|
||||||
|
button = InlineKeyboardButton(
|
||||||
|
f"{emoji} {item_name}{quantity_text}{equipped_marker}{weight_vol_text}",
|
||||||
|
callback_data=f"inventory_item:{item_id}"
|
||||||
|
)
|
||||||
|
row.append(button)
|
||||||
|
if len(row) == 2:
|
||||||
|
keyboard.append(row)
|
||||||
|
row = []
|
||||||
|
# Add remaining item if odd number
|
||||||
|
if row:
|
||||||
|
keyboard.append(row)
|
||||||
|
|
||||||
|
# Add sections in order
|
||||||
|
add_section("Equipped", equipped)
|
||||||
|
add_section("Consumables", consumables)
|
||||||
|
add_section("Weapons", weapons)
|
||||||
|
add_section("Equipment", equipment)
|
||||||
|
add_section("Resources", resources)
|
||||||
|
add_section("Quest Items", quest_items)
|
||||||
|
|
||||||
|
if not keyboard:
|
||||||
|
keyboard.append([InlineKeyboardButton("--- Inventory is empty ---", callback_data="no_op")])
|
||||||
|
else:
|
||||||
|
keyboard.append([InlineKeyboardButton("--- Inventory is empty ---", callback_data="no_op")])
|
||||||
|
|
||||||
|
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="main_menu")])
|
||||||
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
def inventory_item_actions_keyboard(item_db_id: int, item_def: dict, is_equipped: bool = False, quantity: int = 1) -> InlineKeyboardMarkup:
|
||||||
|
keyboard = []
|
||||||
|
|
||||||
|
# Use button for consumables
|
||||||
|
if item_def.get('type') == 'consumable':
|
||||||
|
keyboard.append([InlineKeyboardButton("➡️ Use Item", callback_data=f"inventory_use:{item_db_id}")])
|
||||||
|
|
||||||
|
# Equip/Unequip button for weapons and equipment
|
||||||
|
if item_def.get('type') in ["weapon", "equipment"]:
|
||||||
|
if is_equipped:
|
||||||
|
keyboard.append([InlineKeyboardButton("❌ Unequip", callback_data=f"inventory_unequip:{item_db_id}")])
|
||||||
|
else:
|
||||||
|
keyboard.append([InlineKeyboardButton("✅ Equip", callback_data=f"inventory_equip:{item_db_id}")])
|
||||||
|
|
||||||
|
# Drop buttons - simplified for single items
|
||||||
|
if quantity == 1:
|
||||||
|
# Just show a single "Drop" button
|
||||||
|
keyboard.append([InlineKeyboardButton("🗑️ Drop", callback_data=f"inventory_drop:{item_db_id}:all")])
|
||||||
|
else:
|
||||||
|
# Show x1, x5, x10 options based on quantity
|
||||||
|
drop_row = [InlineKeyboardButton("🗑️ Drop x1", callback_data=f"inventory_drop:{item_db_id}:1")]
|
||||||
|
if quantity >= 5:
|
||||||
|
drop_row.append(InlineKeyboardButton("🗑️ Drop x5", callback_data=f"inventory_drop:{item_db_id}:5"))
|
||||||
|
if quantity >= 10:
|
||||||
|
drop_row.append(InlineKeyboardButton("🗑️ Drop x10", callback_data=f"inventory_drop:{item_db_id}:10"))
|
||||||
|
|
||||||
|
# Split into rows if more than 2 buttons
|
||||||
|
if len(drop_row) > 2:
|
||||||
|
keyboard.append(drop_row[:2])
|
||||||
|
keyboard.append(drop_row[2:])
|
||||||
|
else:
|
||||||
|
keyboard.append(drop_row)
|
||||||
|
|
||||||
|
# Add "Drop All" option
|
||||||
|
keyboard.append([InlineKeyboardButton(f"🗑️ Drop All ({quantity})", callback_data=f"inventory_drop:{item_db_id}:all")])
|
||||||
|
|
||||||
|
keyboard.append([InlineKeyboardButton("⬅️ Back to Inventory", callback_data="inventory_menu")])
|
||||||
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
async def combat_keyboard(player_id: int) -> InlineKeyboardMarkup:
|
||||||
|
"""Create combat action keyboard."""
|
||||||
|
from bot import database
|
||||||
|
keyboard = []
|
||||||
|
|
||||||
|
# Attack option
|
||||||
|
keyboard.append([InlineKeyboardButton("⚔️ Attack", callback_data="combat_attack")])
|
||||||
|
|
||||||
|
# Flee option
|
||||||
|
keyboard.append([InlineKeyboardButton("🏃 Try to Flee", callback_data="combat_flee")])
|
||||||
|
|
||||||
|
# Use item option (show consumables)
|
||||||
|
inventory_items = await database.get_inventory(player_id)
|
||||||
|
consumables = [item for item in inventory_items if ITEMS.get(item['item_id'], {}).get('type') == 'consumable']
|
||||||
|
|
||||||
|
if consumables:
|
||||||
|
keyboard.append([InlineKeyboardButton("💊 Use Item", callback_data="combat_use_item_menu")])
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
async def combat_items_keyboard(player_id: int) -> InlineKeyboardMarkup:
|
||||||
|
"""Show consumable items during combat."""
|
||||||
|
from bot import database
|
||||||
|
keyboard = []
|
||||||
|
|
||||||
|
inventory_items = await database.get_inventory(player_id)
|
||||||
|
consumables = [item for item in inventory_items if ITEMS.get(item['item_id'], {}).get('type') == 'consumable']
|
||||||
|
|
||||||
|
if consumables:
|
||||||
|
keyboard.append([InlineKeyboardButton("--- Select item to use ---", callback_data="no_op")])
|
||||||
|
for item in consumables:
|
||||||
|
item_def = ITEMS.get(item['item_id'], {})
|
||||||
|
emoji = item_def.get('emoji', '❔')
|
||||||
|
keyboard.append([InlineKeyboardButton(
|
||||||
|
f"{emoji} {item_def.get('name', 'Unknown')} x{item['quantity']}",
|
||||||
|
callback_data=f"combat_use_item:{item['id']}"
|
||||||
|
)])
|
||||||
|
|
||||||
|
keyboard.append([InlineKeyboardButton("⬅️ Back to Combat", callback_data="combat_back")])
|
||||||
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
def corpse_keyboard(corpse_id: int, corpse_type: str) -> InlineKeyboardMarkup:
|
||||||
|
"""Create keyboard for interacting with corpses."""
|
||||||
|
keyboard = []
|
||||||
|
|
||||||
|
if corpse_type == "player":
|
||||||
|
keyboard.append([InlineKeyboardButton("🎒 Loot Bag", callback_data=f"loot_player_corpse:{corpse_id}")])
|
||||||
|
else: # NPC corpse
|
||||||
|
keyboard.append([InlineKeyboardButton("🔪 Scavenge Corpse", callback_data=f"scavenge_npc_corpse:{corpse_id}")])
|
||||||
|
|
||||||
|
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="inspect_area")])
|
||||||
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
def player_corpse_loot_keyboard(corpse_id: int, items: list) -> InlineKeyboardMarkup:
|
||||||
|
"""Show items in a player corpse bag."""
|
||||||
|
keyboard = []
|
||||||
|
|
||||||
|
if items:
|
||||||
|
keyboard.append([InlineKeyboardButton("--- Take items ---", callback_data="no_op")])
|
||||||
|
for i, item_data in enumerate(items):
|
||||||
|
item_def = ITEMS.get(item_data['item_id'], {})
|
||||||
|
emoji = item_def.get('emoji', '❔')
|
||||||
|
keyboard.append([InlineKeyboardButton(
|
||||||
|
f"{emoji} Take {item_def.get('name', 'Unknown')} x{item_data['quantity']}",
|
||||||
|
callback_data=f"take_corpse_item:{corpse_id}:{i}"
|
||||||
|
)])
|
||||||
|
else:
|
||||||
|
keyboard.append([InlineKeyboardButton("--- Bag is empty ---", callback_data="no_op")])
|
||||||
|
|
||||||
|
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="inspect_area")])
|
||||||
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
def npc_corpse_scavenge_keyboard(corpse_id: int, loot_items: list) -> InlineKeyboardMarkup:
|
||||||
|
"""Show scavenging options for NPC corpse."""
|
||||||
|
keyboard = []
|
||||||
|
|
||||||
|
if loot_items:
|
||||||
|
keyboard.append([InlineKeyboardButton("--- Scavenge for materials ---", callback_data="no_op")])
|
||||||
|
for i, loot_data in enumerate(loot_items):
|
||||||
|
item_def = ITEMS.get(loot_data['item_id'], {})
|
||||||
|
emoji = item_def.get('emoji', '❔')
|
||||||
|
|
||||||
|
label = f"{emoji} {item_def.get('name', 'Unknown')}"
|
||||||
|
if loot_data.get('required_tool'):
|
||||||
|
tool_def = ITEMS.get(loot_data['required_tool'], {})
|
||||||
|
label += f" (needs {tool_def.get('name', 'tool')})"
|
||||||
|
|
||||||
|
keyboard.append([InlineKeyboardButton(
|
||||||
|
label,
|
||||||
|
callback_data=f"scavenge_corpse_item:{corpse_id}:{i}"
|
||||||
|
)])
|
||||||
|
else:
|
||||||
|
keyboard.append([InlineKeyboardButton("--- Nothing left to scavenge ---", callback_data="no_op")])
|
||||||
|
|
||||||
|
keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="inspect_area")])
|
||||||
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
def spend_points_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
"""Create keyboard for spending stat points."""
|
||||||
|
keyboard = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("❤️ Max HP (+10)", callback_data="spend_point:max_hp"),
|
||||||
|
InlineKeyboardButton("⚡ Stamina (+5)", callback_data="spend_point:max_stamina")
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("💪 Strength (+1)", callback_data="spend_point:strength"),
|
||||||
|
InlineKeyboardButton("🏃 Agility (+1)", callback_data="spend_point:agility")
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("💚 Endurance (+1)", callback_data="spend_point:endurance"),
|
||||||
|
InlineKeyboardButton("🧠 Intellect (+1)", callback_data="spend_point:intellect")
|
||||||
|
],
|
||||||
|
[InlineKeyboardButton("⬅️ Back to Profile", callback_data="profile")]
|
||||||
|
]
|
||||||
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
119
bot/logic.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import random
|
||||||
|
from typing import Tuple, Dict, Any
|
||||||
|
|
||||||
|
from data.items import ITEMS
|
||||||
|
from data.models import Action, Outcome
|
||||||
|
|
||||||
|
def calculate_inventory_load(player_inventory: list) -> Tuple[float, float]:
|
||||||
|
"""Calculates the total weight and volume of a player's inventory."""
|
||||||
|
total_weight = 0.0
|
||||||
|
total_volume = 0.0
|
||||||
|
for item in player_inventory:
|
||||||
|
item_def = ITEMS.get(item["item_id"])
|
||||||
|
if item_def:
|
||||||
|
total_weight += item_def["weight"] * item["quantity"]
|
||||||
|
total_volume += item_def["volume"] * item["quantity"]
|
||||||
|
return round(total_weight, 2), round(total_volume, 2)
|
||||||
|
|
||||||
|
def get_player_capacity(player_inventory: list, player_stats: dict) -> Tuple[float, float]:
|
||||||
|
"""Calculates the total carrying capacity of a player."""
|
||||||
|
base_weight_cap = player_stats['strength'] * 5 # Example formula
|
||||||
|
base_volume_cap = player_stats['strength'] * 2 # Example formula
|
||||||
|
|
||||||
|
for item in player_inventory:
|
||||||
|
if item["is_equipped"]:
|
||||||
|
item_def = ITEMS.get(item["item_id"])
|
||||||
|
if item_def and item_def.get("type") == "equipment":
|
||||||
|
effects = item_def.get("effects", {})
|
||||||
|
base_weight_cap += effects.get("capacity_weight", 0)
|
||||||
|
base_volume_cap += effects.get("capacity_volume", 0)
|
||||||
|
|
||||||
|
return base_weight_cap, base_volume_cap
|
||||||
|
|
||||||
|
def resolve_action(player_stats: dict, action_obj: Action) -> Outcome:
|
||||||
|
"""
|
||||||
|
Resolves a player action, like searching, based on stats and luck.
|
||||||
|
Returns the resulting Outcome object.
|
||||||
|
"""
|
||||||
|
# A simple success chance calculation
|
||||||
|
base_chance = 50 + (player_stats.get('intellect', 5) * 2)
|
||||||
|
roll = random.randint(1, 100)
|
||||||
|
|
||||||
|
outcome_key = "failure"
|
||||||
|
if roll <= 5 and "critical_failure" in action_obj.outcomes:
|
||||||
|
outcome_key = "critical_failure"
|
||||||
|
elif roll <= base_chance and "success" in action_obj.outcomes:
|
||||||
|
outcome_key = "success"
|
||||||
|
|
||||||
|
return action_obj.outcomes.get(outcome_key, action_obj.outcomes["failure"])
|
||||||
|
|
||||||
|
async def can_add_item_to_inventory(user_id: int, item_id: str, quantity: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Check if an item can be added to the player's inventory.
|
||||||
|
Returns (can_add, reason_if_not)
|
||||||
|
"""
|
||||||
|
from . import database
|
||||||
|
|
||||||
|
player = await database.get_player(user_id)
|
||||||
|
if not player:
|
||||||
|
return False, "Player not found."
|
||||||
|
|
||||||
|
inventory = await database.get_inventory(user_id)
|
||||||
|
item_def = ITEMS.get(item_id)
|
||||||
|
|
||||||
|
if not item_def:
|
||||||
|
return False, "Invalid item."
|
||||||
|
|
||||||
|
# Calculate current and projected weight/volume
|
||||||
|
current_weight, current_volume = calculate_inventory_load(inventory)
|
||||||
|
max_weight, max_volume = get_player_capacity(inventory, player)
|
||||||
|
|
||||||
|
item_weight = item_def["weight"] * quantity
|
||||||
|
item_volume = item_def["volume"] * quantity
|
||||||
|
|
||||||
|
new_weight = current_weight + item_weight
|
||||||
|
new_volume = current_volume + item_volume
|
||||||
|
|
||||||
|
if new_weight > max_weight:
|
||||||
|
return False, f"Too heavy! ({new_weight:.1f}/{max_weight:.1f} kg)"
|
||||||
|
|
||||||
|
if new_volume > max_volume:
|
||||||
|
return False, f"Not enough space! ({new_volume:.1f}/{max_volume:.1f} vol)"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def calculate_travel_stamina_cost(player: dict, inventory: list, from_location, to_location) -> int:
|
||||||
|
"""
|
||||||
|
Calculate stamina cost for traveling between locations.
|
||||||
|
Based on distance, endurance (reduces cost), and carried weight (increases cost).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player: Player stats dictionary
|
||||||
|
inventory: Player's inventory list
|
||||||
|
from_location: Location object being traveled from
|
||||||
|
to_location: Location object being traveled to
|
||||||
|
"""
|
||||||
|
from data.travel_helpers import calculate_base_stamina_cost
|
||||||
|
|
||||||
|
# Get base cost from shared helper (used by map and game)
|
||||||
|
distance_cost = calculate_base_stamina_cost(from_location, to_location)
|
||||||
|
|
||||||
|
# Endurance reduces cost (each point reduces by 0.5)
|
||||||
|
endurance_reduction = player['endurance'] * 0.5
|
||||||
|
|
||||||
|
# Calculate weight burden
|
||||||
|
current_weight, _ = calculate_inventory_load(inventory)
|
||||||
|
max_weight, _ = get_player_capacity(inventory, player)
|
||||||
|
|
||||||
|
# Weight penalty: if carrying more than 50% capacity, add extra cost
|
||||||
|
weight_ratio = current_weight / max_weight if max_weight > 0 else 0
|
||||||
|
weight_penalty = 0
|
||||||
|
|
||||||
|
if weight_ratio > 0.5:
|
||||||
|
# Each 10% over 50% adds 1 stamina
|
||||||
|
weight_penalty = int((weight_ratio - 0.5) * 10)
|
||||||
|
|
||||||
|
# Calculate final cost (minimum 3)
|
||||||
|
final_cost = max(3, int(distance_cost - endurance_reduction + weight_penalty))
|
||||||
|
|
||||||
|
return final_cost
|
||||||
119
bot/spawn_manager.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""
|
||||||
|
Global Wandering Enemy Spawn Manager
|
||||||
|
Runs periodically to spawn/despawn enemies based on location danger levels.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from typing import Dict, List
|
||||||
|
from bot import database
|
||||||
|
from data.npcs import (
|
||||||
|
LOCATION_SPAWNS,
|
||||||
|
LOCATION_DANGER,
|
||||||
|
get_random_npc_for_location,
|
||||||
|
get_wandering_enemy_chance
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SPAWN_CHECK_INTERVAL = 120 # Check every 2 minutes
|
||||||
|
ENEMY_LIFETIME = 600 # Enemies live for 10 minutes
|
||||||
|
MAX_ENEMIES_PER_LOCATION = {
|
||||||
|
0: 0, # Safe zones - no wandering enemies
|
||||||
|
1: 1, # Low danger - max 1 enemy
|
||||||
|
2: 2, # Medium danger - max 2 enemies
|
||||||
|
3: 3, # High danger - max 3 enemies
|
||||||
|
4: 4, # Extreme danger - max 4 enemies
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_danger_level(location_id: str) -> int:
|
||||||
|
"""Get danger level for a location."""
|
||||||
|
danger_data = LOCATION_DANGER.get(location_id, (0, 0.0, 0.0))
|
||||||
|
return danger_data[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def spawn_manager_loop():
|
||||||
|
"""
|
||||||
|
Main spawn manager loop.
|
||||||
|
Runs continuously, checking spawn conditions every SPAWN_CHECK_INTERVAL seconds.
|
||||||
|
"""
|
||||||
|
logger.info("🎲 Spawn Manager started")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(SPAWN_CHECK_INTERVAL)
|
||||||
|
|
||||||
|
# Clean up expired enemies first
|
||||||
|
despawned_count = await database.cleanup_expired_wandering_enemies()
|
||||||
|
if despawned_count > 0:
|
||||||
|
logger.info(f"🧹 Cleaned up {despawned_count} expired wandering enemies")
|
||||||
|
|
||||||
|
# Process each location
|
||||||
|
spawned_count = 0
|
||||||
|
for location_id, spawn_table in LOCATION_SPAWNS.items():
|
||||||
|
if not spawn_table:
|
||||||
|
continue # Skip locations with no spawns
|
||||||
|
|
||||||
|
# Get danger level and max enemies for this location
|
||||||
|
danger_level = get_danger_level(location_id)
|
||||||
|
max_enemies = MAX_ENEMIES_PER_LOCATION.get(danger_level, 0)
|
||||||
|
|
||||||
|
if max_enemies == 0:
|
||||||
|
continue # Skip safe zones
|
||||||
|
|
||||||
|
# Check current enemy count
|
||||||
|
current_count = await database.get_wandering_enemy_count_in_location(location_id)
|
||||||
|
|
||||||
|
if current_count >= max_enemies:
|
||||||
|
continue # Location is at capacity
|
||||||
|
|
||||||
|
# Calculate spawn chance based on wandering_enemy_chance
|
||||||
|
spawn_chance = get_wandering_enemy_chance(location_id)
|
||||||
|
|
||||||
|
# Attempt to spawn enemies up to max capacity
|
||||||
|
for _ in range(max_enemies - current_count):
|
||||||
|
if random.random() < spawn_chance:
|
||||||
|
# Spawn an enemy
|
||||||
|
npc_id = get_random_npc_for_location(location_id)
|
||||||
|
if npc_id:
|
||||||
|
await database.spawn_wandering_enemy(
|
||||||
|
npc_id=npc_id,
|
||||||
|
location_id=location_id,
|
||||||
|
lifetime_seconds=ENEMY_LIFETIME
|
||||||
|
)
|
||||||
|
spawned_count += 1
|
||||||
|
logger.info(f"👹 Spawned {npc_id} at {location_id} (current: {current_count + 1}/{max_enemies})")
|
||||||
|
|
||||||
|
if spawned_count > 0:
|
||||||
|
logger.info(f"✨ Spawn cycle complete: {spawned_count} enemies spawned")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error in spawn manager loop: {e}", exc_info=True)
|
||||||
|
# Continue running even if there's an error
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_spawn_manager():
|
||||||
|
"""Start the spawn manager as a background task."""
|
||||||
|
asyncio.create_task(spawn_manager_loop())
|
||||||
|
logger.info("🎮 Spawn Manager initialized")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_spawn_stats() -> Dict:
|
||||||
|
"""Get statistics about current spawns (for debugging/monitoring)."""
|
||||||
|
all_enemies = await database.get_all_active_wandering_enemies()
|
||||||
|
|
||||||
|
# Count by location
|
||||||
|
location_counts = {}
|
||||||
|
for enemy in all_enemies:
|
||||||
|
loc = enemy['location_id']
|
||||||
|
location_counts[loc] = location_counts.get(loc, 0) + 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_active": len(all_enemies),
|
||||||
|
"by_location": location_counts,
|
||||||
|
"enemies": all_enemies
|
||||||
|
}
|
||||||
60
bot/utils.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Utility functions and decorators for the bot.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import ContextTypes
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_ids():
|
||||||
|
"""Get the list of admin user IDs from environment variable."""
|
||||||
|
admin_ids_str = os.getenv("ADMIN_IDS", "")
|
||||||
|
if not admin_ids_str:
|
||||||
|
logger.warning("ADMIN_IDS not set in .env file. No admins configured.")
|
||||||
|
return set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse comma-separated list of IDs
|
||||||
|
admin_ids = set(int(id.strip()) for id in admin_ids_str.split(",") if id.strip())
|
||||||
|
return admin_ids
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Error parsing ADMIN_IDS: {e}")
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def admin_only(func):
|
||||||
|
"""
|
||||||
|
Decorator that restricts command to admin users only.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@admin_only
|
||||||
|
async def my_admin_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
@functools.wraps(func)
|
||||||
|
async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs):
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
admin_ids = get_admin_ids()
|
||||||
|
|
||||||
|
if user_id not in admin_ids:
|
||||||
|
await update.message.reply_html(
|
||||||
|
"🚫 <b>Access Denied</b>\n\n"
|
||||||
|
"This command is restricted to administrators only."
|
||||||
|
)
|
||||||
|
logger.warning(f"User {user_id} attempted to use admin command: {func.__name__}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# User is admin, execute the command
|
||||||
|
return await func(update, context, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(user_id: int) -> bool:
|
||||||
|
"""Check if a user ID is an admin."""
|
||||||
|
admin_ids = get_admin_ids()
|
||||||
|
return user_id in admin_ids
|
||||||
0
data/__init__.py
Normal file
71
data/items.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Definitions for all items in the game.
|
||||||
|
# Now loaded from JSON file
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def load_items():
|
||||||
|
"""Load items from JSON file"""
|
||||||
|
json_path = Path(__file__).parent.parent / 'gamedata' / 'items.json'
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(json_path, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data['items']
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"⚠️ Warning: {json_path} not found, using fallback items")
|
||||||
|
return _get_fallback_items()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Warning: Error loading items from JSON: {e}")
|
||||||
|
return _get_fallback_items()
|
||||||
|
|
||||||
|
def _get_fallback_items():
|
||||||
|
"""Fallback items if JSON loading fails"""
|
||||||
|
return {
|
||||||
|
# Resources
|
||||||
|
"scrap_metal": {"name": "Scrap Metal", "weight": 0.5, "volume": 0.2, "type": "resource", "emoji": "⚙️"},
|
||||||
|
"rusty_nails": {"name": "Rusty Nails", "weight": 0.2, "volume": 0.1, "type": "resource", "emoji": "📌"},
|
||||||
|
"wood_planks": {"name": "Wood Planks", "weight": 3.0, "volume": 2.0, "type": "resource", "emoji": "🪵"},
|
||||||
|
"cloth_scraps": {"name": "Cloth Scraps", "weight": 0.1, "volume": 0.2, "type": "resource", "emoji": "🧵"},
|
||||||
|
"cloth": {"name": "Cloth", "weight": 0.1, "volume": 0.2, "type": "resource", "emoji": "🧵"},
|
||||||
|
"plastic_bottles": {"name": "Plastic Bottles", "weight": 0.05, "volume": 0.3, "type": "resource", "emoji": "🍶"},
|
||||||
|
"bone": {"name": "Bone", "weight": 0.3, "volume": 0.1, "type": "resource", "emoji": "🦴"},
|
||||||
|
"raw_meat": {"name": "Raw Meat", "weight": 0.5, "volume": 0.2, "type": "resource", "emoji": "🥩"},
|
||||||
|
"animal_hide": {"name": "Animal Hide", "weight": 0.4, "volume": 0.3, "type": "resource", "emoji": "🧤"},
|
||||||
|
"mutant_tissue": {"name": "Mutant Tissue", "weight": 0.2, "volume": 0.1, "type": "resource", "emoji": "🧬"},
|
||||||
|
"infected_tissue": {"name": "Infected Tissue", "weight": 0.2, "volume": 0.1, "type": "resource", "emoji": "☣️"},
|
||||||
|
|
||||||
|
# Consumables - Food
|
||||||
|
"stale_chocolate_bar": {"name": "Stale Chocolate Bar", "weight": 0.1, "volume": 0.1, "type": "consumable", "hp_restore": 10, "emoji": "🍫"},
|
||||||
|
"canned_beans": {"name": "Canned Beans", "weight": 0.4, "volume": 0.2, "type": "consumable", "hp_restore": 20, "stamina_restore": 5, "emoji": "🥫"},
|
||||||
|
"canned_food": {"name": "Canned Food", "weight": 0.4, "volume": 0.2, "type": "consumable", "hp_restore": 25, "stamina_restore": 5, "emoji": "🥫"},
|
||||||
|
"bottled_water": {"name": "Bottled Water", "weight": 0.5, "volume": 0.3, "type": "consumable", "stamina_restore": 10, "emoji": "💧"},
|
||||||
|
"water_bottle": {"name": "Water Bottle", "weight": 0.5, "volume": 0.3, "type": "consumable", "stamina_restore": 10, "emoji": "💧"},
|
||||||
|
"energy_bar": {"name": "Energy Bar", "weight": 0.1, "volume": 0.1, "type": "consumable", "stamina_restore": 15, "emoji": "🍫"},
|
||||||
|
"mystery_pills": {"name": "Mystery Pills", "weight": 0.05, "volume": 0.05, "type": "consumable", "hp_restore": 30, "emoji": "💊"},
|
||||||
|
|
||||||
|
# Consumables - Medical
|
||||||
|
"first_aid_kit": {"name": "First Aid Kit", "description": "A professional medical kit with bandages, antiseptic, and pain relievers.", "weight": 0.8, "volume": 0.5, "type": "consumable", "hp_restore": 50, "emoji": "🩹"},
|
||||||
|
"bandage": {"name": "Bandage", "description": "Clean cloth bandages for treating minor wounds.", "weight": 0.1, "volume": 0.1, "type": "consumable", "hp_restore": 15, "emoji": "🩹"},
|
||||||
|
"medical_supplies": {"name": "Medical Supplies", "description": "Assorted medical supplies scavenged from a clinic.", "weight": 0.6, "volume": 0.4, "type": "consumable", "hp_restore": 40, "emoji": "⚕️"},
|
||||||
|
"antibiotics": {"name": "Antibiotics", "description": "Pills that fight infections. Expired, but better than nothing.", "weight": 0.1, "volume": 0.1, "type": "consumable", "hp_restore": 20, "emoji": "💊"},
|
||||||
|
|
||||||
|
# Weapons & Tools
|
||||||
|
"tire_iron": {"name": "Tire Iron", "description": "A heavy metal tool. Makes a decent improvised weapon.", "weight": 2.0, "volume": 1.0, "type": "weapon", "slot": "hand", "damage_min": 3, "damage_max": 7, "emoji": "🔧"},
|
||||||
|
"baseball_bat": {"name": "Baseball Bat", "description": "Wooden bat with dents and bloodstains. Someone used this before you.", "weight": 1.0, "volume": 1.5, "type": "weapon", "slot": "hand", "damage_min": 2, "damage_max": 6, "emoji": "⚾"},
|
||||||
|
"rusty_knife": {"name": "Rusty Knife", "description": "A dull, rusted blade. Better than your fists.", "weight": 0.3, "volume": 0.2, "type": "weapon", "slot": "hand", "damage_min": 2, "damage_max": 5, "emoji": "🔪"},
|
||||||
|
"knife": {"name": "Knife", "description": "A sharp survival knife in decent condition.", "weight": 0.3, "volume": 0.2, "type": "weapon", "slot": "hand", "damage_min": 3, "damage_max": 6, "emoji": "🔪"},
|
||||||
|
"rusty_pipe": {"name": "Rusty Pipe", "description": "Heavy metal pipe. Crude but effective.", "weight": 1.5, "volume": 0.8, "type": "weapon", "slot": "hand", "damage_min": 4, "damage_max": 8, "emoji": "🔩"},
|
||||||
|
|
||||||
|
# Equipment
|
||||||
|
"tattered_rucksack": {"name": "Tattered Rucksack", "description": "An old backpack with torn straps. Still functional.", "weight": 1.0, "volume": 0, "type": "equipment", "slot": "back", "capacity_weight": 10, "capacity_volume": 10, "emoji": "🎒"},
|
||||||
|
"hiking_backpack": {"name": "Hiking Backpack", "description": "A quality backpack with multiple compartments.", "weight": 1.5, "volume": 0, "type": "equipment", "slot": "back", "capacity_weight": 20, "capacity_volume": 20, "emoji": "🎒"},
|
||||||
|
"flashlight": {"name": "Flashlight", "description": "A battery-powered flashlight. Batteries low but working.", "weight": 0.3, "volume": 0.2, "type": "equipment", "slot": "tool", "emoji": "🔦"},
|
||||||
|
|
||||||
|
# Quest Items
|
||||||
|
"old_photograph": {"name": "Old Photograph", "weight": 0.01, "volume": 0.01, "type": "quest", "emoji": "📷"},
|
||||||
|
"key_ring": {"name": "Key Ring", "weight": 0.1, "volume": 0.05, "type": "quest", "emoji": "🔑"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load items from JSON on module import
|
||||||
|
ITEMS = load_items()
|
||||||
57
data/models.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Outcome:
|
||||||
|
text: str
|
||||||
|
items_reward: Dict[str, int] = field(default_factory=dict)
|
||||||
|
damage_taken: int = 0
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Action:
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
stamina_cost: int
|
||||||
|
outcomes: Dict[str, Outcome] = field(default_factory=dict)
|
||||||
|
def add_outcome(self, name: str, outcome: Outcome): self.outcomes[name] = outcome
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Interactable:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
actions: Dict[str, Action] = field(default_factory=dict)
|
||||||
|
image_path: Optional[str] = None
|
||||||
|
def add_action(self, action: Action): self.actions[action.id] = action
|
||||||
|
def get_action(self, action_id: str) -> Optional[Action]: return self.actions.get(action_id)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Location:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
exits: Dict[str, str] = field(default_factory=dict)
|
||||||
|
interactables: Dict[str, Interactable] = field(default_factory=dict) # Key is now the INSTANCE_ID
|
||||||
|
image_path: Optional[str] = None
|
||||||
|
x: float = 0.0 # X coordinate for map positioning
|
||||||
|
y: float = 0.0 # Y coordinate for map positioning
|
||||||
|
|
||||||
|
def add_exit(self, direction: str, destination_id: str):
|
||||||
|
self.exits[direction] = destination_id
|
||||||
|
|
||||||
|
def add_interactable(self, instance_id: str, interactable_template: Interactable):
|
||||||
|
"""Adds an instance of an interactable template to the location."""
|
||||||
|
self.interactables[instance_id] = interactable_template
|
||||||
|
|
||||||
|
def get_interactable(self, instance_id: str) -> Optional[Interactable]:
|
||||||
|
return self.interactables.get(instance_id)
|
||||||
|
|
||||||
|
class World:
|
||||||
|
_instance = None
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super(World, cls).__new__(cls)
|
||||||
|
cls._instance.locations = {}
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def add_location(self, location: Location): self.locations[location.id] = location
|
||||||
|
def get_location(self, location_id: str) -> Optional[Location]: return self.locations.get(location_id)
|
||||||
236
data/npcs.py
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"""
|
||||||
|
NPC definitions for combat encounters - NOW LOADED FROM JSON
|
||||||
|
Each NPC has stats, loot tables, and combat behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LootItem:
|
||||||
|
"""Item that can be dropped by NPCs"""
|
||||||
|
item_id: str
|
||||||
|
quantity_min: int
|
||||||
|
quantity_max: int
|
||||||
|
drop_chance: float # 0.0 to 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CorpseLoot:
|
||||||
|
"""Item that can be scavenged from a corpse"""
|
||||||
|
item_id: str
|
||||||
|
quantity_min: int
|
||||||
|
quantity_max: int
|
||||||
|
required_tool: Optional[str] = None # item_id of required tool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StatusEffect:
|
||||||
|
"""Status effect data"""
|
||||||
|
name: str
|
||||||
|
duration_turns: int
|
||||||
|
damage_per_turn: int = 0 # For bleeding
|
||||||
|
stun: bool = False # Prevents action
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NPCDefinition:
|
||||||
|
"""Complete NPC definition"""
|
||||||
|
npc_id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
emoji: str
|
||||||
|
|
||||||
|
# Combat stats
|
||||||
|
hp_min: int
|
||||||
|
hp_max: int
|
||||||
|
damage_min: int
|
||||||
|
damage_max: int
|
||||||
|
defense: int # Reduces incoming damage
|
||||||
|
|
||||||
|
# Rewards
|
||||||
|
xp_reward: int
|
||||||
|
loot_table: List[LootItem]
|
||||||
|
corpse_loot: List[CorpseLoot]
|
||||||
|
|
||||||
|
# Behavior
|
||||||
|
flee_chance: float # NPC's chance to flee if low HP
|
||||||
|
status_inflict_chance: float # Chance to inflict status on player
|
||||||
|
|
||||||
|
# Visuals
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
death_message: str = "The enemy falls defeated."
|
||||||
|
|
||||||
|
|
||||||
|
def load_npcs_from_json():
|
||||||
|
"""Load NPCs, danger levels, and spawn tables from JSON"""
|
||||||
|
json_path = Path(__file__).parent.parent / 'gamedata' / 'npcs.json'
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(json_path, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Convert JSON to NPCDefinition objects
|
||||||
|
npcs = {}
|
||||||
|
for npc_id, npc_data in data['npcs'].items():
|
||||||
|
# Convert loot tables
|
||||||
|
loot_table = [
|
||||||
|
LootItem(**loot) for loot in npc_data.get('loot_table', [])
|
||||||
|
]
|
||||||
|
corpse_loot = [
|
||||||
|
CorpseLoot(**loot) for loot in npc_data.get('corpse_loot', [])
|
||||||
|
]
|
||||||
|
|
||||||
|
# Create NPC definition
|
||||||
|
npcs[npc_id] = NPCDefinition(
|
||||||
|
npc_id=npc_data['npc_id'],
|
||||||
|
name=npc_data['name'],
|
||||||
|
description=npc_data['description'],
|
||||||
|
emoji=npc_data['emoji'],
|
||||||
|
hp_min=npc_data['hp_min'],
|
||||||
|
hp_max=npc_data['hp_max'],
|
||||||
|
damage_min=npc_data['damage_min'],
|
||||||
|
damage_max=npc_data['damage_max'],
|
||||||
|
defense=npc_data['defense'],
|
||||||
|
xp_reward=npc_data['xp_reward'],
|
||||||
|
loot_table=loot_table,
|
||||||
|
corpse_loot=corpse_loot,
|
||||||
|
flee_chance=npc_data['flee_chance'],
|
||||||
|
status_inflict_chance=npc_data['status_inflict_chance'],
|
||||||
|
image_url=npc_data.get('image_url'),
|
||||||
|
death_message=npc_data.get('death_message', "The enemy falls defeated.")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load danger levels - convert to tuple format (danger_level, encounter_rate, wandering_chance)
|
||||||
|
danger_levels = {}
|
||||||
|
for loc_id, danger_data in data['danger_levels'].items():
|
||||||
|
danger_levels[loc_id] = (
|
||||||
|
danger_data['danger_level'],
|
||||||
|
danger_data['encounter_rate'],
|
||||||
|
danger_data['wandering_chance']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load spawn tables - convert to list of tuples format
|
||||||
|
spawn_tables = {}
|
||||||
|
for loc_id, spawns in data['spawn_tables'].items():
|
||||||
|
spawn_tables[loc_id] = [
|
||||||
|
(spawn['npc_id'], spawn['weight']) for spawn in spawns
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"✅ Loaded {len(npcs)} NPCs, {len(danger_levels)} danger configs, {len(spawn_tables)} spawn tables from JSON")
|
||||||
|
return npcs, danger_levels, spawn_tables
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"⚠️ Warning: {json_path} not found, using fallback NPCs")
|
||||||
|
return _get_fallback_npcs()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Warning: Error loading NPCs from JSON: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return _get_fallback_npcs()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fallback_npcs():
|
||||||
|
"""Fallback NPCs if JSON loading fails"""
|
||||||
|
npcs = {
|
||||||
|
"feral_dog": NPCDefinition(
|
||||||
|
npc_id="feral_dog",
|
||||||
|
name="Feral Dog",
|
||||||
|
description="A wild, mangy dog with desperate hunger in its eyes.",
|
||||||
|
emoji="🐕",
|
||||||
|
hp_min=15,
|
||||||
|
hp_max=25,
|
||||||
|
damage_min=3,
|
||||||
|
damage_max=7,
|
||||||
|
defense=0,
|
||||||
|
xp_reward=10,
|
||||||
|
flee_chance=0.3,
|
||||||
|
status_inflict_chance=0.15,
|
||||||
|
loot_table=[
|
||||||
|
LootItem("raw_meat", 1, 2, 0.6),
|
||||||
|
LootItem("bone", 1, 1, 0.4),
|
||||||
|
LootItem("animal_hide", 1, 1, 0.3)
|
||||||
|
],
|
||||||
|
corpse_loot=[
|
||||||
|
CorpseLoot("raw_meat", 1, 2),
|
||||||
|
CorpseLoot("bone", 1, 1),
|
||||||
|
CorpseLoot("animal_hide", 1, 1, required_tool="knife")
|
||||||
|
],
|
||||||
|
image_url=None,
|
||||||
|
death_message="The feral dog whimpers and collapses."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
danger_levels = {
|
||||||
|
"start_point": (0, 0.0, 0.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_tables = {
|
||||||
|
"start_point": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
return npcs, danger_levels, spawn_tables
|
||||||
|
|
||||||
|
|
||||||
|
# Load on module import
|
||||||
|
NPCS, LOCATION_DANGER, LOCATION_SPAWNS = load_npcs_from_json()
|
||||||
|
|
||||||
|
# Status effects that can be applied in combat
|
||||||
|
STATUS_EFFECTS = {
|
||||||
|
"bleeding": StatusEffect(
|
||||||
|
name="Bleeding",
|
||||||
|
duration_turns=3,
|
||||||
|
damage_per_turn=2,
|
||||||
|
stun=False
|
||||||
|
),
|
||||||
|
"stunned": StatusEffect(
|
||||||
|
name="Stunned",
|
||||||
|
duration_turns=1,
|
||||||
|
damage_per_turn=0,
|
||||||
|
stun=True
|
||||||
|
),
|
||||||
|
"infected": StatusEffect(
|
||||||
|
name="Infected",
|
||||||
|
duration_turns=5,
|
||||||
|
damage_per_turn=1,
|
||||||
|
stun=False
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Helper functions
|
||||||
|
def get_danger_level(location_id: str) -> int:
|
||||||
|
"""Get danger level for a location (0-4)"""
|
||||||
|
return LOCATION_DANGER.get(location_id, (0, 0.0, 0.0))[0]
|
||||||
|
|
||||||
|
|
||||||
|
def get_location_encounter_rate(location_id: str) -> float:
|
||||||
|
"""Get base encounter rate for a location"""
|
||||||
|
return LOCATION_DANGER.get(location_id, (0, 0.0, 0.0))[1]
|
||||||
|
|
||||||
|
|
||||||
|
def get_wandering_enemy_chance(location_id: str) -> float:
|
||||||
|
"""Get chance for wandering enemy to spawn"""
|
||||||
|
return LOCATION_DANGER.get(location_id, (0, 0.0, 0.0))[2]
|
||||||
|
|
||||||
|
|
||||||
|
def get_random_npc_for_location(location_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Get a random NPC ID for the given location based on spawn weights.
|
||||||
|
Returns None if no NPCs can spawn at this location.
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
|
||||||
|
spawn_table = LOCATION_SPAWNS.get(location_id, [])
|
||||||
|
if not spawn_table:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract NPCs and weights
|
||||||
|
npcs = [npc_id for npc_id, weight in spawn_table]
|
||||||
|
weights = [weight for npc_id, weight in spawn_table]
|
||||||
|
|
||||||
|
# Use random.choices with weights
|
||||||
|
return random.choices(npcs, weights=weights, k=1)[0] if npcs else None
|
||||||
31
data/travel_helpers.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Shared travel/stamina calculation helpers used by both game logic and map visualization
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
def calculate_base_stamina_cost(from_location, to_location) -> int:
|
||||||
|
"""
|
||||||
|
Calculate base stamina cost for traveling between two locations.
|
||||||
|
Based purely on Euclidean distance.
|
||||||
|
|
||||||
|
This is the base cost used by:
|
||||||
|
- Map visualization (to show connection costs)
|
||||||
|
- Game logic (as starting point before player modifiers)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
from_location: Location object with x, y coordinates
|
||||||
|
to_location: Location object with x, y coordinates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Base stamina cost (minimum 1)
|
||||||
|
"""
|
||||||
|
# Calculate Euclidean distance
|
||||||
|
dx = to_location.x - from_location.x
|
||||||
|
dy = to_location.y - from_location.y
|
||||||
|
distance = math.sqrt(dx**2 + dy**2)
|
||||||
|
|
||||||
|
# Base cost: 3 stamina per distance unit (rounded)
|
||||||
|
# Minimum cost is 1 stamina
|
||||||
|
base_cost = max(1, int(distance * 3 + 0.5))
|
||||||
|
|
||||||
|
return base_cost
|
||||||
313
data/world_loader.py
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
"""
|
||||||
|
World loader - NOW LOADS FROM JSON
|
||||||
|
Creates and connects all the game world objects from gamedata/locations.json
|
||||||
|
Uses template-based interactables from gamedata/interactables.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .models import World, Location, Interactable, Action, Outcome
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def load_interactable_templates():
|
||||||
|
"""Load interactable templates from interactables.json."""
|
||||||
|
templates = {}
|
||||||
|
json_path = Path(__file__).parent.parent / 'gamedata' / 'interactables.json'
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(json_path, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
for template_id, template_data in data.get('interactables', {}).items():
|
||||||
|
templates[template_id] = template_data
|
||||||
|
|
||||||
|
print(f"📦 Loaded {len(templates)} interactable templates")
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("⚠️ interactables.json not found, using inline data only")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error loading interactables.json: {e}")
|
||||||
|
|
||||||
|
return templates
|
||||||
|
|
||||||
|
|
||||||
|
def create_interactable_from_template(template_id, template_data, instance_data):
|
||||||
|
"""Create an Interactable object from template and instance data."""
|
||||||
|
interactable = Interactable(
|
||||||
|
id=template_id,
|
||||||
|
name=template_data.get('name', 'Unknown'),
|
||||||
|
image_path=template_data.get('image_path', '')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get actions from template
|
||||||
|
template_actions = template_data.get('actions', {})
|
||||||
|
|
||||||
|
# Get outcomes from instance (template-based format)
|
||||||
|
instance_outcomes = instance_data.get('outcomes', {})
|
||||||
|
|
||||||
|
# Build actions by merging template actions with instance outcomes
|
||||||
|
for action_id, action_template in template_actions.items():
|
||||||
|
action = Action(
|
||||||
|
id=action_template['id'],
|
||||||
|
label=action_template['label'],
|
||||||
|
stamina_cost=action_template.get('stamina_cost', 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the instance-specific outcome data for this action
|
||||||
|
if action_id in instance_outcomes:
|
||||||
|
outcome_data = instance_outcomes[action_id]
|
||||||
|
|
||||||
|
# Build outcomes from the instance data
|
||||||
|
text_dict = outcome_data.get('text', {})
|
||||||
|
rewards = outcome_data.get('rewards', {})
|
||||||
|
|
||||||
|
# Add success outcome
|
||||||
|
if text_dict.get('success'):
|
||||||
|
items_reward = {}
|
||||||
|
if 'items' in rewards:
|
||||||
|
for item in rewards['items']:
|
||||||
|
items_reward[item['item_id']] = item.get('quantity', 1)
|
||||||
|
|
||||||
|
outcome = Outcome(
|
||||||
|
text=text_dict['success'],
|
||||||
|
items_reward=items_reward,
|
||||||
|
damage_taken=rewards.get('damage', 0)
|
||||||
|
)
|
||||||
|
action.add_outcome('success', outcome)
|
||||||
|
|
||||||
|
# Add failure outcome
|
||||||
|
if text_dict.get('failure'):
|
||||||
|
outcome = Outcome(
|
||||||
|
text=text_dict['failure'],
|
||||||
|
items_reward={},
|
||||||
|
damage_taken=0
|
||||||
|
)
|
||||||
|
action.add_outcome('failure', outcome)
|
||||||
|
|
||||||
|
# Add critical failure outcome
|
||||||
|
if text_dict.get('crit_failure'):
|
||||||
|
outcome = Outcome(
|
||||||
|
text=text_dict['crit_failure'],
|
||||||
|
items_reward={},
|
||||||
|
damage_taken=rewards.get('crit_damage', 0)
|
||||||
|
)
|
||||||
|
action.add_outcome('critical_failure', outcome)
|
||||||
|
|
||||||
|
interactable.add_action(action)
|
||||||
|
|
||||||
|
return interactable
|
||||||
|
|
||||||
|
|
||||||
|
def load_world() -> World:
|
||||||
|
"""Creates and connects all the game world objects from JSON."""
|
||||||
|
world = World()
|
||||||
|
|
||||||
|
# Load interactable templates first
|
||||||
|
templates = load_interactable_templates()
|
||||||
|
|
||||||
|
json_path = Path(__file__).parent.parent / 'gamedata' / 'locations.json'
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(json_path, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
print(f"📍 Loading {len(data['locations'])} locations from JSON...")
|
||||||
|
|
||||||
|
# First pass: Create all locations
|
||||||
|
for loc_data in data['locations']:
|
||||||
|
location = Location(
|
||||||
|
id=loc_data['id'],
|
||||||
|
name=loc_data['name'],
|
||||||
|
description=loc_data['description'],
|
||||||
|
image_path=loc_data['image_path'],
|
||||||
|
x=loc_data.get('x', 0.0),
|
||||||
|
y=loc_data.get('y', 0.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add interactables using template-based format
|
||||||
|
for instance_id, instance_data in loc_data.get('interactables', {}).items():
|
||||||
|
template_id = instance_data.get('template_id')
|
||||||
|
|
||||||
|
if not template_id:
|
||||||
|
print(f"⚠️ Skipping interactable {instance_id} - no template_id")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get template data
|
||||||
|
template_data = templates.get(template_id)
|
||||||
|
|
||||||
|
if not template_data:
|
||||||
|
print(f"⚠️ Template '{template_id}' not found for {instance_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create interactable from template + instance data
|
||||||
|
interactable = create_interactable_from_template(
|
||||||
|
template_id,
|
||||||
|
template_data,
|
||||||
|
instance_data
|
||||||
|
)
|
||||||
|
|
||||||
|
location.add_interactable(instance_id, interactable)
|
||||||
|
|
||||||
|
world.add_location(location)
|
||||||
|
|
||||||
|
# Second pass: Add connections
|
||||||
|
print(f"🔗 Adding {len(data['connections'])} connections...")
|
||||||
|
for conn in data['connections']:
|
||||||
|
from_loc = world.get_location(conn['from'])
|
||||||
|
if from_loc:
|
||||||
|
from_loc.add_exit(conn['direction'], conn['to'])
|
||||||
|
|
||||||
|
print(f"✅ World loaded successfully!")
|
||||||
|
return world
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"⚠️ Warning: {json_path} not found, loading fallback world")
|
||||||
|
return _load_fallback_world()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error loading world from JSON: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return _load_fallback_world()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_fallback_world() -> World:
|
||||||
|
"""Fallback world with minimal locations if JSON loading fails"""
|
||||||
|
world = World()
|
||||||
|
|
||||||
|
# Create a simple fallback location
|
||||||
|
start = Location(
|
||||||
|
id="start_point",
|
||||||
|
name="🌆 Ruined Downtown Core",
|
||||||
|
description="The wind howls through skeletal skyscrapers. Debris litters the cracked asphalt.",
|
||||||
|
image_path="images/locations/downtown.png",
|
||||||
|
x=0.0,
|
||||||
|
y=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add a simple interactable
|
||||||
|
rubble = Interactable(
|
||||||
|
id="rubble",
|
||||||
|
name="Pile of Rubble",
|
||||||
|
image_path="images/interactables/rubble.png"
|
||||||
|
)
|
||||||
|
search_action = Action(id="search", label="🔎 Search Rubble", stamina_cost=2)
|
||||||
|
search_action.add_outcome("success", Outcome(
|
||||||
|
text="You find some scrap metal.",
|
||||||
|
items_reward={"scrap_metal": 2}
|
||||||
|
))
|
||||||
|
search_action.add_outcome("failure", Outcome(
|
||||||
|
text="Nothing useful here."
|
||||||
|
))
|
||||||
|
rubble.add_action(search_action)
|
||||||
|
start.add_interactable("start_rubble", rubble)
|
||||||
|
|
||||||
|
world.add_location(start)
|
||||||
|
|
||||||
|
return world
|
||||||
|
|
||||||
|
|
||||||
|
def export_map_data() -> dict:
|
||||||
|
"""
|
||||||
|
Export map data for external visualization.
|
||||||
|
Returns a dictionary with locations, connections, interactables, and enemy spawns.
|
||||||
|
Can be saved as JSON for use in web-based or other visualizers.
|
||||||
|
"""
|
||||||
|
from data.npcs import LOCATION_SPAWNS, NPCS, LOCATION_DANGER, get_danger_level, get_location_encounter_rate, get_wandering_enemy_chance
|
||||||
|
|
||||||
|
map_data = {
|
||||||
|
"locations": [],
|
||||||
|
"connections": [],
|
||||||
|
"interactables": [],
|
||||||
|
"spawn_tables": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for location in game_world.locations.values():
|
||||||
|
# Get danger information
|
||||||
|
danger_level = get_danger_level(location.id)
|
||||||
|
encounter_rate = get_location_encounter_rate(location.id)
|
||||||
|
wandering_chance = get_wandering_enemy_chance(location.id)
|
||||||
|
|
||||||
|
# Add location data with image and danger info
|
||||||
|
map_data["locations"].append({
|
||||||
|
"id": location.id,
|
||||||
|
"name": location.name,
|
||||||
|
"description": location.description,
|
||||||
|
"x": location.x,
|
||||||
|
"y": location.y,
|
||||||
|
"image_path": location.image_path,
|
||||||
|
"interactable_count": len(location.interactables),
|
||||||
|
"danger_level": danger_level,
|
||||||
|
"encounter_rate": round(encounter_rate * 100, 1), # As percentage
|
||||||
|
"wandering_chance": round(wandering_chance * 100, 1) # As percentage
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add interactable data with images and loot chances
|
||||||
|
for instance_id, interactable in location.interactables.items():
|
||||||
|
interactable_data = {
|
||||||
|
"instance_id": instance_id,
|
||||||
|
"location_id": location.id,
|
||||||
|
"id": interactable.id,
|
||||||
|
"name": interactable.name,
|
||||||
|
"image_path": interactable.image_path,
|
||||||
|
"actions": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process each action and its outcomes
|
||||||
|
for action in interactable.actions.values():
|
||||||
|
action_data = {
|
||||||
|
"id": action.id,
|
||||||
|
"label": action.label,
|
||||||
|
"stamina_cost": action.stamina_cost,
|
||||||
|
"outcomes": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add outcome information (for loot chances)
|
||||||
|
for outcome_name, outcome in action.outcomes.items():
|
||||||
|
outcome_data = {
|
||||||
|
"type": outcome_name,
|
||||||
|
"text": outcome.text,
|
||||||
|
"items": outcome.items_reward,
|
||||||
|
"damage": outcome.damage_taken
|
||||||
|
}
|
||||||
|
action_data["outcomes"].append(outcome_data)
|
||||||
|
|
||||||
|
interactable_data["actions"].append(action_data)
|
||||||
|
|
||||||
|
map_data["interactables"].append(interactable_data)
|
||||||
|
|
||||||
|
# Add connections
|
||||||
|
for direction, destination in location.exits.items():
|
||||||
|
# Calculate stamina cost based on distance between locations
|
||||||
|
dest_loc = game_world.get_location(destination)
|
||||||
|
if dest_loc:
|
||||||
|
from .travel_helpers import calculate_base_stamina_cost
|
||||||
|
stamina_cost = calculate_base_stamina_cost(location, dest_loc)
|
||||||
|
else:
|
||||||
|
stamina_cost = 5 # Fallback
|
||||||
|
|
||||||
|
map_data["connections"].append({
|
||||||
|
"from": location.id,
|
||||||
|
"to": destination,
|
||||||
|
"direction": direction,
|
||||||
|
"stamina_cost": stamina_cost
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add spawn table for this location
|
||||||
|
if location.id in LOCATION_SPAWNS:
|
||||||
|
spawn_data = []
|
||||||
|
for npc_id, weight in LOCATION_SPAWNS[location.id]:
|
||||||
|
if npc_id in NPCS:
|
||||||
|
npc = NPCS[npc_id]
|
||||||
|
spawn_data.append({
|
||||||
|
"npc_id": npc_id,
|
||||||
|
"name": npc.name,
|
||||||
|
"emoji": npc.emoji,
|
||||||
|
"weight": weight,
|
||||||
|
"level_range": f"{npc.hp_min}-{npc.hp_max} HP"
|
||||||
|
})
|
||||||
|
map_data["spawn_tables"][location.id] = spawn_data
|
||||||
|
|
||||||
|
return map_data
|
||||||
|
|
||||||
|
|
||||||
|
# Create singleton world instance on module import
|
||||||
|
game_world = load_world()
|
||||||
70
docker-compose.yml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
services:
|
||||||
|
echoes_of_the_ashes_db:
|
||||||
|
image: postgres:15
|
||||||
|
container_name: echoes_of_the_ashes_db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- echoes-postgres-data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- default_docker
|
||||||
|
#ports:
|
||||||
|
# Optional: expose port to host for debugging with a DB client
|
||||||
|
# - "5432:5432"
|
||||||
|
|
||||||
|
echoes_of_the_ashes_bot:
|
||||||
|
build: .
|
||||||
|
container_name: echoes_of_the_ashes_bot
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./gamedata:/app/gamedata:rw
|
||||||
|
- ./images:/app/images:ro
|
||||||
|
depends_on:
|
||||||
|
- echoes_of_the_ashes_db
|
||||||
|
networks:
|
||||||
|
- default_docker
|
||||||
|
|
||||||
|
echoes_of_the_ashes_map:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.map
|
||||||
|
container_name: echoes_of_the_ashes_map
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./gamedata:/app/gamedata:rw
|
||||||
|
- ./images:/app/images:rw
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
ports:
|
||||||
|
- 8080:8080
|
||||||
|
networks:
|
||||||
|
- default_docker
|
||||||
|
- traefik
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.echoesoftheash-http.entrypoints=web
|
||||||
|
- traefik.http.routers.echoesoftheash-http.rule=Host(`echoesoftheash.patacuack.net`)
|
||||||
|
- traefik.http.routers.echoesoftheash-http.middlewares=https-redirect@file
|
||||||
|
- traefik.http.routers.echoesoftheash.entrypoints=websecure
|
||||||
|
- traefik.http.routers.echoesoftheash.rule=Host(`echoesoftheash.patacuack.net`)
|
||||||
|
- traefik.http.routers.echoesoftheash.tls=true
|
||||||
|
- traefik.http.routers.echoesoftheash.tls.certResolver=production
|
||||||
|
- traefik.http.services.echoesoftheash.loadbalancer.server.port=8080
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
echoes-postgres-data:
|
||||||
|
name: echoes-of-the-ashes-postgres-data
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default_docker:
|
||||||
|
external: true
|
||||||
|
name: default_docker
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
|
name: traefik
|
||||||
144
gamedata/interactables.json
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
"interactables": {
|
||||||
|
"rubble": {
|
||||||
|
"id": "rubble",
|
||||||
|
"name": "Pile of Rubble",
|
||||||
|
"description": "A scattered pile of debris and broken concrete.",
|
||||||
|
"image_path": "images/interactables/rubble.png",
|
||||||
|
"actions": {
|
||||||
|
"search": {
|
||||||
|
"id": "search",
|
||||||
|
"label": "\ud83d\udd0e Search Rubble",
|
||||||
|
"stamina_cost": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dumpster": {
|
||||||
|
"id": "dumpster",
|
||||||
|
"name": "\ud83d\uddd1\ufe0f Dumpster",
|
||||||
|
"description": "A rusted metal dumpster, possibly containing scavenged goods.",
|
||||||
|
"image_path": "images/interactables/dumpster.png",
|
||||||
|
"actions": {
|
||||||
|
"search_dumpster": {
|
||||||
|
"id": "search_dumpster",
|
||||||
|
"label": "\ud83d\udd0e Dig Through Trash",
|
||||||
|
"stamina_cost": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sedan": {
|
||||||
|
"id": "sedan",
|
||||||
|
"name": "\ud83d\ude97 Rusty Sedan",
|
||||||
|
"description": "An abandoned sedan with rusted doors.",
|
||||||
|
"image_path": "images/interactables/sedan.png",
|
||||||
|
"actions": {
|
||||||
|
"search_glovebox": {
|
||||||
|
"id": "search_glovebox",
|
||||||
|
"label": "\ud83d\udd0e Search Glovebox",
|
||||||
|
"stamina_cost": 1
|
||||||
|
},
|
||||||
|
"pop_trunk": {
|
||||||
|
"id": "pop_trunk",
|
||||||
|
"label": "\ud83d\udd27 Pop the Trunk",
|
||||||
|
"stamina_cost": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"house": {
|
||||||
|
"id": "house",
|
||||||
|
"name": "\ud83c\udfda\ufe0f Abandoned House",
|
||||||
|
"description": "A dilapidated house with boarded windows.",
|
||||||
|
"image_path": "images/interactables/house.png",
|
||||||
|
"actions": {
|
||||||
|
"search_house": {
|
||||||
|
"id": "search_house",
|
||||||
|
"label": "\ud83d\udd0e Search House",
|
||||||
|
"stamina_cost": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toolshed": {
|
||||||
|
"id": "toolshed",
|
||||||
|
"name": "\ud83d\udd28 Tool Shed",
|
||||||
|
"description": "A small wooden shed, door slightly ajar.",
|
||||||
|
"image_path": "images/interactables/toolshed.png",
|
||||||
|
"actions": {
|
||||||
|
"search_shed": {
|
||||||
|
"id": "search_shed",
|
||||||
|
"label": "\ud83d\udd0e Search Shed",
|
||||||
|
"stamina_cost": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"medkit": {
|
||||||
|
"id": "medkit",
|
||||||
|
"name": "\ud83c\udfe5 Medical Supply Cabinet",
|
||||||
|
"description": "A white metal cabinet with a red cross symbol.",
|
||||||
|
"image_path": "images/interactables/medkit.png",
|
||||||
|
"actions": {
|
||||||
|
"search_medkit": {
|
||||||
|
"id": "search_medkit",
|
||||||
|
"label": "\ud83d\udd0e Search Cabinet",
|
||||||
|
"stamina_cost": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vending": {
|
||||||
|
"id": "vending",
|
||||||
|
"name": "\ud83e\uddc3 Vending Machine",
|
||||||
|
"description": "A broken vending machine, glass shattered.",
|
||||||
|
"image_path": "images/interactables/vending.png",
|
||||||
|
"actions": {
|
||||||
|
"break_vending": {
|
||||||
|
"id": "break_vending",
|
||||||
|
"label": "\ud83d\udd28 Break Open",
|
||||||
|
"stamina_cost": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"medical_cabinet": {
|
||||||
|
"id": "medical_cabinet",
|
||||||
|
"name": "Medical Cabinet",
|
||||||
|
"description": "A white metal cabinet with a red cross symbol.",
|
||||||
|
"image_path": "images/interactables/medkit.png",
|
||||||
|
"actions": {
|
||||||
|
"search": {
|
||||||
|
"id": "search",
|
||||||
|
"label": "\ud83d\udd0e Search Cabinet",
|
||||||
|
"stamina_cost": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage_box": {
|
||||||
|
"id": "storage_box",
|
||||||
|
"name": "Storage Box",
|
||||||
|
"description": "A weathered storage container.",
|
||||||
|
"image_path": "images/interactables/storage_box.png",
|
||||||
|
"actions": {
|
||||||
|
"search": {
|
||||||
|
"id": "search",
|
||||||
|
"label": "\ud83d\udd0e Search Box",
|
||||||
|
"stamina_cost": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vending_machine": {
|
||||||
|
"id": "vending_machine",
|
||||||
|
"name": "Vending Machine",
|
||||||
|
"description": "A broken vending machine, glass shattered.",
|
||||||
|
"image_path": "images/interactables/vending.png",
|
||||||
|
"actions": {
|
||||||
|
"break": {
|
||||||
|
"id": "break",
|
||||||
|
"label": "\ud83d\udd28 Break Open",
|
||||||
|
"stamina_cost": 5
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"id": "search",
|
||||||
|
"label": "\ud83d\udd0e Search Machine",
|
||||||
|
"stamina_cost": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
275
gamedata/items.json
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"scrap_metal": {
|
||||||
|
"name": "Scrap Metal",
|
||||||
|
"type": "resource",
|
||||||
|
"weight": 0.5,
|
||||||
|
"volume": 0.2,
|
||||||
|
"emoji": "\u2699\ufe0f"
|
||||||
|
},
|
||||||
|
"rusty_nails": {
|
||||||
|
"name": "Rusty Nails",
|
||||||
|
"weight": 0.2,
|
||||||
|
"volume": 0.1,
|
||||||
|
"type": "resource",
|
||||||
|
"emoji": "\ud83d\udccc"
|
||||||
|
},
|
||||||
|
"wood_planks": {
|
||||||
|
"name": "Wood Planks",
|
||||||
|
"weight": 3.0,
|
||||||
|
"volume": 2.0,
|
||||||
|
"type": "resource",
|
||||||
|
"emoji": "\ud83e\udeb5"
|
||||||
|
},
|
||||||
|
"cloth_scraps": {
|
||||||
|
"name": "Cloth Scraps",
|
||||||
|
"weight": 0.1,
|
||||||
|
"volume": 0.2,
|
||||||
|
"type": "resource",
|
||||||
|
"emoji": "\ud83e\uddf5"
|
||||||
|
},
|
||||||
|
"cloth": {
|
||||||
|
"name": "Cloth",
|
||||||
|
"weight": 0.1,
|
||||||
|
"volume": 0.2,
|
||||||
|
"type": "resource",
|
||||||
|
"emoji": "\ud83e\uddf5"
|
||||||
|
},
|
||||||
|
"plastic_bottles": {
|
||||||
|
"name": "Plastic Bottles",
|
||||||
|
"weight": 0.05,
|
||||||
|
"volume": 0.3,
|
||||||
|
"type": "resource",
|
||||||
|
"emoji": "\ud83c\udf76"
|
||||||
|
},
|
||||||
|
"bone": {
|
||||||
|
"name": "Bone",
|
||||||
|
"weight": 0.3,
|
||||||
|
"volume": 0.1,
|
||||||
|
"type": "resource",
|
||||||
|
"emoji": "\ud83e\uddb4"
|
||||||
|
},
|
||||||
|
"raw_meat": {
|
||||||
|
"name": "Raw Meat",
|
||||||
|
"weight": 0.5,
|
||||||
|
"volume": 0.2,
|
||||||
|
"type": "resource",
|
||||||
|
"emoji": "\ud83e\udd69"
|
||||||
|
},
|
||||||
|
"animal_hide": {
|
||||||
|
"name": "Animal Hide",
|
||||||
|
"weight": 0.4,
|
||||||
|
"volume": 0.3,
|
||||||
|
"type": "resource",
|
||||||
|
"emoji": "\ud83e\udde4"
|
||||||
|
},
|
||||||
|
"mutant_tissue": {
|
||||||
|
"name": "Mutant Tissue",
|
||||||
|
"weight": 0.2,
|
||||||
|
"volume": 0.1,
|
||||||
|
"type": "resource",
|
||||||
|
"emoji": "\ud83e\uddec"
|
||||||
|
},
|
||||||
|
"infected_tissue": {
|
||||||
|
"name": "Infected Tissue",
|
||||||
|
"weight": 0.2,
|
||||||
|
"volume": 0.1,
|
||||||
|
"type": "resource",
|
||||||
|
"emoji": "\u2623\ufe0f"
|
||||||
|
},
|
||||||
|
"stale_chocolate_bar": {
|
||||||
|
"name": "Stale Chocolate Bar",
|
||||||
|
"weight": 0.1,
|
||||||
|
"volume": 0.1,
|
||||||
|
"type": "consumable",
|
||||||
|
"hp_restore": 10,
|
||||||
|
"emoji": "\ud83c\udf6b"
|
||||||
|
},
|
||||||
|
"canned_beans": {
|
||||||
|
"name": "Canned Beans",
|
||||||
|
"weight": 0.4,
|
||||||
|
"volume": 0.2,
|
||||||
|
"type": "consumable",
|
||||||
|
"hp_restore": 20,
|
||||||
|
"stamina_restore": 5,
|
||||||
|
"emoji": "\ud83e\udd6b"
|
||||||
|
},
|
||||||
|
"canned_food": {
|
||||||
|
"name": "Canned Food",
|
||||||
|
"weight": 0.4,
|
||||||
|
"volume": 0.2,
|
||||||
|
"type": "consumable",
|
||||||
|
"hp_restore": 25,
|
||||||
|
"stamina_restore": 5,
|
||||||
|
"emoji": "\ud83e\udd6b"
|
||||||
|
},
|
||||||
|
"bottled_water": {
|
||||||
|
"name": "Bottled Water",
|
||||||
|
"weight": 0.5,
|
||||||
|
"volume": 0.3,
|
||||||
|
"type": "consumable",
|
||||||
|
"stamina_restore": 10,
|
||||||
|
"emoji": "\ud83d\udca7"
|
||||||
|
},
|
||||||
|
"water_bottle": {
|
||||||
|
"name": "Water Bottle",
|
||||||
|
"weight": 0.5,
|
||||||
|
"volume": 0.3,
|
||||||
|
"type": "consumable",
|
||||||
|
"stamina_restore": 10,
|
||||||
|
"emoji": "\ud83d\udca7"
|
||||||
|
},
|
||||||
|
"energy_bar": {
|
||||||
|
"name": "Energy Bar",
|
||||||
|
"weight": 0.1,
|
||||||
|
"volume": 0.1,
|
||||||
|
"type": "consumable",
|
||||||
|
"stamina_restore": 15,
|
||||||
|
"emoji": "\ud83c\udf6b"
|
||||||
|
},
|
||||||
|
"mystery_pills": {
|
||||||
|
"name": "Mystery Pills",
|
||||||
|
"weight": 0.05,
|
||||||
|
"volume": 0.05,
|
||||||
|
"type": "consumable",
|
||||||
|
"hp_restore": 30,
|
||||||
|
"emoji": "\ud83d\udc8a"
|
||||||
|
},
|
||||||
|
"first_aid_kit": {
|
||||||
|
"name": "First Aid Kit",
|
||||||
|
"description": "A professional medical kit with bandages, antiseptic, and pain relievers.",
|
||||||
|
"weight": 0.8,
|
||||||
|
"volume": 0.5,
|
||||||
|
"type": "consumable",
|
||||||
|
"hp_restore": 50,
|
||||||
|
"emoji": "\ud83e\ude79"
|
||||||
|
},
|
||||||
|
"bandage": {
|
||||||
|
"name": "Bandage",
|
||||||
|
"description": "Clean cloth bandages for treating minor wounds.",
|
||||||
|
"weight": 0.1,
|
||||||
|
"volume": 0.1,
|
||||||
|
"type": "consumable",
|
||||||
|
"hp_restore": 15,
|
||||||
|
"emoji": "\ud83e\ude79"
|
||||||
|
},
|
||||||
|
"medical_supplies": {
|
||||||
|
"name": "Medical Supplies",
|
||||||
|
"description": "Assorted medical supplies scavenged from a clinic.",
|
||||||
|
"weight": 0.6,
|
||||||
|
"volume": 0.4,
|
||||||
|
"type": "consumable",
|
||||||
|
"hp_restore": 40,
|
||||||
|
"emoji": "\u2695\ufe0f"
|
||||||
|
},
|
||||||
|
"antibiotics": {
|
||||||
|
"name": "Antibiotics",
|
||||||
|
"description": "Pills that fight infections. Expired, but better than nothing.",
|
||||||
|
"weight": 0.1,
|
||||||
|
"volume": 0.1,
|
||||||
|
"type": "consumable",
|
||||||
|
"hp_restore": 20,
|
||||||
|
"emoji": "\ud83d\udc8a"
|
||||||
|
},
|
||||||
|
"tire_iron": {
|
||||||
|
"name": "Tire Iron",
|
||||||
|
"description": "A heavy metal tool. Makes a decent improvised weapon.",
|
||||||
|
"weight": 2.0,
|
||||||
|
"volume": 1.0,
|
||||||
|
"type": "weapon",
|
||||||
|
"slot": "hand",
|
||||||
|
"damage_min": 3,
|
||||||
|
"damage_max": 7,
|
||||||
|
"emoji": "\ud83d\udd27"
|
||||||
|
},
|
||||||
|
"baseball_bat": {
|
||||||
|
"name": "Baseball Bat",
|
||||||
|
"description": "Wooden bat with dents and bloodstains. Someone used this before you.",
|
||||||
|
"weight": 1.0,
|
||||||
|
"volume": 1.5,
|
||||||
|
"type": "weapon",
|
||||||
|
"slot": "hand",
|
||||||
|
"damage_min": 2,
|
||||||
|
"damage_max": 6,
|
||||||
|
"emoji": "\u26be"
|
||||||
|
},
|
||||||
|
"rusty_knife": {
|
||||||
|
"name": "Rusty Knife",
|
||||||
|
"description": "A dull, rusted blade. Better than your fists.",
|
||||||
|
"weight": 0.3,
|
||||||
|
"volume": 0.2,
|
||||||
|
"type": "weapon",
|
||||||
|
"slot": "hand",
|
||||||
|
"damage_min": 2,
|
||||||
|
"damage_max": 5,
|
||||||
|
"emoji": "\ud83d\udd2a"
|
||||||
|
},
|
||||||
|
"knife": {
|
||||||
|
"name": "Knife",
|
||||||
|
"description": "A sharp survival knife in decent condition.",
|
||||||
|
"weight": 0.3,
|
||||||
|
"volume": 0.2,
|
||||||
|
"type": "weapon",
|
||||||
|
"slot": "hand",
|
||||||
|
"damage_min": 3,
|
||||||
|
"damage_max": 6,
|
||||||
|
"emoji": "\ud83d\udd2a"
|
||||||
|
},
|
||||||
|
"rusty_pipe": {
|
||||||
|
"name": "Rusty Pipe",
|
||||||
|
"description": "Heavy metal pipe. Crude but effective.",
|
||||||
|
"weight": 1.5,
|
||||||
|
"volume": 0.8,
|
||||||
|
"type": "weapon",
|
||||||
|
"slot": "hand",
|
||||||
|
"damage_min": 4,
|
||||||
|
"damage_max": 8,
|
||||||
|
"emoji": "\ud83d\udd29"
|
||||||
|
},
|
||||||
|
"tattered_rucksack": {
|
||||||
|
"name": "Tattered Rucksack",
|
||||||
|
"description": "An old backpack with torn straps. Still functional.",
|
||||||
|
"weight": 1.0,
|
||||||
|
"volume": 0,
|
||||||
|
"type": "equipment",
|
||||||
|
"slot": "back",
|
||||||
|
"capacity_weight": 10,
|
||||||
|
"capacity_volume": 10,
|
||||||
|
"emoji": "\ud83c\udf92"
|
||||||
|
},
|
||||||
|
"hiking_backpack": {
|
||||||
|
"name": "Hiking Backpack",
|
||||||
|
"description": "A quality backpack with multiple compartments.",
|
||||||
|
"weight": 1.5,
|
||||||
|
"volume": 0,
|
||||||
|
"type": "equipment",
|
||||||
|
"slot": "back",
|
||||||
|
"capacity_weight": 20,
|
||||||
|
"capacity_volume": 20,
|
||||||
|
"emoji": "\ud83c\udf92"
|
||||||
|
},
|
||||||
|
"flashlight": {
|
||||||
|
"name": "Flashlight",
|
||||||
|
"description": "A battery-powered flashlight. Batteries low but working.",
|
||||||
|
"weight": 0.3,
|
||||||
|
"volume": 0.2,
|
||||||
|
"type": "equipment",
|
||||||
|
"slot": "tool",
|
||||||
|
"emoji": "\ud83d\udd26"
|
||||||
|
},
|
||||||
|
"old_photograph": {
|
||||||
|
"name": "Old Photograph",
|
||||||
|
"weight": 0.01,
|
||||||
|
"volume": 0.01,
|
||||||
|
"type": "quest",
|
||||||
|
"emoji": "\ud83d\udcf7"
|
||||||
|
},
|
||||||
|
"key_ring": {
|
||||||
|
"name": "Key Ring",
|
||||||
|
"weight": 0.1,
|
||||||
|
"volume": 0.05,
|
||||||
|
"type": "quest",
|
||||||
|
"emoji": "\ud83d\udd11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1438
gamedata/locations.json
Normal file
465
gamedata/npcs.json
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
{
|
||||||
|
"npcs": {
|
||||||
|
"feral_dog": {
|
||||||
|
"npc_id": "feral_dog",
|
||||||
|
"name": "Feral Dog",
|
||||||
|
"description": "A wild, mangy dog with desperate hunger in its eyes. Its ribs are visible beneath matted fur.",
|
||||||
|
"emoji": "🐕",
|
||||||
|
"hp_min": 15,
|
||||||
|
"hp_max": 25,
|
||||||
|
"damage_min": 3,
|
||||||
|
"damage_max": 7,
|
||||||
|
"defense": 0,
|
||||||
|
"xp_reward": 10,
|
||||||
|
"loot_table": [
|
||||||
|
{
|
||||||
|
"item_id": "raw_meat",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 2,
|
||||||
|
"drop_chance": 0.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": "animal_hide",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 1,
|
||||||
|
"drop_chance": 0.3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"corpse_loot": [
|
||||||
|
{
|
||||||
|
"item_id": "raw_meat",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 3,
|
||||||
|
"required_tool": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": "animal_hide",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 1,
|
||||||
|
"required_tool": "knife"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"flee_chance": 0.3,
|
||||||
|
"status_inflict_chance": 0.15,
|
||||||
|
"image_url": "images/npcs/feral_dog.png",
|
||||||
|
"death_message": "The feral dog whimpers and collapses. Perhaps it was just hungry..."
|
||||||
|
},
|
||||||
|
"raider_scout": {
|
||||||
|
"npc_id": "raider_scout",
|
||||||
|
"name": "Raider Scout",
|
||||||
|
"description": "A lone raider wearing makeshift armor. They eye you with hostile intent.",
|
||||||
|
"emoji": "🏴☠️",
|
||||||
|
"hp_min": 30,
|
||||||
|
"hp_max": 45,
|
||||||
|
"damage_min": 5,
|
||||||
|
"damage_max": 12,
|
||||||
|
"defense": 2,
|
||||||
|
"xp_reward": 25,
|
||||||
|
"loot_table": [
|
||||||
|
{
|
||||||
|
"item_id": "water_bottle",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 1,
|
||||||
|
"drop_chance": 0.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": "canned_food",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 2,
|
||||||
|
"drop_chance": 0.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": "bandage",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 3,
|
||||||
|
"drop_chance": 0.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": "rusty_pipe",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 1,
|
||||||
|
"drop_chance": 0.3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"corpse_loot": [
|
||||||
|
{
|
||||||
|
"item_id": "scrap_metal",
|
||||||
|
"quantity_min": 2,
|
||||||
|
"quantity_max": 4,
|
||||||
|
"required_tool": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": "cloth",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 2,
|
||||||
|
"required_tool": "knife"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"flee_chance": 0.2,
|
||||||
|
"status_inflict_chance": 0.1,
|
||||||
|
"image_url": "images/npcs/raider_scout.png",
|
||||||
|
"death_message": "The raider scout falls with a final gasp. Their supplies are yours."
|
||||||
|
},
|
||||||
|
"mutant_rat": {
|
||||||
|
"npc_id": "mutant_rat",
|
||||||
|
"name": "Mutant Rat",
|
||||||
|
"description": "A grotesquely large rat, its fur patchy and eyes glowing with unnatural light.",
|
||||||
|
"emoji": "🐀",
|
||||||
|
"hp_min": 10,
|
||||||
|
"hp_max": 18,
|
||||||
|
"damage_min": 2,
|
||||||
|
"damage_max": 5,
|
||||||
|
"defense": 0,
|
||||||
|
"xp_reward": 8,
|
||||||
|
"loot_table": [
|
||||||
|
{
|
||||||
|
"item_id": "raw_meat",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 1,
|
||||||
|
"drop_chance": 0.4
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"corpse_loot": [
|
||||||
|
{
|
||||||
|
"item_id": "raw_meat",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 2,
|
||||||
|
"required_tool": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": "mutant_tissue",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 1,
|
||||||
|
"required_tool": "knife"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"flee_chance": 0.5,
|
||||||
|
"status_inflict_chance": 0.25,
|
||||||
|
"image_url": "images/npcs/mutant_rat.png",
|
||||||
|
"death_message": "The mutant rat squeals its last and goes still."
|
||||||
|
},
|
||||||
|
"infected_human": {
|
||||||
|
"npc_id": "infected_human",
|
||||||
|
"name": "Infected Human",
|
||||||
|
"description": "Once human, now something else. Their movements are jerky and their skin shows signs of advanced infection.",
|
||||||
|
"emoji": "🧟",
|
||||||
|
"hp_min": 35,
|
||||||
|
"hp_max": 50,
|
||||||
|
"damage_min": 6,
|
||||||
|
"damage_max": 10,
|
||||||
|
"defense": 1,
|
||||||
|
"xp_reward": 30,
|
||||||
|
"loot_table": [
|
||||||
|
{
|
||||||
|
"item_id": "medical_supplies",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 1,
|
||||||
|
"drop_chance": 0.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": "antibiotics",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 1,
|
||||||
|
"drop_chance": 0.15
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"corpse_loot": [
|
||||||
|
{
|
||||||
|
"item_id": "infected_tissue",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 2,
|
||||||
|
"required_tool": "knife"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": "bone",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 3,
|
||||||
|
"required_tool": "knife"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"flee_chance": 0.1,
|
||||||
|
"status_inflict_chance": 0.3,
|
||||||
|
"image_url": "images/npcs/infected_human.png",
|
||||||
|
"death_message": "The infected human finally finds peace in death."
|
||||||
|
},
|
||||||
|
"scavenger": {
|
||||||
|
"npc_id": "scavenger",
|
||||||
|
"name": "Hostile Scavenger",
|
||||||
|
"description": "Another survivor, but this one sees you as competition. They won't share territory.",
|
||||||
|
"emoji": "💀",
|
||||||
|
"hp_min": 25,
|
||||||
|
"hp_max": 40,
|
||||||
|
"damage_min": 4,
|
||||||
|
"damage_max": 9,
|
||||||
|
"defense": 3,
|
||||||
|
"xp_reward": 20,
|
||||||
|
"loot_table": [
|
||||||
|
{
|
||||||
|
"item_id": "water_bottle",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 2,
|
||||||
|
"drop_chance": 0.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": "canned_food",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 2,
|
||||||
|
"drop_chance": 0.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": "bandage",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 2,
|
||||||
|
"drop_chance": 0.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": "knife",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 1,
|
||||||
|
"drop_chance": 0.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": "flashlight",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 1,
|
||||||
|
"drop_chance": 0.3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"corpse_loot": [
|
||||||
|
{
|
||||||
|
"item_id": "cloth",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 3,
|
||||||
|
"required_tool": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": "scrap_metal",
|
||||||
|
"quantity_min": 1,
|
||||||
|
"quantity_max": 2,
|
||||||
|
"required_tool": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"flee_chance": 0.25,
|
||||||
|
"status_inflict_chance": 0.05,
|
||||||
|
"image_url": "images/npcs/scavenger.png",
|
||||||
|
"death_message": "The scavenger's struggle ends. Survival has no mercy."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"danger_levels": {
|
||||||
|
"start_point": {
|
||||||
|
"danger_level": 0,
|
||||||
|
"encounter_rate": 0.0,
|
||||||
|
"wandering_chance": 0.0
|
||||||
|
},
|
||||||
|
"gas_station": {
|
||||||
|
"danger_level": 0,
|
||||||
|
"encounter_rate": 0.0,
|
||||||
|
"wandering_chance": 0.0
|
||||||
|
},
|
||||||
|
"residential": {
|
||||||
|
"danger_level": 1,
|
||||||
|
"encounter_rate": 0.10,
|
||||||
|
"wandering_chance": 0.20
|
||||||
|
},
|
||||||
|
"park": {
|
||||||
|
"danger_level": 1,
|
||||||
|
"encounter_rate": 0.10,
|
||||||
|
"wandering_chance": 0.20
|
||||||
|
},
|
||||||
|
"clinic": {
|
||||||
|
"danger_level": 2,
|
||||||
|
"encounter_rate": 0.20,
|
||||||
|
"wandering_chance": 0.35
|
||||||
|
},
|
||||||
|
"plaza": {
|
||||||
|
"danger_level": 2,
|
||||||
|
"encounter_rate": 0.15,
|
||||||
|
"wandering_chance": 0.30
|
||||||
|
},
|
||||||
|
"warehouse": {
|
||||||
|
"danger_level": 2,
|
||||||
|
"encounter_rate": 0.18,
|
||||||
|
"wandering_chance": 0.32
|
||||||
|
},
|
||||||
|
"warehouse_interior": {
|
||||||
|
"danger_level": 2,
|
||||||
|
"encounter_rate": 0.22,
|
||||||
|
"wandering_chance": 0.40
|
||||||
|
},
|
||||||
|
"overpass": {
|
||||||
|
"danger_level": 3,
|
||||||
|
"encounter_rate": 0.30,
|
||||||
|
"wandering_chance": 0.45
|
||||||
|
},
|
||||||
|
"office_building": {
|
||||||
|
"danger_level": 3,
|
||||||
|
"encounter_rate": 0.25,
|
||||||
|
"wandering_chance": 0.40
|
||||||
|
},
|
||||||
|
"office_interior": {
|
||||||
|
"danger_level": 3,
|
||||||
|
"encounter_rate": 0.35,
|
||||||
|
"wandering_chance": 0.50
|
||||||
|
},
|
||||||
|
"subway": {
|
||||||
|
"danger_level": 4,
|
||||||
|
"encounter_rate": 0.35,
|
||||||
|
"wandering_chance": 0.50
|
||||||
|
},
|
||||||
|
"subway_tunnels": {
|
||||||
|
"danger_level": 4,
|
||||||
|
"encounter_rate": 0.45,
|
||||||
|
"wandering_chance": 0.65
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"spawn_tables": {
|
||||||
|
"start_point": [],
|
||||||
|
"gas_station": [],
|
||||||
|
"residential": [
|
||||||
|
{
|
||||||
|
"npc_id": "feral_dog",
|
||||||
|
"weight": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "mutant_rat",
|
||||||
|
"weight": 40
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"park": [
|
||||||
|
{
|
||||||
|
"npc_id": "feral_dog",
|
||||||
|
"weight": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "mutant_rat",
|
||||||
|
"weight": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clinic": [
|
||||||
|
{
|
||||||
|
"npc_id": "infected_human",
|
||||||
|
"weight": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "mutant_rat",
|
||||||
|
"weight": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "scavenger",
|
||||||
|
"weight": 30
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"plaza": [
|
||||||
|
{
|
||||||
|
"npc_id": "raider_scout",
|
||||||
|
"weight": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "scavenger",
|
||||||
|
"weight": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "feral_dog",
|
||||||
|
"weight": 25
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"warehouse": [
|
||||||
|
{
|
||||||
|
"npc_id": "raider_scout",
|
||||||
|
"weight": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "scavenger",
|
||||||
|
"weight": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "mutant_rat",
|
||||||
|
"weight": 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"warehouse_interior": [
|
||||||
|
{
|
||||||
|
"npc_id": "raider_scout",
|
||||||
|
"weight": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "scavenger",
|
||||||
|
"weight": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "mutant_rat",
|
||||||
|
"weight": 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"overpass": [
|
||||||
|
{
|
||||||
|
"npc_id": "raider_scout",
|
||||||
|
"weight": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "infected_human",
|
||||||
|
"weight": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "scavenger",
|
||||||
|
"weight": 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"office_building": [
|
||||||
|
{
|
||||||
|
"npc_id": "raider_scout",
|
||||||
|
"weight": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "infected_human",
|
||||||
|
"weight": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "scavenger",
|
||||||
|
"weight": 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"office_interior": [
|
||||||
|
{
|
||||||
|
"npc_id": "infected_human",
|
||||||
|
"weight": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "raider_scout",
|
||||||
|
"weight": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "scavenger",
|
||||||
|
"weight": 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subway": [
|
||||||
|
{
|
||||||
|
"npc_id": "infected_human",
|
||||||
|
"weight": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "raider_scout",
|
||||||
|
"weight": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "mutant_rat",
|
||||||
|
"weight": 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subway_tunnels": [
|
||||||
|
{
|
||||||
|
"npc_id": "infected_human",
|
||||||
|
"weight": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "raider_scout",
|
||||||
|
"weight": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc_id": "mutant_rat",
|
||||||
|
"weight": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
26
images/README.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Game Images
|
||||||
|
|
||||||
|
Place your location and interactable images in this directory.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
- `locations/` - Images for different locations
|
||||||
|
- `interactables/` - Images for interactive objects
|
||||||
|
|
||||||
|
## Supported Formats
|
||||||
|
- PNG, JPG, JPEG
|
||||||
|
- Recommended size: 800x600 or similar aspect ratio
|
||||||
|
- Max file size: 10MB (Telegram limit)
|
||||||
|
|
||||||
|
## Example Files
|
||||||
|
To use images, add them to the appropriate folder and reference them in `data/world_loader.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
location = Location(
|
||||||
|
id="start_point",
|
||||||
|
name="Ruined Downtown Core",
|
||||||
|
description="...",
|
||||||
|
image_path="images/locations/downtown.jpg"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The bot will automatically upload the image once and cache the Telegram file_id for future use.
|
||||||
2
images/interactables/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# This is a placeholder file.
|
||||||
|
# Replace with actual interactable images.
|
||||||
BIN
images/interactables/dumpster.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
images/interactables/house.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
images/interactables/medkit.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
images/interactables/rubble.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
images/interactables/sedan.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
images/interactables/toolshed.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
images/interactables/vending.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
2
images/locations/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# This is a placeholder file.
|
||||||
|
# Replace with actual location images.
|
||||||
BIN
images/locations/clinic.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
images/locations/downtown.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
images/locations/gas_station.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
images/locations/office_building.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
images/locations/office_interior.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
images/locations/overpass.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
images/locations/park.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
images/locations/plaza.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
images/locations/residential.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
images/locations/subway.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
images/locations/subway_section_a.jpg
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
images/locations/subway_tunnels.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
images/locations/warehouse.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
images/locations/warehouse_interior.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
0
images/npcs/.gitkeep
Normal file
BIN
images/npcs/feral_dog.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
images/npcs/infected_human.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
images/npcs/mutant_rat.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
images/npcs/raider_scout.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
images/npcs/scavenger.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
154
main.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import Application, CommandHandler, CallbackQueryHandler
|
||||||
|
|
||||||
|
from bot import database, handlers
|
||||||
|
|
||||||
|
# Enable logging
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO
|
||||||
|
)
|
||||||
|
# Quieten down the HTTPX logger, which is very verbose
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# A global event to signal shutdown
|
||||||
|
shutdown_event = asyncio.Event()
|
||||||
|
|
||||||
|
def signal_handler(sig, frame):
|
||||||
|
"""Gracefully handle shutdown signals."""
|
||||||
|
logger.info("Shutdown signal received. Shutting down gracefully...")
|
||||||
|
shutdown_event.set()
|
||||||
|
|
||||||
|
async def decay_dropped_items():
|
||||||
|
"""A background task that periodically cleans up old dropped items."""
|
||||||
|
while not shutdown_event.is_set():
|
||||||
|
try:
|
||||||
|
# Wait for 5 minutes before the next cleanup
|
||||||
|
await asyncio.wait_for(shutdown_event.wait(), timeout=300)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.info("Running item decay task...")
|
||||||
|
# Set decay time to 1 hour (3600 seconds)
|
||||||
|
decay_seconds = 3600
|
||||||
|
timestamp_limit = int(time.time()) - decay_seconds
|
||||||
|
items_removed = await database.remove_expired_dropped_items(timestamp_limit)
|
||||||
|
if items_removed > 0:
|
||||||
|
logger.info(f"Decayed and removed {items_removed} old items.")
|
||||||
|
|
||||||
|
async def regenerate_stamina():
|
||||||
|
"""A background task that periodically regenerates stamina for all players."""
|
||||||
|
while not shutdown_event.is_set():
|
||||||
|
try:
|
||||||
|
# Wait for 5 minutes before the next regeneration cycle
|
||||||
|
await asyncio.wait_for(shutdown_event.wait(), timeout=300)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.info("Running stamina regeneration...")
|
||||||
|
players_updated = await database.regenerate_all_players_stamina()
|
||||||
|
if players_updated > 0:
|
||||||
|
logger.info(f"Regenerated stamina for {players_updated} players.")
|
||||||
|
|
||||||
|
async def check_combat_timers():
|
||||||
|
"""A background task that checks for idle combat turns and auto-attacks."""
|
||||||
|
while not shutdown_event.is_set():
|
||||||
|
try:
|
||||||
|
# Wait for 30 seconds before next check
|
||||||
|
await asyncio.wait_for(shutdown_event.wait(), timeout=30)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Check for combats idle for more than 5 minutes (300 seconds)
|
||||||
|
idle_threshold = time.time() - 300
|
||||||
|
idle_combats = await database.get_all_idle_combats(idle_threshold)
|
||||||
|
|
||||||
|
for combat in idle_combats:
|
||||||
|
try:
|
||||||
|
from bot import combat as combat_logic
|
||||||
|
# Force end player's turn and let NPC attack
|
||||||
|
if combat['turn'] == 'player':
|
||||||
|
logger.info(f"Player {combat['player_id']} idle in combat - auto-ending turn")
|
||||||
|
await database.update_combat(combat['player_id'], {
|
||||||
|
'turn': 'npc',
|
||||||
|
'turn_started_at': time.time()
|
||||||
|
})
|
||||||
|
# NPC attacks
|
||||||
|
await combat_logic.npc_attack(combat['player_id'])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing idle combat: {e}")
|
||||||
|
|
||||||
|
async def decay_corpses():
|
||||||
|
"""A background task that removes old corpses."""
|
||||||
|
while not shutdown_event.is_set():
|
||||||
|
try:
|
||||||
|
# Wait for 10 minutes before next cleanup
|
||||||
|
await asyncio.wait_for(shutdown_event.wait(), timeout=600)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.info("Running corpse decay...")
|
||||||
|
# Player corpses decay after 24 hours
|
||||||
|
player_corpse_limit = time.time() - (24 * 3600)
|
||||||
|
player_corpses_removed = await database.remove_expired_player_corpses(player_corpse_limit)
|
||||||
|
|
||||||
|
# NPC corpses decay after 2 hours
|
||||||
|
npc_corpse_limit = time.time() - (2 * 3600)
|
||||||
|
npc_corpses_removed = await database.remove_expired_npc_corpses(npc_corpse_limit)
|
||||||
|
|
||||||
|
if player_corpses_removed > 0 or npc_corpses_removed > 0:
|
||||||
|
logger.info(f"Decayed {player_corpses_removed} player corpses and {npc_corpses_removed} NPC corpses.")
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
"""Start the bot and wait for a shutdown signal."""
|
||||||
|
load_dotenv()
|
||||||
|
TOKEN = os.getenv("TELEGRAM_TOKEN")
|
||||||
|
|
||||||
|
if not TOKEN or TOKEN == "YOUR_TELEGRAM_BOT_TOKEN_HERE":
|
||||||
|
logger.error("TELEGRAM_TOKEN is not set! Please edit your .env file.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await database.create_tables()
|
||||||
|
|
||||||
|
application = Application.builder().token(TOKEN).build()
|
||||||
|
|
||||||
|
application.add_handler(CommandHandler("start", handlers.start))
|
||||||
|
application.add_handler(CommandHandler("map", handlers.export_map))
|
||||||
|
application.add_handler(CommandHandler("spawns", handlers.spawn_stats))
|
||||||
|
application.add_handler(CallbackQueryHandler(handlers.button_handler))
|
||||||
|
|
||||||
|
async with application:
|
||||||
|
await application.start()
|
||||||
|
await application.updater.start_polling(allowed_updates=Update.ALL_TYPES)
|
||||||
|
logger.info("Bot is running and polling for updates...")
|
||||||
|
|
||||||
|
# Start the spawn manager
|
||||||
|
from bot import spawn_manager
|
||||||
|
await spawn_manager.start_spawn_manager()
|
||||||
|
|
||||||
|
# Start the background tasks
|
||||||
|
decay_task = asyncio.create_task(decay_dropped_items())
|
||||||
|
stamina_task = asyncio.create_task(regenerate_stamina())
|
||||||
|
combat_timer_task = asyncio.create_task(check_combat_timers())
|
||||||
|
corpse_decay_task = asyncio.create_task(decay_corpses())
|
||||||
|
|
||||||
|
await shutdown_event.wait()
|
||||||
|
|
||||||
|
await application.updater.stop()
|
||||||
|
await application.stop()
|
||||||
|
|
||||||
|
# Ensure the background tasks are also cancelled on shutdown
|
||||||
|
decay_task.cancel()
|
||||||
|
stamina_task.cancel()
|
||||||
|
combat_timer_task.cancel()
|
||||||
|
corpse_decay_task.cancel()
|
||||||
|
logger.info("Bot has been shut down.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
logger.info("Main function interrupted.")
|
||||||
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
python-telegram-bot[ext]==21.0.1
|
||||||
|
sqlalchemy[asyncio]==2.0.25
|
||||||
|
aiosqlite==0.19.0
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
psycopg[binary,async]==3.1.18
|
||||||
202
web-map/README.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# 🗺️ Echoes of the Ashes - Interactive Map Visualizer
|
||||||
|
|
||||||
|
A web-based interactive map viewer for the RPG game world.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Interactive Canvas Map**: Drag, zoom, and explore the game world
|
||||||
|
- **Real-time Location Data**: Dynamically loaded from game data
|
||||||
|
- **Distance Calculations**: Shows travel distances and stamina costs
|
||||||
|
- **Location Details**: Click on locations to see full information
|
||||||
|
- **Connection Routes**: Visual representation of all travel paths
|
||||||
|
- **Statistics Dashboard**: View map statistics and metrics
|
||||||
|
- **Responsive Design**: Works on desktop and mobile devices
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Option 1: Docker (Recommended)
|
||||||
|
|
||||||
|
The map server is included in the docker-compose setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d echoes_of_the_ashes_map
|
||||||
|
```
|
||||||
|
|
||||||
|
Access the map at: **http://localhost:8080**
|
||||||
|
|
||||||
|
### Option 2: Standalone Python Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web-map
|
||||||
|
python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional: Specify a custom port
|
||||||
|
```bash
|
||||||
|
python server.py --port 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features Overview
|
||||||
|
|
||||||
|
### Map Controls
|
||||||
|
|
||||||
|
- **🖱️ Pan**: Click and drag to move around the map
|
||||||
|
- **🔍 Zoom**: Use mouse wheel or zoom buttons to adjust view
|
||||||
|
- **🎯 Reset**: Return to default view
|
||||||
|
- **🏷️ Toggle Labels**: Show/hide location names
|
||||||
|
|
||||||
|
### Location Information
|
||||||
|
|
||||||
|
Click on any location node to see:
|
||||||
|
- Location name and description
|
||||||
|
- Coordinates (X, Y)
|
||||||
|
- Number of interactables
|
||||||
|
- Connected locations with distances
|
||||||
|
- Estimated stamina costs for travel
|
||||||
|
|
||||||
|
### Map Legend
|
||||||
|
|
||||||
|
- **Green Circles**: Locations
|
||||||
|
- **Blue Lines**: Travel routes
|
||||||
|
- **Orange Circle**: Selected location
|
||||||
|
- **Pink Badge**: Number of interactables at location
|
||||||
|
|
||||||
|
## Map Statistics
|
||||||
|
|
||||||
|
The dashboard shows:
|
||||||
|
- **Total Locations**: Number of places in the game world
|
||||||
|
- **Total Routes**: Number of connections between locations
|
||||||
|
- **Longest Route**: Maximum distance between connected locations
|
||||||
|
- **Average Distance**: Mean distance across all routes
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Data Source
|
||||||
|
|
||||||
|
The map dynamically loads data from `/map_data.json`, which is generated from the game's world loader. The data includes:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "start_point",
|
||||||
|
"name": "🌆 Ruined Downtown Core",
|
||||||
|
"description": "...",
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 0.0,
|
||||||
|
"interactable_count": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"from": "start_point",
|
||||||
|
"to": "gas_station",
|
||||||
|
"direction": "north",
|
||||||
|
"distance": 2.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Architecture
|
||||||
|
|
||||||
|
- **Backend**: Python HTTP server with dynamic data generation
|
||||||
|
- **Frontend**: Vanilla JavaScript with HTML5 Canvas
|
||||||
|
- **Responsive**: CSS Grid and Flexbox layout
|
||||||
|
- **Real-time**: Live data from game world loader
|
||||||
|
|
||||||
|
### Port Configuration
|
||||||
|
|
||||||
|
Default port: **8080**
|
||||||
|
|
||||||
|
To change the port, modify:
|
||||||
|
1. `docker-compose.yml`: Update the ports mapping
|
||||||
|
2. Or use `--port` flag when running standalone
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
Edit `index.html` to customize:
|
||||||
|
- Colors and gradients
|
||||||
|
- Card layouts
|
||||||
|
- Typography
|
||||||
|
- Responsive breakpoints
|
||||||
|
|
||||||
|
### Map Appearance
|
||||||
|
|
||||||
|
Edit `map.js` to customize:
|
||||||
|
- Node sizes and colors
|
||||||
|
- Line widths
|
||||||
|
- Scale factors
|
||||||
|
- Animation effects
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### With Reverse Proxy (Nginx/Caddy)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /map {
|
||||||
|
proxy_pass http://localhost:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Public Access
|
||||||
|
|
||||||
|
To make it accessible globally:
|
||||||
|
|
||||||
|
1. **Port Forward**: Open port 8080 on your firewall
|
||||||
|
2. **Domain**: Point a subdomain to your server
|
||||||
|
3. **SSL**: Use Let's Encrypt for HTTPS
|
||||||
|
|
||||||
|
Example with Caddy:
|
||||||
|
```
|
||||||
|
map.yourdomain.com {
|
||||||
|
reverse_proxy localhost:8080
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Map Not Loading
|
||||||
|
|
||||||
|
1. Check if server is running:
|
||||||
|
```bash
|
||||||
|
docker ps | grep map
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check logs:
|
||||||
|
```bash
|
||||||
|
docker logs echoes_of_the_ashes_map
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify port is accessible:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Not Updating
|
||||||
|
|
||||||
|
The map data is generated dynamically. If you've changed location coordinates:
|
||||||
|
1. Restart the map server
|
||||||
|
2. Hard refresh browser (Ctrl+F5)
|
||||||
|
|
||||||
|
### Connection Issues
|
||||||
|
|
||||||
|
If running in Docker, ensure the container is on the correct network and ports are properly mapped.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
To modify the map visualization:
|
||||||
|
|
||||||
|
1. Edit `map.js` for canvas rendering logic
|
||||||
|
2. Edit `index.html` for layout and UI
|
||||||
|
3. Edit `server.py` for data serving logic
|
||||||
|
|
||||||
|
The server auto-loads changes - just refresh your browser!
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Part of the Echoes of the Ashes RPG project.
|
||||||
1160
web-map/editor.html
Normal file
470
web-map/editor.js
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
// Map Editor JavaScript
|
||||||
|
let currentLocations = [];
|
||||||
|
let availableNPCs = [];
|
||||||
|
let selectedLocationId = null;
|
||||||
|
let canvas, ctx;
|
||||||
|
let scale = 50;
|
||||||
|
let offsetX = 0;
|
||||||
|
let offsetY = 0;
|
||||||
|
|
||||||
|
// Check authentication on load
|
||||||
|
window.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
const response = await fetch('/api/check-auth');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.authenticated) {
|
||||||
|
showEditor();
|
||||||
|
} else {
|
||||||
|
document.getElementById('loginContainer').style.display = 'flex';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login form handler
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({password})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showEditor();
|
||||||
|
} else {
|
||||||
|
showError(data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError('Login failed: ' + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
const errorDiv = document.getElementById('loginError');
|
||||||
|
errorDiv.textContent = message;
|
||||||
|
errorDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccess(message) {
|
||||||
|
const successDiv = document.getElementById('saveSuccess');
|
||||||
|
successDiv.textContent = message;
|
||||||
|
successDiv.style.display = 'block';
|
||||||
|
setTimeout(() => {
|
||||||
|
successDiv.style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showEditor() {
|
||||||
|
document.getElementById('loginContainer').style.display = 'none';
|
||||||
|
document.getElementById('editorContainer').style.display = 'grid';
|
||||||
|
|
||||||
|
// Initialize canvas
|
||||||
|
canvas = document.getElementById('editorCanvas');
|
||||||
|
ctx = canvas.getContext('2d');
|
||||||
|
resizeCanvas();
|
||||||
|
window.addEventListener('resize', resizeCanvas);
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
await loadLocations();
|
||||||
|
await loadAvailableNPCs();
|
||||||
|
|
||||||
|
// Draw map
|
||||||
|
drawMap();
|
||||||
|
|
||||||
|
// Canvas click handler
|
||||||
|
canvas.addEventListener('click', handleCanvasClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
canvas.width = canvas.offsetWidth;
|
||||||
|
canvas.height = canvas.offsetHeight;
|
||||||
|
drawMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocations() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/editor/locations');
|
||||||
|
const data = await response.json();
|
||||||
|
currentLocations = data.locations;
|
||||||
|
renderLocationList();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load locations:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAvailableNPCs() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/editor/available-npcs');
|
||||||
|
const data = await response.json();
|
||||||
|
availableNPCs = data.npcs;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load NPCs:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLocationList() {
|
||||||
|
const list = document.getElementById('locationList');
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
currentLocations.forEach(location => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'location-item';
|
||||||
|
if (location.id === selectedLocationId) {
|
||||||
|
item.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="location-item-name">${location.name}</div>
|
||||||
|
<div class="location-item-coords">📍 (${location.x}, ${location.y}) | Danger: ${location.danger_level}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
item.onclick = () => selectLocation(location.id);
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectLocation(locationId) {
|
||||||
|
selectedLocationId = locationId;
|
||||||
|
renderLocationList();
|
||||||
|
|
||||||
|
// Load full location details
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/editor/location/${locationId}`);
|
||||||
|
const location = await response.json();
|
||||||
|
populateForm(location);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load location details:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateForm(location) {
|
||||||
|
document.getElementById('noSelectionMessage').classList.add('hidden');
|
||||||
|
document.getElementById('propertiesForm').classList.remove('hidden');
|
||||||
|
|
||||||
|
document.getElementById('locationId').value = location.id;
|
||||||
|
document.getElementById('locationName').value = location.name;
|
||||||
|
document.getElementById('locationDescription').value = location.description;
|
||||||
|
document.getElementById('locationX').value = location.x;
|
||||||
|
document.getElementById('locationY').value = location.y;
|
||||||
|
document.getElementById('dangerLevel').value = location.danger_level;
|
||||||
|
document.getElementById('encounterRate').value = location.encounter_rate;
|
||||||
|
document.getElementById('wanderingChance').value = location.wandering_chance;
|
||||||
|
document.getElementById('imagePath').value = location.image_path || '';
|
||||||
|
|
||||||
|
// Update image preview
|
||||||
|
updateImagePreview(location.image_path);
|
||||||
|
|
||||||
|
// Render spawn list
|
||||||
|
renderSpawnList(location.spawn_npcs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateImagePreview(imagePath) {
|
||||||
|
const preview = document.getElementById('imagePreview');
|
||||||
|
if (imagePath) {
|
||||||
|
preview.innerHTML = `<img src="/${imagePath}" alt="Location image">`;
|
||||||
|
} else {
|
||||||
|
preview.innerHTML = '<span>No image</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSpawnList(spawns) {
|
||||||
|
const list = document.getElementById('spawnList');
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
spawns.forEach((spawn, index) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'spawn-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="spawn-item-info">
|
||||||
|
<div class="spawn-item-name">${spawn.emoji} ${spawn.name}</div>
|
||||||
|
<div class="spawn-item-weight">Weight: ${spawn.weight}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-remove" onclick="removeSpawn(${index})">Remove</button>
|
||||||
|
`;
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMap() {
|
||||||
|
ctx.fillStyle = '#0f0f1e';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Draw grid
|
||||||
|
ctx.strokeStyle = '#1a1a3e';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
const centerX = canvas.width / 2;
|
||||||
|
const centerY = canvas.height / 2;
|
||||||
|
|
||||||
|
// Draw vertical lines
|
||||||
|
for (let x = centerX % scale; x < canvas.width; x += scale) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, canvas.height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw horizontal lines
|
||||||
|
for (let y = centerY % scale; y < canvas.height; y += scale) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(canvas.width, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw axes
|
||||||
|
ctx.strokeStyle = '#3a3a6a';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(centerX, 0);
|
||||||
|
ctx.lineTo(centerX, canvas.height);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, centerY);
|
||||||
|
ctx.lineTo(canvas.width, centerY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw locations
|
||||||
|
currentLocations.forEach(location => {
|
||||||
|
const screenX = centerX + location.x * scale;
|
||||||
|
const screenY = centerY - location.y * scale;
|
||||||
|
|
||||||
|
// Danger level colors
|
||||||
|
const dangerColors = ['#4caf50', '#8bc34a', '#ffa726', '#ff5722', '#d32f2f'];
|
||||||
|
const color = dangerColors[location.danger_level] || '#9e9e9e';
|
||||||
|
|
||||||
|
// Draw location circle
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(screenX, screenY, 15, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Highlight selected
|
||||||
|
if (location.id === selectedLocationId) {
|
||||||
|
ctx.strokeStyle = '#ffa726';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw label
|
||||||
|
ctx.fillStyle = '#e0e0e0';
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(location.name, screenX, screenY + 30);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCanvasClick(event) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const clickX = event.clientX - rect.left;
|
||||||
|
const clickY = event.clientY - rect.top;
|
||||||
|
|
||||||
|
const centerX = canvas.width / 2;
|
||||||
|
const centerY = canvas.height / 2;
|
||||||
|
|
||||||
|
// Check if clicked on existing location
|
||||||
|
for (const location of currentLocations) {
|
||||||
|
const screenX = centerX + location.x * scale;
|
||||||
|
const screenY = centerY - location.y * scale;
|
||||||
|
|
||||||
|
const distance = Math.sqrt(Math.pow(clickX - screenX, 2) + Math.pow(clickY - screenY, 2));
|
||||||
|
|
||||||
|
if (distance < 15) {
|
||||||
|
selectLocation(location.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clicked on empty space - create new location
|
||||||
|
const worldX = ((clickX - centerX) / scale).toFixed(1);
|
||||||
|
const worldY = (-(clickY - centerY) / scale).toFixed(1);
|
||||||
|
|
||||||
|
if (confirm(`Create new location at (${worldX}, ${worldY})?`)) {
|
||||||
|
createLocationAt(parseFloat(worldX), parseFloat(worldY));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLocationAt(x, y) {
|
||||||
|
const newId = 'location_' + Date.now();
|
||||||
|
|
||||||
|
const newLocation = {
|
||||||
|
id: newId,
|
||||||
|
name: 'New Location',
|
||||||
|
description: 'Enter description...',
|
||||||
|
image_path: '',
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
danger_level: 0,
|
||||||
|
encounter_rate: 0.0,
|
||||||
|
wandering_chance: 0.0,
|
||||||
|
spawn_npcs: []
|
||||||
|
};
|
||||||
|
|
||||||
|
currentLocations.push(newLocation);
|
||||||
|
selectedLocationId = newId;
|
||||||
|
renderLocationList();
|
||||||
|
populateForm(newLocation);
|
||||||
|
drawMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNewLocation() {
|
||||||
|
createLocationAt(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLocation() {
|
||||||
|
const locationData = {
|
||||||
|
id: document.getElementById('locationId').value,
|
||||||
|
name: document.getElementById('locationName').value,
|
||||||
|
description: document.getElementById('locationDescription').value,
|
||||||
|
x: parseFloat(document.getElementById('locationX').value),
|
||||||
|
y: parseFloat(document.getElementById('locationY').value),
|
||||||
|
danger_level: parseInt(document.getElementById('dangerLevel').value),
|
||||||
|
encounter_rate: parseFloat(document.getElementById('encounterRate').value),
|
||||||
|
wandering_chance: parseFloat(document.getElementById('wanderingChance').value),
|
||||||
|
image_path: document.getElementById('imagePath').value,
|
||||||
|
spawn_npcs: getCurrentSpawns()
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/editor/location', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(locationData)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showSuccess('Location saved successfully!');
|
||||||
|
await loadLocations();
|
||||||
|
drawMap();
|
||||||
|
} else {
|
||||||
|
alert('Failed to save: ' + data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to save location: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentSpawns() {
|
||||||
|
const spawns = [];
|
||||||
|
const spawnItems = document.querySelectorAll('.spawn-item');
|
||||||
|
|
||||||
|
spawnItems.forEach(item => {
|
||||||
|
const nameDiv = item.querySelector('.spawn-item-name');
|
||||||
|
const weightDiv = item.querySelector('.spawn-item-weight');
|
||||||
|
|
||||||
|
if (nameDiv && weightDiv) {
|
||||||
|
const text = nameDiv.textContent;
|
||||||
|
const npcName = text.substring(text.indexOf(' ') + 1).trim();
|
||||||
|
const weight = parseInt(weightDiv.textContent.replace('Weight: ', ''));
|
||||||
|
|
||||||
|
// Find NPC ID by name
|
||||||
|
const npc = availableNPCs.find(n => n.name === npcName);
|
||||||
|
if (npc) {
|
||||||
|
spawns.push({npc_id: npc.id, weight: weight});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return spawns;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddSpawnModal() {
|
||||||
|
const modal = document.getElementById('addSpawnModal');
|
||||||
|
const list = document.getElementById('npcSelectList');
|
||||||
|
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
availableNPCs.forEach(npc => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'npc-select-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div><strong>${npc.emoji} ${npc.name}</strong></div>
|
||||||
|
<div style="font-size: 0.85em; opacity: 0.7;">
|
||||||
|
HP: ${npc.hp_range[0]}-${npc.hp_range[1]} |
|
||||||
|
DMG: ${npc.damage_range[0]}-${npc.damage_range[1]} |
|
||||||
|
XP: ${npc.xp_reward}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
item.onclick = () => addSpawn(npc);
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddSpawnModal() {
|
||||||
|
document.getElementById('addSpawnModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSpawn(npc) {
|
||||||
|
const weight = prompt(`Enter spawn weight for ${npc.name}:`, '50');
|
||||||
|
if (weight && !isNaN(weight)) {
|
||||||
|
const list = document.getElementById('spawnList');
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'spawn-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="spawn-item-info">
|
||||||
|
<div class="spawn-item-name">${npc.emoji} ${npc.name}</div>
|
||||||
|
<div class="spawn-item-weight">Weight: ${weight}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-remove" onclick="this.parentElement.remove()">Remove</button>
|
||||||
|
`;
|
||||||
|
list.appendChild(item);
|
||||||
|
}
|
||||||
|
closeAddSpawnModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSpawn(index) {
|
||||||
|
const spawnItems = document.querySelectorAll('.spawn-item');
|
||||||
|
if (spawnItems[index]) {
|
||||||
|
spawnItems[index].remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImage() {
|
||||||
|
const fileInput = document.getElementById('imageUpload');
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/editor/upload-image', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('imagePath').value = data.image_path;
|
||||||
|
updateImagePreview(data.image_path);
|
||||||
|
showSuccess(data.message);
|
||||||
|
} else {
|
||||||
|
alert('Upload failed: ' + data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Upload failed: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/logout', {method: 'POST'});
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
3025
web-map/editor_enhanced.js
Normal file
869
web-map/index.html
Normal file
@@ -0,0 +1,869 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Echoes of the Ashes - Interactive World Map</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
color: #e0e0e0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 450px;
|
||||||
|
height: 100vh;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #0f0f1e;
|
||||||
|
border-right: 2px solid #2a2a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 20px 30px;
|
||||||
|
background: linear-gradient(135deg, #2a2a4a 0%, #1a1a3e 100%);
|
||||||
|
border-bottom: 2px solid #3a3a6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #ffa726;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.9em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: linear-gradient(135deg, #0088cc 0%, #006699 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 136, 204, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-link:hover {
|
||||||
|
background: linear-gradient(135deg, #00a0e6 0%, #0088cc 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 136, 204, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-link:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-link-icon {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mapCanvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mapCanvas:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: rgba(42, 42, 74, 0.9);
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #3a3a6a;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
|
display: block;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin: 5px 0;
|
||||||
|
background: #3a3a6a;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #ffa726;
|
||||||
|
font-size: 1.2em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button:hover {
|
||||||
|
background: #ffa726;
|
||||||
|
color: #1a1a3e;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #16162e;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid #2a2a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section h2 {
|
||||||
|
color: #ffa726;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 1.3em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section h3 {
|
||||||
|
color: #80cbc4;
|
||||||
|
margin: 15px 0 10px 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-image {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border: 2px solid #3a3a6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
background: linear-gradient(135deg, #2a2a4a 0%, #1a1a3e 100%);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px solid #3a3a6a;
|
||||||
|
color: #5a5a7a;
|
||||||
|
font-size: 3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactable-image,
|
||||||
|
.enemy-image {
|
||||||
|
width: 60px;
|
||||||
|
height: 45px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 2px solid #3a3a6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactable-image-placeholder,
|
||||||
|
.enemy-image-placeholder {
|
||||||
|
width: 60px;
|
||||||
|
height: 45px;
|
||||||
|
background: linear-gradient(135deg, #2a2a4a 0%, #1a1a3e 100%);
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 2px solid #3a3a6a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5em;
|
||||||
|
color: #5a5a7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(42, 42, 74, 0.3);
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 0.95em;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connections {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-item {
|
||||||
|
background: rgba(42, 42, 74, 0.5);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
border-left: 3px solid #80cbc4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactable-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactable-card {
|
||||||
|
background: rgba(42, 42, 74, 0.3);
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #3a3a6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactable-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactable-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactable-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffa726;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item {
|
||||||
|
background: rgba(58, 58, 106, 0.3);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-header {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #80cbc4;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outcome-item {
|
||||||
|
padding: 5px 10px;
|
||||||
|
margin: 3px 0;
|
||||||
|
border-left: 2px solid #5a5a7a;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outcome-success {
|
||||||
|
border-left-color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outcome-failure {
|
||||||
|
border-left-color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outcome-critical {
|
||||||
|
border-left-color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-card {
|
||||||
|
background: rgba(74, 42, 42, 0.3);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #6a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-emoji {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ff5252;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
background: rgba(58, 58, 106, 0.3);
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
padding: 15px;
|
||||||
|
background: rgba(42, 42, 74, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 30px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: rgba(42, 42, 74, 0.5);
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #3a3a6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffa726;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.85em;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.5;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image modal/lightbox styles */
|
||||||
|
.image-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.95);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-modal.active {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-modal-content {
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 3px solid #ffa726;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 30px rgba(255, 167, 38, 0.5);
|
||||||
|
animation: zoomIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes zoomIn {
|
||||||
|
from { transform: scale(0.8); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 30px;
|
||||||
|
font-size: 40px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10000;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 0, 0, 0.8);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-modal-close:hover {
|
||||||
|
color: #ffa726;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-modal-info {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make images clickable */
|
||||||
|
.location-image,
|
||||||
|
.interactable-image,
|
||||||
|
.enemy-image {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-image:hover,
|
||||||
|
.interactable-image:hover,
|
||||||
|
.enemy-image:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 0 15px rgba(255, 167, 38, 0.6);
|
||||||
|
border-color: #ffa726;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #3a3a6a;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #4a4a7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsive Styles */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: 1fr 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 15px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-section {
|
||||||
|
border-right: none;
|
||||||
|
min-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel {
|
||||||
|
max-height: 50vh;
|
||||||
|
border-top: 2px solid #2a2a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 12px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-link {
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 6px 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section h2 {
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section h3 {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connections {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-section {
|
||||||
|
height: 60vh;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel {
|
||||||
|
max-height: none;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-link {
|
||||||
|
font-size: 0.8em;
|
||||||
|
padding: 6px 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-link-icon {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section h2 {
|
||||||
|
font-size: 1em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section h3 {
|
||||||
|
font-size: 0.95em;
|
||||||
|
margin: 10px 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-item {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactable-card,
|
||||||
|
.enemy-card {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal adjustments for mobile */
|
||||||
|
.image-modal-content {
|
||||||
|
max-width: 95%;
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-modal-close {
|
||||||
|
top: 10px;
|
||||||
|
right: 15px;
|
||||||
|
font-size: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-modal-info {
|
||||||
|
bottom: 10px;
|
||||||
|
padding: 8px 15px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Landscape orientation on mobile */
|
||||||
|
@media (max-width: 768px) and (orientation: landscape) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: 1fr 300px;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-section {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel {
|
||||||
|
max-height: 100vh;
|
||||||
|
border-top: none;
|
||||||
|
border-left: 2px solid #2a2a4a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch-friendly controls */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
.controls button {
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-image,
|
||||||
|
.interactable-image,
|
||||||
|
.enemy-image {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Larger tap targets */
|
||||||
|
.connection-item,
|
||||||
|
.action-item,
|
||||||
|
.stat-item {
|
||||||
|
min-height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="map-section">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🗺️ Interactive World Map</h1>
|
||||||
|
<p class="subtitle">Echoes of the Ashes</p>
|
||||||
|
<a href="https://t.me/echoes_of_the_ash_bot" target="_blank" rel="noopener noreferrer" class="bot-link">
|
||||||
|
<span class="bot-link-icon">🤖</span>
|
||||||
|
<span>Play on Telegram</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="canvas-wrapper">
|
||||||
|
<canvas id="mapCanvas"></canvas>
|
||||||
|
<div class="controls">
|
||||||
|
<button id="zoomIn" title="Zoom In">+</button>
|
||||||
|
<button id="zoomOut" title="Zoom Out">−</button>
|
||||||
|
<button id="resetView" title="Reset View">⟲</button>
|
||||||
|
<button id="toggleLabels" title="Toggle Labels">🏷️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-panel">
|
||||||
|
<div class="info-section">
|
||||||
|
<h2>📍 Location Details</h2>
|
||||||
|
<div id="locationInfo">
|
||||||
|
<p class="no-data">Click on a location to see details</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-section">
|
||||||
|
<h2>🎯 Interactables</h2>
|
||||||
|
<div id="interactablesInfo">
|
||||||
|
<p class="no-data">Select a location to see interactables</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-section">
|
||||||
|
<h2>⚔️ Enemy Encounters</h2>
|
||||||
|
<div id="enemiesInfo">
|
||||||
|
<p class="no-data">Select a location to see possible enemies</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-section">
|
||||||
|
<h2>📊 Map Statistics</h2>
|
||||||
|
<div id="statsInfo">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value" id="totalLocations">-</span>
|
||||||
|
<span class="stat-label">Locations</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value" id="totalConnections">-</span>
|
||||||
|
<span class="stat-label">Routes</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value" id="totalInteractables">-</span>
|
||||||
|
<span class="stat-label">Interactables</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value" id="totalEnemies">-</span>
|
||||||
|
<span class="stat-label">Enemy Types</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-section">
|
||||||
|
<h2>🗺️ Legend</h2>
|
||||||
|
<div class="legend">
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background: #4fc3f7;"></div>
|
||||||
|
<span>Safe Zone</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background: #ffa726;"></div>
|
||||||
|
<span>Low Danger</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background: #ff7043;"></div>
|
||||||
|
<span>Medium Danger</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background: #e53935;"></div>
|
||||||
|
<span>High Danger</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Modal/Lightbox -->
|
||||||
|
<div id="imageModal" class="image-modal">
|
||||||
|
<span class="image-modal-close">×</span>
|
||||||
|
<img id="modalImage" class="image-modal-content" alt="Full size image">
|
||||||
|
<div class="image-modal-info" id="modalInfo">Click anywhere to close</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="map.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
603
web-map/map.js
Normal file
@@ -0,0 +1,603 @@
|
|||||||
|
// Map visualization with pan, zoom, and interactive features
|
||||||
|
let canvas, ctx;
|
||||||
|
let mapData = { locations: [], connections: [], interactables: [], spawn_tables: {} };
|
||||||
|
let viewOffset = { x: 0, y: 0 };
|
||||||
|
let viewScale = 1.0;
|
||||||
|
let isDragging = false;
|
||||||
|
let lastMouse = { x: 0, y: 0 };
|
||||||
|
let showLabels = true;
|
||||||
|
let selectedLocation = null;
|
||||||
|
|
||||||
|
// Visual settings
|
||||||
|
const gridSize = 100; // pixels per game unit
|
||||||
|
const nodeRadius = 12;
|
||||||
|
const colors = {
|
||||||
|
background: '#0a0a1a',
|
||||||
|
grid: '#1a1a2e',
|
||||||
|
connection: '#3a3a6a',
|
||||||
|
nodeSafe: '#4fc3f7',
|
||||||
|
nodeLowDanger: '#ffa726',
|
||||||
|
nodeMediumDanger: '#ff7043',
|
||||||
|
nodeHighDanger: '#e53935',
|
||||||
|
nodeSelected: '#00ff88',
|
||||||
|
text: '#ffffff',
|
||||||
|
label: '#e0e0e0'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
window.addEventListener('load', async () => {
|
||||||
|
canvas = document.getElementById('mapCanvas');
|
||||||
|
ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
resizeCanvas();
|
||||||
|
window.addEventListener('resize', resizeCanvas);
|
||||||
|
|
||||||
|
// Load map data
|
||||||
|
await loadMapData();
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
canvas.addEventListener('mousedown', onMouseDown);
|
||||||
|
canvas.addEventListener('mousemove', onMouseMove);
|
||||||
|
canvas.addEventListener('mouseup', onMouseUp);
|
||||||
|
canvas.addEventListener('mouseleave', onMouseUp);
|
||||||
|
canvas.addEventListener('wheel', onWheel);
|
||||||
|
canvas.addEventListener('click', onClick);
|
||||||
|
|
||||||
|
// Touch event listeners for mobile
|
||||||
|
canvas.addEventListener('touchstart', onTouchStart, { passive: false });
|
||||||
|
canvas.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||||
|
canvas.addEventListener('touchend', onTouchEnd);
|
||||||
|
|
||||||
|
document.getElementById('zoomIn').addEventListener('click', () => zoom(1.2));
|
||||||
|
document.getElementById('zoomOut').addEventListener('click', () => zoom(0.8));
|
||||||
|
document.getElementById('resetView').addEventListener('click', resetView);
|
||||||
|
document.getElementById('toggleLabels').addEventListener('click', toggleLabels);
|
||||||
|
|
||||||
|
// Image modal setup
|
||||||
|
setupImageModal();
|
||||||
|
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
canvas.width = canvas.clientWidth;
|
||||||
|
canvas.height = canvas.clientHeight;
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMapData() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/map_data.json');
|
||||||
|
mapData = await response.json();
|
||||||
|
centerView();
|
||||||
|
updateStatistics();
|
||||||
|
draw();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load map data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatistics() {
|
||||||
|
document.getElementById('totalLocations').textContent = mapData.locations.length;
|
||||||
|
document.getElementById('totalConnections').textContent = mapData.connections.length;
|
||||||
|
document.getElementById('totalInteractables').textContent = mapData.interactables.length;
|
||||||
|
|
||||||
|
const uniqueEnemies = new Set();
|
||||||
|
Object.values(mapData.spawn_tables).forEach(enemies => {
|
||||||
|
enemies.forEach(enemy => uniqueEnemies.add(enemy.npc_id));
|
||||||
|
});
|
||||||
|
document.getElementById('totalEnemies').textContent = uniqueEnemies.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function worldToScreen(x, y) {
|
||||||
|
return {
|
||||||
|
x: (x * gridSize * viewScale) + viewOffset.x + canvas.width / 2,
|
||||||
|
y: (-y * gridSize * viewScale) + viewOffset.y + canvas.height / 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenToWorld(sx, sy) {
|
||||||
|
return {
|
||||||
|
x: ((sx - canvas.width / 2 - viewOffset.x) / gridSize) / viewScale,
|
||||||
|
y: -((sy - canvas.height / 2 - viewOffset.y) / gridSize) / viewScale
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerView() {
|
||||||
|
if (mapData.locations.length === 0) return;
|
||||||
|
|
||||||
|
// Calculate bounds
|
||||||
|
let minX = Infinity, maxX = -Infinity;
|
||||||
|
let minY = Infinity, maxY = -Infinity;
|
||||||
|
|
||||||
|
mapData.locations.forEach(loc => {
|
||||||
|
minX = Math.min(minX, loc.x);
|
||||||
|
maxX = Math.max(maxX, loc.x);
|
||||||
|
minY = Math.min(minY, loc.y);
|
||||||
|
maxY = Math.max(maxY, loc.y);
|
||||||
|
});
|
||||||
|
|
||||||
|
const centerX = (minX + maxX) / 2;
|
||||||
|
const centerY = (minY + maxY) / 2;
|
||||||
|
|
||||||
|
viewOffset.x = -centerX * gridSize * viewScale;
|
||||||
|
viewOffset.y = centerY * gridSize * viewScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetView() {
|
||||||
|
viewScale = 1.0;
|
||||||
|
centerView();
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLabels() {
|
||||||
|
showLabels = !showLabels;
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoom(factor) {
|
||||||
|
const oldScale = viewScale;
|
||||||
|
viewScale *= factor;
|
||||||
|
viewScale = Math.max(0.3, Math.min(3.0, viewScale));
|
||||||
|
|
||||||
|
const scaleDiff = viewScale - oldScale;
|
||||||
|
viewOffset.x -= (canvas.width / 2) * scaleDiff / oldScale;
|
||||||
|
viewOffset.y -= (canvas.height / 2) * scaleDiff / oldScale;
|
||||||
|
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseDown(e) {
|
||||||
|
isDragging = true;
|
||||||
|
lastMouse = { x: e.clientX, y: e.clientY };
|
||||||
|
canvas.style.cursor = 'grabbing';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMove(e) {
|
||||||
|
if (isDragging) {
|
||||||
|
const dx = e.clientX - lastMouse.x;
|
||||||
|
const dy = e.clientY - lastMouse.y;
|
||||||
|
viewOffset.x += dx;
|
||||||
|
viewOffset.y += dy;
|
||||||
|
lastMouse = { x: e.clientX, y: e.clientY };
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
isDragging = false;
|
||||||
|
canvas.style.cursor = 'grab';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch event handlers for mobile
|
||||||
|
let lastTouch = { x: 0, y: 0 };
|
||||||
|
let touchStartTime = 0;
|
||||||
|
let lastTouchDistance = 0;
|
||||||
|
|
||||||
|
function onTouchStart(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
// Single touch - pan
|
||||||
|
isDragging = true;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
lastTouch = { x: touch.clientX, y: touch.clientY };
|
||||||
|
lastMouse = { x: touch.clientX, y: touch.clientY };
|
||||||
|
touchStartTime = Date.now();
|
||||||
|
} else if (e.touches.length === 2) {
|
||||||
|
// Two touches - pinch zoom
|
||||||
|
isDragging = false;
|
||||||
|
const touch1 = e.touches[0];
|
||||||
|
const touch2 = e.touches[1];
|
||||||
|
lastTouchDistance = Math.sqrt(
|
||||||
|
Math.pow(touch2.clientX - touch1.clientX, 2) +
|
||||||
|
Math.pow(touch2.clientY - touch1.clientY, 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (e.touches.length === 1 && isDragging) {
|
||||||
|
// Single touch - pan
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const dx = touch.clientX - lastTouch.x;
|
||||||
|
const dy = touch.clientY - lastTouch.y;
|
||||||
|
viewOffset.x += dx;
|
||||||
|
viewOffset.y += dy;
|
||||||
|
lastTouch = { x: touch.clientX, y: touch.clientY };
|
||||||
|
draw();
|
||||||
|
} else if (e.touches.length === 2) {
|
||||||
|
// Two touches - pinch zoom
|
||||||
|
const touch1 = e.touches[0];
|
||||||
|
const touch2 = e.touches[1];
|
||||||
|
const newDistance = Math.sqrt(
|
||||||
|
Math.pow(touch2.clientX - touch1.clientX, 2) +
|
||||||
|
Math.pow(touch2.clientY - touch1.clientY, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lastTouchDistance > 0) {
|
||||||
|
const zoomFactor = newDistance / lastTouchDistance;
|
||||||
|
|
||||||
|
// Calculate center point between two touches
|
||||||
|
const centerX = (touch1.clientX + touch2.clientX) / 2;
|
||||||
|
const centerY = (touch1.clientY + touch2.clientY) / 2;
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mouseX = centerX - rect.left;
|
||||||
|
const mouseY = centerY - rect.top;
|
||||||
|
|
||||||
|
const worldBefore = screenToWorld(mouseX, mouseY);
|
||||||
|
zoom(zoomFactor);
|
||||||
|
const worldAfter = screenToWorld(mouseX, mouseY);
|
||||||
|
|
||||||
|
viewOffset.x += (worldAfter.x - worldBefore.x) * gridSize * viewScale;
|
||||||
|
viewOffset.y -= (worldAfter.y - worldBefore.y) * gridSize * viewScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTouchDistance = newDistance;
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd(e) {
|
||||||
|
if (e.touches.length === 0) {
|
||||||
|
isDragging = false;
|
||||||
|
lastTouchDistance = 0;
|
||||||
|
|
||||||
|
// Check if it was a quick tap (< 200ms) for click action
|
||||||
|
const touchDuration = Date.now() - touchStartTime;
|
||||||
|
if (touchDuration < 200 && e.changedTouches.length === 1) {
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
// Simulate click event
|
||||||
|
const clickEvent = {
|
||||||
|
clientX: touch.clientX,
|
||||||
|
clientY: touch.clientY
|
||||||
|
};
|
||||||
|
onClick(clickEvent);
|
||||||
|
}
|
||||||
|
} else if (e.touches.length === 1) {
|
||||||
|
// One finger remaining, reset for pan
|
||||||
|
const touch = e.touches[0];
|
||||||
|
lastTouch = { x: touch.clientX, y: touch.clientY };
|
||||||
|
isDragging = true;
|
||||||
|
lastTouchDistance = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const zoomSpeed = 0.001;
|
||||||
|
const delta = -e.deltaY * zoomSpeed;
|
||||||
|
const factor = 1 + delta;
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX - rect.left;
|
||||||
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
const worldBefore = screenToWorld(mouseX, mouseY);
|
||||||
|
zoom(factor);
|
||||||
|
const worldAfter = screenToWorld(mouseX, mouseY);
|
||||||
|
|
||||||
|
viewOffset.x += (worldAfter.x - worldBefore.x) * gridSize * viewScale;
|
||||||
|
viewOffset.y -= (worldAfter.y - worldBefore.y) * gridSize * viewScale;
|
||||||
|
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(e) {
|
||||||
|
if (isDragging) return;
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX - rect.left;
|
||||||
|
const mouseY = e.clientY - rect.top;
|
||||||
|
const worldPos = screenToWorld(mouseX, mouseY);
|
||||||
|
|
||||||
|
// Find clicked location
|
||||||
|
for (const location of mapData.locations) {
|
||||||
|
const dist = Math.sqrt(
|
||||||
|
Math.pow(location.x - worldPos.x, 2) +
|
||||||
|
Math.pow(location.y - worldPos.y, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dist < 0.3) { // Click threshold
|
||||||
|
selectedLocation = location;
|
||||||
|
showLocationInfo(location);
|
||||||
|
draw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDangerLevel(locationId) {
|
||||||
|
const spawns = mapData.spawn_tables[locationId];
|
||||||
|
if (!spawns || spawns.length === 0) return 'safe';
|
||||||
|
|
||||||
|
// Calculate average enemy strength
|
||||||
|
let totalXP = 0;
|
||||||
|
spawns.forEach(enemy => {
|
||||||
|
totalXP += enemy.xp_reward * (enemy.spawn_weight / 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (totalXP < 20) return 'low';
|
||||||
|
if (totalXP < 40) return 'medium';
|
||||||
|
return 'high';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeColor(locationId) {
|
||||||
|
const danger = getDangerLevel(locationId);
|
||||||
|
switch (danger) {
|
||||||
|
case 'safe': return colors.nodeSafe;
|
||||||
|
case 'low': return colors.nodeLowDanger;
|
||||||
|
case 'medium': return colors.nodeMediumDanger;
|
||||||
|
case 'high': return colors.nodeHighDanger;
|
||||||
|
default: return colors.nodeSafe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
// Clear canvas
|
||||||
|
ctx.fillStyle = colors.background;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Draw grid
|
||||||
|
drawGrid();
|
||||||
|
|
||||||
|
// Draw connections
|
||||||
|
ctx.lineWidth = 2 * viewScale;
|
||||||
|
mapData.connections.forEach(conn => {
|
||||||
|
const from = mapData.locations.find(l => l.id === conn.from);
|
||||||
|
const to = mapData.locations.find(l => l.id === conn.to);
|
||||||
|
|
||||||
|
if (from && to) {
|
||||||
|
const fromScreen = worldToScreen(from.x, from.y);
|
||||||
|
const toScreen = worldToScreen(to.x, to.y);
|
||||||
|
|
||||||
|
ctx.strokeStyle = colors.connection;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(fromScreen.x, fromScreen.y);
|
||||||
|
ctx.lineTo(toScreen.x, toScreen.y);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw distance label
|
||||||
|
if (showLabels && viewScale > 0.6) {
|
||||||
|
const midX = (fromScreen.x + toScreen.x) / 2;
|
||||||
|
const midY = (fromScreen.y + toScreen.y) / 2;
|
||||||
|
const stamina = Math.ceil(conn.distance * 3);
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(42, 42, 74, 0.9)';
|
||||||
|
ctx.fillRect(midX - 20, midY - 10, 40, 20);
|
||||||
|
|
||||||
|
ctx.fillStyle = colors.label;
|
||||||
|
ctx.font = `${10 * viewScale}px sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(`${stamina}⚡`, midX, midY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw locations
|
||||||
|
mapData.locations.forEach(location => {
|
||||||
|
const screen = worldToScreen(location.x, location.y);
|
||||||
|
const radius = nodeRadius * viewScale;
|
||||||
|
|
||||||
|
// Node circle
|
||||||
|
const isSelected = selectedLocation && selectedLocation.id === location.id;
|
||||||
|
ctx.fillStyle = isSelected ? colors.nodeSelected : getNodeColor(location.id);
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = isSelected ? 3 * viewScale : 2 * viewScale;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(screen.x, screen.y, radius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Interactable indicator
|
||||||
|
if (location.interactable_count > 0) {
|
||||||
|
ctx.fillStyle = '#ffd700';
|
||||||
|
ctx.font = `${8 * viewScale}px sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(location.interactable_count, screen.x, screen.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label
|
||||||
|
if (showLabels) {
|
||||||
|
ctx.fillStyle = colors.label;
|
||||||
|
ctx.font = `${12 * viewScale}px sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillText(location.name, screen.x, screen.y + radius + 5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGrid() {
|
||||||
|
ctx.strokeStyle = colors.grid;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
const step = gridSize * viewScale;
|
||||||
|
const startX = (viewOffset.x % step) - step;
|
||||||
|
const startY = (viewOffset.y % step) - step;
|
||||||
|
|
||||||
|
for (let x = startX; x < canvas.width; x += step) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, canvas.height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = startY; y < canvas.height; y += step) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(canvas.width, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLocationInfo(location) {
|
||||||
|
// Location details
|
||||||
|
const locationHTML = `
|
||||||
|
${location.image_path ?
|
||||||
|
`<img src="/${location.image_path}" class="location-image" onerror="this.style.display='none';" />` :
|
||||||
|
`<div class="image-placeholder">🗺️</div>`
|
||||||
|
}
|
||||||
|
<h3>${location.name}</h3>
|
||||||
|
<div class="description">${location.description}</div>
|
||||||
|
<p><strong>Coordinates:</strong> (${location.x}, ${location.y})</p>
|
||||||
|
<p><strong>Interactables:</strong> ${location.interactable_count}</p>
|
||||||
|
<h3>🧭 Connections</h3>
|
||||||
|
<div class="connections">
|
||||||
|
${mapData.connections
|
||||||
|
.filter(c => c.from === location.id)
|
||||||
|
.map(c => {
|
||||||
|
const dest = mapData.locations.find(l => l.id === c.to);
|
||||||
|
const stamina = Math.ceil(c.distance * 3);
|
||||||
|
return `<div class="connection-item">
|
||||||
|
<strong>${c.direction.toUpperCase()}</strong><br>
|
||||||
|
${dest ? dest.name : c.to}<br>
|
||||||
|
${stamina}⚡ stamina
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.getElementById('locationInfo').innerHTML = locationHTML;
|
||||||
|
|
||||||
|
// Add click handler to location image
|
||||||
|
setTimeout(() => {
|
||||||
|
const locationImg = document.querySelector('.location-image');
|
||||||
|
if (locationImg) {
|
||||||
|
locationImg.addEventListener('click', () => openImageModal(locationImg.src, location.name));
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Interactables
|
||||||
|
const locationInteractables = mapData.interactables.filter(i => i.location_id === location.id);
|
||||||
|
if (locationInteractables.length > 0) {
|
||||||
|
const interactablesHTML = `
|
||||||
|
<div class="interactable-list">
|
||||||
|
${locationInteractables.map(inter => `
|
||||||
|
<div class="interactable-card">
|
||||||
|
<div class="interactable-header">
|
||||||
|
<div class="interactable-icon">
|
||||||
|
${inter.image_path ?
|
||||||
|
`<img src="/${inter.image_path}" class="interactable-image" onerror="this.outerHTML='<div class=\\'interactable-image-placeholder\\'>📦</div>';" />` :
|
||||||
|
`<div class="interactable-image-placeholder">📦</div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="interactable-name">${inter.name}</div>
|
||||||
|
</div>
|
||||||
|
${inter.actions.map(action => `
|
||||||
|
<div class="action-item">
|
||||||
|
<div class="action-header">${action.label} (${action.stamina_cost}⚡)</div>
|
||||||
|
${action.outcomes.map(outcome => `
|
||||||
|
<div class="outcome-item outcome-${outcome.type}">
|
||||||
|
<strong>${outcome.type}:</strong> ${outcome.text}
|
||||||
|
${Object.keys(outcome.items).length > 0 ?
|
||||||
|
`<br>Items: ${Object.entries(outcome.items).map(([id, qty]) => `${id} x${qty}`).join(', ')}` : ''}
|
||||||
|
${outcome.damage > 0 ? `<br>⚠️ Damage: ${outcome.damage} HP` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.getElementById('interactablesInfo').innerHTML = interactablesHTML;
|
||||||
|
|
||||||
|
// Add click handlers to interactable images
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelectorAll('.interactable-image').forEach(img => {
|
||||||
|
img.addEventListener('click', () => openImageModal(img.src, img.alt || 'Interactable'));
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
document.getElementById('interactablesInfo').innerHTML = '<p class="no-data">No interactables at this location</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enemies
|
||||||
|
const enemies = mapData.spawn_tables[location.id];
|
||||||
|
if (enemies && enemies.length > 0) {
|
||||||
|
const enemiesHTML = `
|
||||||
|
<p><strong>Encounter Rate:</strong> ${enemies[0].encounter_rate}% when traveling</p>
|
||||||
|
<div class="enemy-list">
|
||||||
|
${enemies.map(enemy => `
|
||||||
|
<div class="enemy-card">
|
||||||
|
<div class="enemy-header">
|
||||||
|
<div class="enemy-icon">
|
||||||
|
${enemy.image_url ?
|
||||||
|
`<img src="/${enemy.image_url}" class="enemy-image" onerror="this.outerHTML='<div class=\\'enemy-image-placeholder\\'>${enemy.emoji}</div>';" />` :
|
||||||
|
`<div class="enemy-image-placeholder">${enemy.emoji}</div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="enemy-name">${enemy.name}</div>
|
||||||
|
<div>${enemy.spawn_chance}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="enemy-stats">
|
||||||
|
<div class="stat-item">❤️ HP: ${enemy.hp_range[0]}-${enemy.hp_range[1]}</div>
|
||||||
|
<div class="stat-item">⚔️ DMG: ${enemy.damage_range[0]}-${enemy.damage_range[1]}</div>
|
||||||
|
<div class="stat-item">⭐ XP: ${enemy.xp_reward}</div>
|
||||||
|
<div class="stat-item">🎲 Weight: ${enemy.spawn_weight}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.getElementById('enemiesInfo').innerHTML = enemiesHTML;
|
||||||
|
|
||||||
|
// Add click handlers to enemy images
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelectorAll('.enemy-image').forEach(img => {
|
||||||
|
img.addEventListener('click', () => openImageModal(img.src, img.alt || 'Enemy'));
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
document.getElementById('enemiesInfo').innerHTML = '<p class="no-data">✅ Safe zone - no enemies spawn here</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image Modal Functions
|
||||||
|
function setupImageModal() {
|
||||||
|
const modal = document.getElementById('imageModal');
|
||||||
|
const closeBtn = document.querySelector('.image-modal-close');
|
||||||
|
|
||||||
|
// Close modal when clicking close button, backdrop, or pressing Escape
|
||||||
|
closeBtn.addEventListener('click', closeImageModal);
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeImageModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && modal.classList.contains('active')) {
|
||||||
|
closeImageModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImageModal(imageSrc, imageTitle) {
|
||||||
|
const modal = document.getElementById('imageModal');
|
||||||
|
const modalImg = document.getElementById('modalImage');
|
||||||
|
const modalInfo = document.getElementById('modalInfo');
|
||||||
|
|
||||||
|
modalImg.src = imageSrc;
|
||||||
|
modalImg.alt = imageTitle;
|
||||||
|
modalInfo.textContent = imageTitle + ' - Click anywhere to close';
|
||||||
|
modal.classList.add('active');
|
||||||
|
|
||||||
|
// Prevent body scrolling when modal is open
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeImageModal() {
|
||||||
|
const modal = document.getElementById('imageModal');
|
||||||
|
modal.classList.remove('active');
|
||||||
|
|
||||||
|
// Re-enable body scrolling
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
}
|
||||||
3
web-map/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
flask==3.0.0
|
||||||
|
flask-cors==4.0.0
|
||||||
|
werkzeug==3.0.1
|
||||||
117
web-map/server.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Get the directory of this script
|
||||||
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||||
|
|
||||||
|
class MapServerHandler(SimpleHTTPRequestHandler):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
# Set the directory to serve files from
|
||||||
|
super().__init__(*args, directory=str(SCRIPT_DIR), **kwargs)
|
||||||
|
|
||||||
|
def end_headers(self):
|
||||||
|
# Add CORS headers to allow access from anywhere
|
||||||
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
||||||
|
self.send_header('Access-Control-Allow-Headers', '*')
|
||||||
|
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate')
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
# Handle map_data.json request
|
||||||
|
if self.path == '/map_data.json':
|
||||||
|
try:
|
||||||
|
# Try to load from parent directory's data module
|
||||||
|
sys.path.insert(0, str(SCRIPT_DIR.parent))
|
||||||
|
from data.world_loader import export_map_data
|
||||||
|
|
||||||
|
map_data = export_map_data()
|
||||||
|
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-type', 'application/json')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(map_data, indent=2).encode())
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error generating map data: {e}")
|
||||||
|
# Fall back to static file if it exists
|
||||||
|
map_file = SCRIPT_DIR / 'map_data.json'
|
||||||
|
if map_file.exists():
|
||||||
|
with open(map_file, 'r') as f:
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-type', 'application/json')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(f.read().encode())
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_error(500, f"Failed to generate map data: {str(e)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Handle image requests from parent images directory
|
||||||
|
if self.path.startswith('/images/'):
|
||||||
|
try:
|
||||||
|
# Construct path to image in parent directory
|
||||||
|
image_path = SCRIPT_DIR.parent / self.path.lstrip('/')
|
||||||
|
|
||||||
|
if image_path.exists() and image_path.is_file():
|
||||||
|
# Determine content type based on file extension
|
||||||
|
content_types = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.svg': 'image/svg+xml'
|
||||||
|
}
|
||||||
|
ext = image_path.suffix.lower()
|
||||||
|
content_type = content_types.get(ext, 'application/octet-stream')
|
||||||
|
|
||||||
|
with open(image_path, 'rb') as f:
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-type', content_type)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(f.read())
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
self.send_error(404, f"Image not found: {self.path}")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error serving image {self.path}: {e}")
|
||||||
|
self.send_error(500, f"Failed to serve image: {str(e)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Serve other files normally
|
||||||
|
return super().do_GET()
|
||||||
|
|
||||||
|
def run_server(port=8080):
|
||||||
|
server_address = ('', port)
|
||||||
|
httpd = HTTPServer(server_address, MapServerHandler)
|
||||||
|
print(f"""
|
||||||
|
╔════════════════════════════════════════════╗
|
||||||
|
║ Echoes of the Ashes - Map Server ║
|
||||||
|
╚════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
🗺️ Map server running on:
|
||||||
|
→ http://localhost:{port}
|
||||||
|
→ http://0.0.0.0:{port}
|
||||||
|
|
||||||
|
📊 Serving from: {SCRIPT_DIR}
|
||||||
|
|
||||||
|
Press Ctrl+C to stop the server
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
httpd.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\n👋 Shutting down server...")
|
||||||
|
httpd.shutdown()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser(description='Run the RPG map visualization server')
|
||||||
|
parser.add_argument('--port', type=int, default=8080, help='Port to run the server on (default: 8080)')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
run_server(args.port)
|
||||||