commit 3ab412bc0964a04948bf56ccc6003342587d0354 Author: Joan Date: Sat Oct 18 19:21:19 2025 +0200 Initial commit: Echoes of the Ashes - Telegram RPG Bot diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fab2bc --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f927395 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.map b/Dockerfile.map new file mode 100644 index 0000000..b38be3e --- /dev/null +++ b/Dockerfile.map @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9530893 --- /dev/null +++ b/README.md @@ -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. + +![Python](https://img.shields.io/badge/python-3.11-blue) +![Telegram Bot API](https://img.shields.io/badge/telegram--bot--api-21.0.1-blue) +![PostgreSQL](https://img.shields.io/badge/postgresql-15-blue) +![Docker](https://img.shields.io/badge/docker-compose-blue) + +## ๐ŸŽฎ 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 diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/combat.py b/bot/combat.py new file mode 100644 index 0000000..b29261d --- /dev/null +++ b/bot/combat.py @@ -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) diff --git a/bot/database.py b/bot/database.py new file mode 100644 index 0000000..a79813a --- /dev/null +++ b/bot/database.py @@ -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()] diff --git a/bot/handlers.py b/bot/handlers.py new file mode 100644 index 0000000..428205c --- /dev/null +++ b/bot/handlers.py @@ -0,0 +1,1268 @@ +import logging +import math +import json +import random +from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton +from telegram.ext import ContextTypes +from telegram.error import BadRequest +from . import database, keyboards, logic +from .utils import admin_only +from data.world_loader import game_world +from data.items import ITEMS + +logger = logging.getLogger(__name__) + +# ... (get_player_status_text, send_or_edit_with_image, start are unchanged) ... +async def get_player_status_text(telegram_id: int) -> str: + player = await database.get_player(telegram_id) + if not player: return "Could not find player data." + location = game_world.get_location(player["location_id"]) + if not location: return "Error: Player is in an unknown location." + inventory = await database.get_inventory(telegram_id) + weight, volume = logic.calculate_inventory_load(inventory) + max_weight, max_volume = logic.get_player_capacity(inventory, player) + + # Get equipped items + equipped_items = [] + for item in inventory: + if item.get('is_equipped'): + item_def = ITEMS.get(item['item_id'], {}) + emoji = item_def.get('emoji', 'โ”') + equipped_items.append(f"{emoji} {item_def.get('name', 'Unknown')}") + + status = f"Location: {location.name}\nStatus: Healthy\n" + status += f"โค๏ธ HP: {player['hp']}/{player['max_hp']} | โšก๏ธ Stamina: {player['stamina']}/{player['max_stamina']}\n" + status += f"๐ŸŽ’ Load: {weight}/{max_weight} kg | {volume}/{max_volume} vol\n" + + if equipped_items: + status += f"โš”๏ธ Equipped: {', '.join(equipped_items)}\n" + + status += f"โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n{location.description}" + return status +async def send_or_edit_with_image(query, text: str, reply_markup: InlineKeyboardMarkup, image_path: str = None, parse_mode='HTML'): + """ + Send a message with an image (as caption) or edit existing message. + Uses edit_message_media for smooth transitions when changing images. + """ + import os + from telegram import InputMediaPhoto + + # Check if we should edit or send new + current_message = query.message + has_photo = bool(current_message.photo) + + if image_path: + # Get or upload image + cached_file_id = await database.get_cached_image(image_path) + + if not cached_file_id and os.path.exists(image_path): + # Upload new image + try: + with open(image_path, 'rb') as img_file: + temp_msg = await current_message.reply_photo( + photo=img_file, + caption=text, + reply_markup=reply_markup, + parse_mode=parse_mode + ) + if temp_msg.photo: + cached_file_id = temp_msg.photo[-1].file_id + await database.cache_image(image_path, cached_file_id) + # Delete old message to keep chat clean + try: + await current_message.delete() + except: + pass + return + except Exception as e: + logger.error(f"Error uploading image: {e}") + cached_file_id = None + + if cached_file_id: + # Check if current message has same photo + if has_photo: + current_file_id = current_message.photo[-1].file_id + if current_file_id == cached_file_id: + # Same image, just edit caption + try: + await query.edit_message_caption( + caption=text, + reply_markup=reply_markup, + parse_mode=parse_mode + ) + return + except BadRequest as e: + if "Message is not modified" in str(e): + return + # Failed to edit, fall through to send new + else: + # Different image - use edit_message_media for smooth transition + try: + media = InputMediaPhoto( + media=cached_file_id, + caption=text, + parse_mode=parse_mode + ) + await query.edit_message_media( + media=media, + reply_markup=reply_markup + ) + return + except Exception as e: + logger.error(f"Error editing message media: {e}") + # Fall through to delete and send new + + # Current message has no photo - try to edit to photo + if not has_photo: + # Can't edit text message to photo message, need to delete and send new + try: + await current_message.delete() + except: + pass + + try: + await current_message.reply_photo( + photo=cached_file_id, + caption=text, + reply_markup=reply_markup, + parse_mode=parse_mode + ) + except Exception as e: + logger.error(f"Error sending cached image: {e}") + else: + # No image requested + if has_photo: + # Current message has photo, need to delete and send text-only + try: + await current_message.delete() + except: + pass + await current_message.reply_html(text=text, reply_markup=reply_markup) + else: + # Both text-only, just edit + try: + await query.edit_message_text(text=text, reply_markup=reply_markup, parse_mode=parse_mode) + except BadRequest as e: + if "Message is not modified" not in str(e): + await current_message.reply_html(text=text, reply_markup=reply_markup) +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + import os + user = update.effective_user + player = await database.get_player(user.id) + if not player: + await database.create_player(user.id, user.first_name) + await update.message.reply_html(f"Welcome, {user.mention_html()}! Your story is just beginning.") + + # Get player status and location image + player = await database.get_player(user.id) + status_text = await get_player_status_text(user.id) + location = game_world.get_location(player['location_id']) + + # Send with image if available + if location and location.image_path: + cached_file_id = await database.get_cached_image(location.image_path) + if cached_file_id: + await update.message.reply_photo( + photo=cached_file_id, + caption=status_text, + reply_markup=keyboards.main_menu_keyboard(), + parse_mode='HTML' + ) + elif os.path.exists(location.image_path): + with open(location.image_path, 'rb') as img_file: + msg = await update.message.reply_photo( + photo=img_file, + caption=status_text, + reply_markup=keyboards.main_menu_keyboard(), + parse_mode='HTML' + ) + if msg.photo: + await database.cache_image(location.image_path, msg.photo[-1].file_id) + else: + await update.message.reply_html(status_text, reply_markup=keyboards.main_menu_keyboard()) + else: + await update.message.reply_html(status_text, reply_markup=keyboards.main_menu_keyboard()) + +@admin_only +async def export_map(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Export map data as JSON for external visualization.""" + from data.world_loader import export_map_data + import json + + map_data = export_map_data() + json_str = json.dumps(map_data, indent=2) + + # Send as text file + from io import BytesIO + file = BytesIO(json_str.encode('utf-8')) + file.name = "map_data.json" + + await update.message.reply_document( + document=file, + filename="map_data.json", + caption="๐Ÿ—บ๏ธ Game Map Data\n\nThis JSON file contains all locations, coordinates, and connections.\nYou can use it to visualize the game map in external tools." + ) + + +@admin_only +async def spawn_stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show wandering enemy spawn statistics (debug command).""" + from bot.spawn_manager import get_spawn_stats + + stats = await get_spawn_stats() + + text = "๐Ÿ“Š Wandering Enemy Statistics\n\n" + text += f"Total Active Enemies: {stats['total_active']}\n\n" + + if stats['by_location']: + text += "Enemies by Location:\n" + for loc_id, count in stats['by_location'].items(): + from data.world_loader import game_world + location = game_world.get_location(loc_id) + loc_name = location.name if location else loc_id + text += f"โ€ข {loc_name}: {count}\n" + else: + text += "No wandering enemies currently active." + + await update.message.reply_html(text) + +async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + user_id = query.from_user.id + data = query.data.split(':') + action_type = data[0] + + player = await database.get_player(user_id) + if not player or player['is_dead']: + await query.answer() + await send_or_edit_with_image(query, text="๐Ÿ’€ Your journey has ended. You died in the wasteland. Create a new character with /start to begin again.", reply_markup=None) + return + + # Check if player is in combat - restrict most actions + combat = await database.get_combat(user_id) + if combat and action_type not in ['combat_attack', 'combat_flee', 'combat_use_item_menu', 'combat_use_item', 'combat_back', 'no_op']: + await query.answer("You're in combat! Focus on the fight!", show_alert=False) + return + + # --- Inspection & World Interaction --- + if action_type == "inspect_area": + await query.answer() + location_id = player['location_id'] + location = game_world.get_location(location_id) + dropped_items = await database.get_dropped_items_in_location(location_id) + + # Get wandering enemies from database + wandering_enemies = await database.get_wandering_enemies_in_location(location_id) + + keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies) + image_path = location.image_path if location else None + await send_or_edit_with_image(query, text="You scan the area. You notice...", reply_markup=keyboard, image_path=image_path) + + elif action_type == "attack_wandering": + # Player initiates combat with a wandering enemy + enemy_db_id = int(data[1]) + await query.answer() + + # Get the enemy from database + wandering_enemies = await database.get_wandering_enemies_in_location(player['location_id']) + enemy_data = next((e for e in wandering_enemies if e['id'] == enemy_db_id), None) + + if not enemy_data: + await query.answer("That enemy has already moved on!", show_alert=True) + # Refresh inspect menu + location_id = player['location_id'] + location = game_world.get_location(location_id) + dropped_items = await database.get_dropped_items_in_location(location_id) + wandering_enemies = await database.get_wandering_enemies_in_location(location_id) + keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies) + image_path = location.image_path if location else None + await send_or_edit_with_image(query, text="You scan the area. You notice...", reply_markup=keyboard, image_path=image_path) + return + + npc_id = enemy_data['npc_id'] + + # Remove enemy from wandering table (they're now in combat) + await database.remove_wandering_enemy(enemy_db_id) + + from data.npcs import NPCS + from bot import combat + + # Initiate combat with from_wandering_enemy=True so it respawns on flee/death + combat_data = await combat.initiate_combat(user_id, npc_id, player['location_id'], from_wandering_enemy=True) + + if combat_data: + npc_def = NPCS.get(npc_id) + message = f"โš”๏ธ You engage the {npc_def.emoji} {npc_def.name}!\n\n" + message += f"{npc_def.description}\n\n" + message += f"{npc_def.emoji} Enemy HP: {combat_data['npc_hp']}/{combat_data['npc_max_hp']}\n" + message += f"โค๏ธ Your HP: {player['hp']}/{player['max_hp']}\n\n" + message += "๐ŸŽฏ Your turn! What will you do?" + + keyboard = await keyboards.combat_keyboard(user_id) + await send_or_edit_with_image(query, text=message, reply_markup=keyboard, image_path=npc_def.image_url if npc_def else None) + else: + await query.answer("Failed to initiate combat.", show_alert=True) + + elif action_type == "inspect": + location_id, instance_id = data[1], data[2] + + location = game_world.get_location(location_id) + + if not location: + await query.answer("Location not found.", show_alert=True) + return + + interactable = location.get_interactable(instance_id) + + if not interactable: + await query.answer("Object not found.", show_alert=False) + return + + # Check if ALL actions are on cooldown + all_on_cooldown = True + for action_id in interactable.actions.keys(): + cooldown_key = f"{instance_id}:{action_id}" + if await database.get_cooldown(cooldown_key) == 0: + all_on_cooldown = False + break + + if all_on_cooldown and len(interactable.actions) > 0: + await query.answer(f"The {interactable.name} has already been searched. Try again later.", show_alert=False) + return + + # Show action menu + await query.answer() + image_path = interactable.image_path if interactable else None + await send_or_edit_with_image(query, text=f"You focus on the {interactable.name}. What do you do?", reply_markup=await keyboards.actions_keyboard(location_id, instance_id), image_path=image_path) + + elif action_type == "action": + location_id, instance_id, action_id = data[1], data[2], data[3] + cooldown_key = f"{instance_id}:{action_id}" + cooldown = await database.get_cooldown(cooldown_key) + if cooldown > 0: + await query.answer(f"Someone got to it just before you!", show_alert=False) + return + + location = game_world.get_location(location_id) + + if not location: + await query.answer("Location not found.", show_alert=True) + return + + action_obj = location.get_interactable(instance_id).get_action(action_id) + + if player['stamina'] < action_obj.stamina_cost: + await query.answer("You are too tired to do that!", show_alert=False) + return + + # Answer the callback to dismiss loading state + await query.answer() + + # FIX: Set cooldown ON ACTION, not after result. + await database.set_cooldown(cooldown_key) + + outcome = logic.resolve_action(player, action_obj) + new_stamina = player['stamina'] - action_obj.stamina_cost + new_hp = player['hp'] - outcome.damage_taken + await database.update_player(user_id, {"stamina": new_stamina, "hp": new_hp}) + + # Build detailed action result + result_details = [] + + # Add the outcome text + result_details.append(f"{outcome.text}") + + # Add stamina cost + if action_obj.stamina_cost > 0: + result_details.append(f"โšก๏ธ Stamina: -{action_obj.stamina_cost}") + + # Add HP damage if any + if outcome.damage_taken > 0: + result_details.append(f"โค๏ธ HP: -{outcome.damage_taken}") + + # Add items gained + if outcome.items_reward: + items_text = [] + items_failed = [] + for item_id, quantity in outcome.items_reward.items(): + # Check if item can be added + can_add, reason = await logic.can_add_item_to_inventory(user_id, item_id, quantity) + + if can_add: + await database.add_item_to_inventory(user_id, item_id, quantity) + item_def = ITEMS.get(item_id, {}) + emoji = item_def.get('emoji', 'โ”') + item_name = item_def.get('name', item_id) + items_text.append(f"{emoji} {item_name} x{quantity}") + else: + item_def = ITEMS.get(item_id, {}) + item_name = item_def.get('name', item_id) + items_failed.append(f"{item_name} ({reason})") + + if items_text: + result_details.append(f"๐ŸŽ Gained: {', '.join(items_text)}") + if items_failed: + result_details.append(f"โš ๏ธ Couldn't take: {', '.join(items_failed)}") + + final_text = await get_player_status_text(user_id) + final_text += f"\n\nโ”โ”โ” Action Result โ”โ”โ”\n" + "\n".join(result_details) + + # Get location image for the result screen + current_location = game_world.get_location(player['location_id']) + location_image = current_location.image_path if current_location else None + await send_or_edit_with_image(query, text=final_text, reply_markup=keyboards.main_menu_keyboard(), image_path=location_image) + + # ... (Other handlers like pickup, inventory, move are mostly unchanged) ... + elif action_type == "main_menu": + await query.answer() + status_text = await get_player_status_text(user_id) + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + await send_or_edit_with_image(query, text=status_text, reply_markup=keyboards.main_menu_keyboard(), image_path=location_image) + + elif action_type == "profile": + await query.answer() + from bot import combat + + # Calculate stats + xp_current = player['xp'] + xp_needed = combat.xp_for_level(player['level'] + 1) + xp_for_current_level = combat.xp_for_level(player['level']) + xp_progress = max(0, xp_current - xp_for_current_level) # Ensure non-negative + xp_level_requirement = xp_needed - xp_for_current_level + progress_percent = int((xp_progress / xp_level_requirement) * 100) if xp_level_requirement > 0 else 0 + + unspent = player.get('unspent_points', 0) + + profile_text = f"๐Ÿ‘ค {player['name']}\n" + profile_text += f"โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n\n" + profile_text += f"Level: {player['level']}\n" + profile_text += f"XP: {xp_current}/{xp_needed} ({progress_percent}%)\n" + + if unspent > 0: + profile_text += f"โญ Unspent Points: {unspent}\n" + + profile_text += f"\nHealth: {player['hp']}/{player['max_hp']} โค๏ธ\n" + profile_text += f"Stamina: {player['stamina']}/{player['max_stamina']} โšก\n\n" + profile_text += f"Stats:\n" + profile_text += f"๐Ÿ’ช Strength: {player['strength']}\n" + profile_text += f"๐Ÿƒ Agility: {player['agility']}\n" + profile_text += f"๐Ÿ’š Endurance: {player['endurance']}\n" + profile_text += f"๐Ÿง  Intellect: {player['intellect']}\n\n" + profile_text += f"Combat:\n" + profile_text += f"โš”๏ธ Base Damage: {5 + player['strength'] // 2 + player['level']}\n" + profile_text += f"๐Ÿ›ก๏ธ Flee Chance: {int((0.5 + player['agility'] / 100) * 100)}%\n" + profile_text += f"๐Ÿ’š Stamina Regen: {1 + player['endurance'] // 10}/cycle\n" + + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + + # Add spend points button if player has unspent points + keyboard_buttons = [] + if unspent > 0: + keyboard_buttons.append([InlineKeyboardButton("โญ Spend Stat Points", callback_data="spend_points_menu")]) + keyboard_buttons.append([InlineKeyboardButton("โฌ…๏ธ Back", callback_data="main_menu")]) + back_keyboard = InlineKeyboardMarkup(keyboard_buttons) + + await send_or_edit_with_image(query, text=profile_text, reply_markup=back_keyboard, image_path=location_image) + + elif action_type == "spend_points_menu": + await query.answer() + unspent = player.get('unspent_points', 0) + + if unspent <= 0: + await query.answer("You have no points to spend!", show_alert=False) + return + + text = f"โญ Spend Stat Points\n\n" + text += f"Available Points: {unspent}\n\n" + text += f"Current Stats:\n" + text += f"โค๏ธ Max HP: {player['max_hp']} (+10 per point)\n" + text += f"โšก Max Stamina: {player['max_stamina']} (+5 per point)\n" + text += f"๐Ÿ’ช Strength: {player['strength']} (+1 per point)\n" + text += f"๐Ÿƒ Agility: {player['agility']} (+1 per point)\n" + text += f"๐Ÿ’š Endurance: {player['endurance']} (+1 per point)\n" + text += f"๐Ÿง  Intellect: {player['intellect']} (+1 per point)\n\n" + text += f"๐Ÿ’ก Choose wisely! Each point matters." + + keyboard = keyboards.spend_points_keyboard() + await send_or_edit_with_image(query, text=text, reply_markup=keyboard) + + elif action_type == "spend_point": + stat_name = data[1] + unspent = player.get('unspent_points', 0) + + if unspent <= 0: + await query.answer("You have no points to spend!", show_alert=False) + return + + # Map stat names to updates + stat_mapping = { + 'max_hp': ('max_hp', 10, 'โค๏ธ Max HP'), + 'max_stamina': ('max_stamina', 5, 'โšก Max Stamina'), + 'strength': ('strength', 1, '๐Ÿ’ช Strength'), + 'agility': ('agility', 1, '๐Ÿƒ Agility'), + 'endurance': ('endurance', 1, '๐Ÿ’š Endurance'), + 'intellect': ('intellect', 1, '๐Ÿง  Intellect'), + } + + if stat_name not in stat_mapping: + await query.answer("Invalid stat!", show_alert=False) + return + + db_field, increase, display_name = stat_mapping[stat_name] + new_value = player[db_field] + increase + new_unspent = unspent - 1 + + await database.update_player(user_id, { + db_field: new_value, + 'unspent_points': new_unspent + }) + + # Update local player data + player[db_field] = new_value + player['unspent_points'] = new_unspent + + await query.answer(f"+{increase} {display_name}!", show_alert=False) + + # Refresh the spend points menu + text = f"โญ Spend Stat Points\n\n" + text += f"Available Points: {new_unspent}\n\n" + text += f"Current Stats:\n" + text += f"โค๏ธ Max HP: {player['max_hp']} (+10 per point)\n" + text += f"โšก Max Stamina: {player['max_stamina']} (+5 per point)\n" + text += f"๐Ÿ’ช Strength: {player['strength']} (+1 per point)\n" + text += f"๐Ÿƒ Agility: {player['agility']} (+1 per point)\n" + text += f"๐Ÿ’š Endurance: {player['endurance']} (+1 per point)\n" + text += f"๐Ÿง  Intellect: {player['intellect']} (+1 per point)\n\n" + text += f"๐Ÿ’ก Choose wisely! Each point matters." + + keyboard = keyboards.spend_points_keyboard() + await send_or_edit_with_image(query, text=text, reply_markup=keyboard) + + elif action_type == "move_menu": + await query.answer() + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + await send_or_edit_with_image(query, text="Where do you want to go?", reply_markup=await keyboards.move_keyboard(player['location_id'], user_id), image_path=location_image) + elif action_type == "move": + destination_id = data[1] + + # Get locations for distance calculation + from_location = game_world.get_location(player['location_id']) + to_location = game_world.get_location(destination_id) + + if not from_location or not to_location: + await query.answer("Invalid location!", show_alert=True) + return + + # Calculate stamina cost for travel + inventory = await database.get_inventory(user_id) + stamina_cost = logic.calculate_travel_stamina_cost(player, inventory, from_location, to_location) + + # Check if player has enough stamina + if player['stamina'] < stamina_cost: + await query.answer(f"Too tired to travel! Need {stamina_cost} stamina.", show_alert=True) + return + + # Deduct stamina and update location + new_stamina = player['stamina'] - stamina_cost + await database.update_player(user_id, {"location_id": destination_id, "stamina": new_stamina}) + + await query.answer(f"โšก๏ธ -{stamina_cost} stamina", show_alert=False) + + # Refresh player data after update + player = await database.get_player(user_id) + + # Check for random NPC encounter (dynamic chance based on destination danger) + from data.npcs import NPCS, get_random_npc_for_location, get_location_encounter_rate + encounter_rate = get_location_encounter_rate(destination_id) + + if random.random() < encounter_rate: + from bot import combat + logging.info(f"Encounter triggered at {destination_id} (rate: {encounter_rate})") + # Select random NPC appropriate for this location + npc_id = get_random_npc_for_location(destination_id) + + # If location has spawns and NPC was selected, initiate combat + if npc_id: + combat_data = await combat.initiate_combat(user_id, npc_id, destination_id) + + if combat_data: + npc_def = NPCS.get(npc_id) + message = f"โš ๏ธ A {npc_def.emoji} {npc_def.name} appears!\n\n" + message += f"{npc_def.description}\n\n" + message += f"{npc_def.emoji} Enemy HP: {combat_data['npc_hp']}/{combat_data['npc_max_hp']}\n" + message += f"โค๏ธ Your HP: {player['hp']}/{player['max_hp']}\n\n" + message += "๐ŸŽฏ Your turn! What will you do?" + + keyboard = await keyboards.combat_keyboard(user_id) + await send_or_edit_with_image(query, text=message, reply_markup=keyboard, image_path=npc_def.image_url if npc_def else None) + return + + status_text = await get_player_status_text(user_id) + new_location = game_world.get_location(destination_id) + location_image = new_location.image_path if new_location else None + await send_or_edit_with_image(query, text=status_text, reply_markup=keyboards.main_menu_keyboard(), image_path=location_image) + + elif action_type == "pickup_menu": + # Show pickup options for an item + dropped_item_id = int(data[1]) + item_to_pickup = await database.get_dropped_item(dropped_item_id) + + if not item_to_pickup: + await query.answer("Someone already picked that up!", show_alert=False) + location_id = player['location_id'] + location = game_world.get_location(location_id) + dropped_items = await database.get_dropped_items_in_location(location_id) + keyboard = await keyboards.inspect_keyboard(location_id, dropped_items) + image_path = location.image_path if location else None + await send_or_edit_with_image(query, text="You scan the area. You notice...", reply_markup=keyboard, image_path=image_path) + return + + item_def = ITEMS.get(item_to_pickup['item_id'], {}) + emoji = item_def.get('emoji', 'โ”') + text = f"{emoji} {item_def.get('name', 'Unknown')}\n\n" + text += f"Available: {item_to_pickup['quantity']}\n" + text += f"Weight: {item_def.get('weight', 0)} kg each\n" + text += f"Volume: {item_def.get('volume', 0)} vol each\n\n" + text += "How many do you want to pick up?" + + await query.answer() + keyboard = keyboards.pickup_options_keyboard(dropped_item_id, item_def.get('name', 'Unknown'), item_to_pickup['quantity']) + # Keep location image for visual continuity + location = game_world.get_location(player['location_id']) + image_path = location.image_path if location else None + await send_or_edit_with_image(query, text=text, reply_markup=keyboard, image_path=image_path) + + elif action_type == "pickup": + dropped_item_id = int(data[1]) + pickup_amount_str = data[2] if len(data) > 2 else "all" + + item_to_pickup = await database.get_dropped_item(dropped_item_id) + if not item_to_pickup: + await query.answer("Someone already picked that up!", show_alert=False) + location_id = player['location_id'] + location = game_world.get_location(location_id) + dropped_items = await database.get_dropped_items_in_location(location_id) + keyboard = await keyboards.inspect_keyboard(location_id, dropped_items) + image_path = location.image_path if location else None + await send_or_edit_with_image(query, text="You scan the area. You notice...", reply_markup=keyboard, image_path=image_path) + return + + # Determine how much to pick up + if pickup_amount_str == "all": + pickup_amount = item_to_pickup['quantity'] + else: + pickup_amount = min(int(pickup_amount_str), item_to_pickup['quantity']) + + # Check inventory capacity + can_add, reason = await logic.can_add_item_to_inventory(user_id, item_to_pickup['item_id'], pickup_amount) + + if not can_add: + await query.answer(reason, show_alert=True) + return + + # Add to inventory + await database.add_item_to_inventory(user_id, item_to_pickup['item_id'], pickup_amount) + + # Update or remove dropped item + remaining = item_to_pickup['quantity'] - pickup_amount + if remaining > 0: + # Update quantity + await database.update_dropped_item(dropped_item_id, remaining) + item_def = ITEMS.get(item_to_pickup['item_id'], {}) + await query.answer(f"Picked up {pickup_amount}x {item_def.get('name', 'item')}. {remaining} remaining.", show_alert=False) + else: + # Remove item completely + await database.remove_dropped_item(dropped_item_id) + item_def = ITEMS.get(item_to_pickup['item_id'], {}) + await query.answer(f"Picked up {pickup_amount}x {item_def.get('name', 'item')}.", show_alert=False) + + # Return to inspect area + location_id = player['location_id'] + location = game_world.get_location(location_id) + dropped_items = await database.get_dropped_items_in_location(location_id) + keyboard = await keyboards.inspect_keyboard(location_id, dropped_items) + image_path = location.image_path if location else None + await send_or_edit_with_image(query, text="You scan the area. You notice...", reply_markup=keyboard, image_path=image_path) + + elif action_type == "inventory_menu": + await query.answer() + inventory_items = await database.get_inventory(user_id) + + # Calculate inventory summary + current_weight, current_volume = logic.calculate_inventory_load(inventory_items) + max_weight, max_volume = logic.get_player_capacity(inventory_items, player) + + text = "๐ŸŽ’ Your Inventory:\n" + text += f"๐Ÿ“Š Weight: {current_weight}/{max_weight} kg\n" + text += f"๐Ÿ“ฆ Volume: {current_volume}/{max_volume} vol\n\n" + + if not inventory_items: + text += "It's empty." + + # Keep current location image for context + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + await send_or_edit_with_image(query, text=text, reply_markup=keyboards.inventory_keyboard(inventory_items), image_path=location_image) + elif action_type == "inventory_item": + await query.answer() + item_db_id = int(data[1]) + item = await database.get_inventory_item(item_db_id) + item_def = ITEMS.get(item['item_id'], {}) + emoji = item_def.get('emoji', 'โ”') + + # Build item details text + text = f"{emoji} {item_def.get('name', 'Unknown')}\n" + + # Add description if available + description = item_def.get('description') + if description: + text += f"{description}\n\n" + else: + text += "\n" + + text += f"Weight: {item_def.get('weight', 0)} kg | Volume: {item_def.get('volume', 0)} vol\n" + + # Add weapon stats if applicable + if item_def.get('type') == 'weapon': + text += f"Damage: {item_def.get('damage_min', 0)}-{item_def.get('damage_max', 0)}\n" + + # Add consumable effects if applicable + if item_def.get('type') == 'consumable': + effects = [] + if item_def.get('hp_restore'): + effects.append(f"โค๏ธ +{item_def.get('hp_restore')} HP") + if item_def.get('stamina_restore'): + effects.append(f"โšก +{item_def.get('stamina_restore')} Stamina") + if effects: + text += f"Effects: {', '.join(effects)}\n" + + # Add equipped status + if item.get('is_equipped'): + text += "\nโœ… Currently Equipped" + + # Keep current location image for context + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + await send_or_edit_with_image(query, text=text, reply_markup=keyboards.inventory_item_actions_keyboard(item_db_id, item_def, item.get('is_equipped', False), item['quantity']), image_path=location_image) + elif action_type == "inventory_use": + item_db_id = int(data[1]) + item = await database.get_inventory_item(item_db_id) + if not item: + await query.answer("Item not found.", show_alert=False) + return + + item_def = ITEMS.get(item['item_id'], {}) + + # Check if item is consumable + if item_def.get('type') != 'consumable': + await query.answer("This item cannot be used.", show_alert=False) + return + + # Answer callback before processing + await query.answer() + + # Apply item effects + result_parts = [] + updates = {} + + # Check for hp_restore + if 'hp_restore' in item_def: + hp_gain = item_def['hp_restore'] + new_hp = min(player['max_hp'], player['hp'] + hp_gain) + actual_gain = new_hp - player['hp'] + updates['hp'] = new_hp + if actual_gain > 0: + result_parts.append(f"โค๏ธ HP: +{actual_gain}") + else: + result_parts.append(f"โค๏ธ HP: Already at maximum!") + + # Check for stamina_restore + if 'stamina_restore' in item_def: + stamina_gain = item_def['stamina_restore'] + new_stamina = min(player['max_stamina'], player['stamina'] + stamina_gain) + actual_gain = new_stamina - player['stamina'] + updates['stamina'] = new_stamina + if actual_gain > 0: + result_parts.append(f"โšก๏ธ Stamina: +{actual_gain}") + else: + result_parts.append(f"โšก๏ธ Stamina: Already at maximum!") + + # Apply all updates at once + if updates: + await database.update_player(user_id, updates) + + # Remove one item from inventory + if item['quantity'] > 1: + await database.update_inventory_item(item['id'], quantity=item['quantity'] - 1) + else: + await database.remove_item_from_inventory(item['id']) + + # Build result message + emoji = item_def.get('emoji', 'โ”') + result_text = f"Used {emoji} {item_def.get('name')}\n\n" + if result_parts: + result_text += "\n".join(result_parts) + else: + result_text += "No effect." + + # Show updated inventory + inventory_items = await database.get_inventory(user_id) + + # Calculate inventory summary + current_weight, current_volume = logic.calculate_inventory_load(inventory_items) + max_weight, max_volume = logic.get_player_capacity(inventory_items, player) + + text = "๐ŸŽ’ Your Inventory:\n" + text += f"๐Ÿ“Š Weight: {current_weight}/{max_weight} kg\n" + text += f"๐Ÿ“ฆ Volume: {current_volume}/{max_volume} vol\n\n" + + if not inventory_items: + text += "It's empty." + else: + text += f"{result_text}" + + # Keep current location image for context + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + await send_or_edit_with_image(query, text=text, reply_markup=keyboards.inventory_keyboard(inventory_items), image_path=location_image) + + elif action_type == "inventory_drop": + item_db_id = int(data[1]) + drop_amount_str = data[2] if len(data) > 2 else None + + item = await database.get_inventory_item(item_db_id) + if not item: + await query.answer("Item not found.", show_alert=False) + return + + item_def = ITEMS.get(item['item_id'], {}) + + # Determine how much to drop + if drop_amount_str is None or drop_amount_str == "all": + # Drop all - pass the full quantity to remove_item_from_inventory + await database.drop_item_to_world(item['item_id'], item['quantity'], player['location_id']) + await database.remove_item_from_inventory(item['id'], quantity=item['quantity']) + await query.answer(f"You dropped all {item['quantity']}x {item_def.get('name')}.", show_alert=False) + else: + # Drop partial amount + drop_amount = int(drop_amount_str) + if drop_amount >= item['quantity']: + # Drop all + await database.drop_item_to_world(item['item_id'], item['quantity'], player['location_id']) + await database.remove_item_from_inventory(item['id'], quantity=item['quantity']) + await query.answer(f"You dropped all {item['quantity']}x {item_def.get('name')}.", show_alert=False) + else: + # Drop partial amount + await database.drop_item_to_world(item['item_id'], drop_amount, player['location_id']) + await database.update_inventory_item(item['id'], quantity=item['quantity'] - drop_amount) + await query.answer(f"You dropped {drop_amount}x {item_def.get('name')}.", show_alert=False) + + inventory_items = await database.get_inventory(user_id) + + # Calculate inventory summary + current_weight, current_volume = logic.calculate_inventory_load(inventory_items) + max_weight, max_volume = logic.get_player_capacity(inventory_items, player) + + text = "๐ŸŽ’ Your Inventory:\n" + text += f"๐Ÿ“Š Weight: {current_weight}/{max_weight} kg\n" + text += f"๐Ÿ“ฆ Volume: {current_volume}/{max_volume} vol\n\n" + + if not inventory_items: + text += "It's empty." + + # Keep current location image for context + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + await send_or_edit_with_image(query, text=text, reply_markup=keyboards.inventory_keyboard(inventory_items), image_path=location_image) + + elif action_type == "inventory_equip": + item_db_id = int(data[1]) + item = await database.get_inventory_item(item_db_id) + + if not item: + await query.answer("Item not found.", show_alert=False) + return + + item_def = ITEMS.get(item['item_id'], {}) + item_slot = item_def.get('slot') + + if not item_slot: + await query.answer("This item cannot be equipped.", show_alert=False) + return + + # Unequip any item in the same slot + inventory_items = await database.get_inventory(user_id) + for inv_item in inventory_items: + if inv_item.get('is_equipped'): + inv_item_def = ITEMS.get(inv_item['item_id'], {}) + if inv_item_def.get('slot') == item_slot: + await database.update_inventory_item(inv_item['id'], is_equipped=False) + + # If equipping from a stack (quantity > 1), split the stack + if item['quantity'] > 1: + # Reduce the stack by 1 + await database.update_inventory_item(item_db_id, quantity=item['quantity'] - 1) + # Create a new inventory entry with quantity 1 and equipped=True + new_item_id = await database.add_equipped_item_to_inventory(user_id, item['item_id']) + + await query.answer(f"Equipped {item_def.get('name')}!", show_alert=False) + + # Refresh the item view with the NEW equipped item + item = await database.get_inventory_item(new_item_id) + emoji = item_def.get('emoji', 'โ”') + text = f"Item: {emoji} {item_def.get('name', 'Unknown')}\n" + text += f"Weight: {item_def.get('weight', 0)} kg | Volume: {item_def.get('volume', 0)} vol\n" + + if item_def.get('type') == 'weapon': + text += f"Damage: {item_def.get('damage_min', 0)}-{item_def.get('damage_max', 0)}\n" + + text += "\nโœ… Currently Equipped" + + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + await send_or_edit_with_image(query, text=text, reply_markup=keyboards.inventory_item_actions_keyboard(new_item_id, item_def, True, item['quantity']), image_path=location_image) + else: + # Equip the single item + await database.update_inventory_item(item_db_id, is_equipped=True) + await query.answer(f"Equipped {item_def.get('name')}!", show_alert=False) + + # Refresh the item view + item = await database.get_inventory_item(item_db_id) + emoji = item_def.get('emoji', 'โ”') + text = f"Item: {emoji} {item_def.get('name', 'Unknown')}\n" + text += f"Weight: {item_def.get('weight', 0)} kg | Volume: {item_def.get('volume', 0)} vol\n" + + if item_def.get('type') == 'weapon': + text += f"Damage: {item_def.get('damage_min', 0)}-{item_def.get('damage_max', 0)}\n" + + text += "\nโœ… Currently Equipped" + + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + await send_or_edit_with_image(query, text=text, reply_markup=keyboards.inventory_item_actions_keyboard(item_db_id, item_def, True, item['quantity']), image_path=location_image) + + elif action_type == "inventory_unequip": + item_db_id = int(data[1]) + item = await database.get_inventory_item(item_db_id) + + if not item: + await query.answer("Item not found.", show_alert=False) + return + + item_def = ITEMS.get(item['item_id'], {}) + + # Check if there's an existing unequipped stack of the same item + inventory_items = await database.get_inventory(user_id) + existing_stack = None + for inv_item in inventory_items: + if inv_item['item_id'] == item['item_id'] and not inv_item.get('is_equipped') and inv_item['id'] != item_db_id: + existing_stack = inv_item + break + + if existing_stack: + # Merge into existing stack + await database.update_inventory_item(existing_stack['id'], quantity=existing_stack['quantity'] + 1) + # Remove the equipped item + await database.remove_item_from_inventory(item_db_id) + await query.answer(f"Unequipped {item_def.get('name')}.", show_alert=False) + + # Show the merged stack + item = await database.get_inventory_item(existing_stack['id']) + emoji = item_def.get('emoji', 'โ”') + text = f"Item: {emoji} {item_def.get('name', 'Unknown')}\n" + text += f"Weight: {item_def.get('weight', 0)} kg | Volume: {item_def.get('volume', 0)} vol\n" + + if item_def.get('type') == 'weapon': + text += f"Damage: {item_def.get('damage_min', 0)}-{item_def.get('damage_max', 0)}\n" + + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + await send_or_edit_with_image(query, text=text, reply_markup=keyboards.inventory_item_actions_keyboard(existing_stack['id'], item_def, False, item['quantity']), image_path=location_image) + else: + # Just unequip the item + await database.update_inventory_item(item_db_id, is_equipped=False) + await query.answer(f"Unequipped {item_def.get('name')}.", show_alert=False) + + # Refresh the item view + item = await database.get_inventory_item(item_db_id) + emoji = item_def.get('emoji', 'โ”') + text = f"Item: {emoji} {item_def.get('name', 'Unknown')}\n" + text += f"Weight: {item_def.get('weight', 0)} kg | Volume: {item_def.get('volume', 0)} vol\n" + + if item_def.get('type') == 'weapon': + text += f"Damage: {item_def.get('damage_min', 0)}-{item_def.get('damage_max', 0)}\n" + + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + await send_or_edit_with_image(query, text=text, reply_markup=keyboards.inventory_item_actions_keyboard(item_db_id, item_def, False, item['quantity']), image_path=location_image) + + # --- Combat Actions --- + elif action_type == "combat_attack": + from bot import combat + await query.answer() + + message, npc_died, turn_ended = await combat.player_attack(user_id) + + if npc_died: + # Combat ended - return to main menu + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + await send_or_edit_with_image(query, text=message, reply_markup=keyboards.main_menu_keyboard(), image_path=location_image) + elif turn_ended: + # NPC's turn - auto-attack + npc_message, player_died = await combat.npc_attack(user_id) + message += "\n\n" + npc_message + + if player_died: + # Player died - show death message + await send_or_edit_with_image(query, text=message, reply_markup=None) + else: + # Show combat state + combat_data = await database.get_combat(user_id) + if combat_data: + from data.npcs import NPCS + npc_def = NPCS.get(combat_data['npc_id']) + keyboard = await keyboards.combat_keyboard(user_id) + await send_or_edit_with_image(query, text=message, reply_markup=keyboard, image_path=npc_def.image_url if npc_def else None) + else: + await query.answer(message, show_alert=False) + + elif action_type == "combat_flee": + from bot import combat + await query.answer() + + message, fled, turn_ended = await combat.flee_attempt(user_id) + + if fled: + # Successfully fled - return to main menu + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + await send_or_edit_with_image(query, text=message, reply_markup=keyboards.main_menu_keyboard(), image_path=location_image) + elif turn_ended: + # Failed to flee - NPC attacks + npc_message, player_died = await combat.npc_attack(user_id) + message += "\n\n" + npc_message + + if player_died: + await send_or_edit_with_image(query, text=message, reply_markup=None) + else: + combat_data = await database.get_combat(user_id) + if combat_data: + from data.npcs import NPCS + npc_def = NPCS.get(combat_data['npc_id']) + keyboard = await keyboards.combat_keyboard(user_id) + await send_or_edit_with_image(query, text=message, reply_markup=keyboard, image_path=npc_def.image_url if npc_def else None) + else: + await query.answer(message, show_alert=False) + + elif action_type == "combat_use_item_menu": + await query.answer() + keyboard = await keyboards.combat_items_keyboard(user_id) + await send_or_edit_with_image(query, text="๐Ÿ’Š Select an item to use:", reply_markup=keyboard) + + elif action_type == "combat_use_item": + from bot import combat + item_db_id = int(data[1]) + + message, turn_ended = await combat.use_item_in_combat(user_id, item_db_id) + await query.answer(message, show_alert=False) + + if turn_ended: + # NPC's turn + npc_message, player_died = await combat.npc_attack(user_id) + + if player_died: + await send_or_edit_with_image(query, text=message + "\n\n" + npc_message, reply_markup=None) + else: + combat_data = await database.get_combat(user_id) + if combat_data: + from data.npcs import NPCS + npc_def = NPCS.get(combat_data['npc_id']) + keyboard = await keyboards.combat_keyboard(user_id) + full_message = message + "\n\n" + npc_message + "\n\n๐ŸŽฏ Your turn!" + await send_or_edit_with_image(query, text=full_message, reply_markup=keyboard, image_path=npc_def.image_url if npc_def else None) + + elif action_type == "combat_back": + await query.answer() + combat_data = await database.get_combat(user_id) + if combat_data: + from data.npcs import NPCS + npc_def = NPCS.get(combat_data['npc_id']) + keyboard = await keyboards.combat_keyboard(user_id) + message = f"โš”๏ธ Combat with {npc_def.emoji} {npc_def.name}!\n" + message += f"{npc_def.emoji} Enemy HP: {combat_data['npc_hp']}/{combat_data['npc_max_hp']}\n" + message += f"โค๏ธ Your HP: {player['hp']}/{player['max_hp']}\n\n" + message += "๐ŸŽฏ Your turn!" if combat_data['turn'] == 'player' else "โณ Enemy's turn..." + await send_or_edit_with_image(query, text=message, reply_markup=keyboard, image_path=npc_def.image_url if npc_def else None) + + # --- Corpse Looting --- + elif action_type == "loot_player_corpse": + corpse_id = int(data[1]) + corpse = await database.get_player_corpse(corpse_id) + + if not corpse: + await query.answer("Corpse not found.", show_alert=False) + return + + items = json.loads(corpse['items']) + keyboard = keyboards.player_corpse_loot_keyboard(corpse_id, items) + + # Get location image + location = game_world.get_location(player['location_id']) + image_path = location.image_path if location else None + + await query.answer() + text = f"๐ŸŽ’ {corpse['player_name']}'s bag\n\nYou find the remains of another survivor..." + await send_or_edit_with_image(query, text=text, reply_markup=keyboard, image_path=image_path) + + elif action_type == "take_corpse_item": + corpse_id = int(data[1]) + item_index = int(data[2]) + + corpse = await database.get_player_corpse(corpse_id) + if not corpse: + await query.answer("Corpse not found.", show_alert=False) + return + + items = json.loads(corpse['items']) + if item_index >= len(items): + await query.answer("Item not found.", show_alert=False) + return + + item_data = items[item_index] + item_def = ITEMS.get(item_data['item_id'], {}) + + # Check inventory capacity + can_add, reason = await logic.can_add_item_to_inventory(user_id, item_data['item_id'], item_data['quantity']) + + if not can_add: + await query.answer(reason, show_alert=False) + return + + # Add to inventory + await database.add_item_to_inventory(user_id, item_data['item_id'], item_data['quantity']) + + # Remove from corpse + items.pop(item_index) + + if items: + await database.update_player_corpse(corpse_id, json.dumps(items)) + keyboard = keyboards.player_corpse_loot_keyboard(corpse_id, items) + + # Get location image + location = game_world.get_location(player['location_id']) + image_path = location.image_path if location else None + + await query.answer(f"Took {item_def.get('name', 'Unknown')}.", show_alert=False) + text = f"๐ŸŽ’ {corpse['player_name']}'s bag" + await send_or_edit_with_image(query, text=text, reply_markup=keyboard, image_path=image_path) + else: + # Bag is empty, remove it + await database.remove_player_corpse(corpse_id) + await query.answer(f"Took {item_def.get('name', 'Unknown')}. The bag is now empty.", show_alert=False) + location = game_world.get_location(player['location_id']) + dropped_items = await database.get_dropped_items_in_location(player['location_id']) + keyboard = await keyboards.inspect_keyboard(player['location_id'], dropped_items) + await send_or_edit_with_image(query, text="You scan the area. You notice...", reply_markup=keyboard, image_path=location.image_path if location else None) + + elif action_type == "scavenge_npc_corpse": + corpse_id = int(data[1]) + corpse = await database.get_npc_corpse(corpse_id) + + if not corpse: + await query.answer("Corpse not found.", show_alert=False) + return + + from data.npcs import NPCS + npc_def = NPCS.get(corpse['npc_id']) + loot_items = json.loads(corpse['loot_remaining']) + keyboard = keyboards.npc_corpse_scavenge_keyboard(corpse_id, loot_items) + + # Get location image + location = game_world.get_location(player['location_id']) + image_path = location.image_path if location else None + + await query.answer() + text = f"๐Ÿ”ช {npc_def.emoji} {npc_def.name} Corpse\n\n{npc_def.description}" + await send_or_edit_with_image(query, text=text, reply_markup=keyboard, image_path=image_path) + + elif action_type == "scavenge_corpse_item": + corpse_id = int(data[1]) + loot_index = int(data[2]) + + corpse = await database.get_npc_corpse(corpse_id) + if not corpse: + await query.answer("Corpse not found.", show_alert=False) + return + + loot_items = json.loads(corpse['loot_remaining']) + if loot_index >= len(loot_items): + await query.answer("Nothing to scavenge here.", show_alert=False) + return + + loot_data = loot_items[loot_index] + required_tool = loot_data.get('required_tool') + + # Check if player has required tool + if required_tool: + inventory_items = await database.get_inventory(user_id) + has_tool = any(item['item_id'] == required_tool for item in inventory_items) + + if not has_tool: + tool_def = ITEMS.get(required_tool, {}) + await query.answer(f"You need a {tool_def.get('name', 'tool')} to scavenge this.", show_alert=False) + return + + # Determine quantity + quantity = random.randint(loot_data['quantity_min'], loot_data['quantity_max']) + item_def = ITEMS.get(loot_data['item_id'], {}) + + # Check inventory capacity + can_add, reason = await logic.can_add_item_to_inventory(user_id, loot_data['item_id'], quantity) + + if not can_add: + await query.answer(reason, show_alert=False) + return + + # Add to inventory + await database.add_item_to_inventory(user_id, loot_data['item_id'], quantity) + + # Remove from corpse + loot_items.pop(loot_index) + + if loot_items: + await database.update_npc_corpse(corpse_id, json.dumps(loot_items)) + keyboard = keyboards.npc_corpse_scavenge_keyboard(corpse_id, loot_items) + + # Get location image + location = game_world.get_location(player['location_id']) + image_path = location.image_path if location else None + + await query.answer(f"Scavenged {quantity}x {item_def.get('name', 'Unknown')}.", show_alert=False) + from data.npcs import NPCS + npc_def = NPCS.get(corpse['npc_id']) + text = f"๐Ÿ”ช {npc_def.emoji} {npc_def.name} Corpse" + await send_or_edit_with_image(query, text=text, reply_markup=keyboard, image_path=image_path) + else: + # Nothing left, remove corpse + await database.remove_npc_corpse(corpse_id) + await query.answer(f"Scavenged {quantity}x {item_def.get('name', 'Unknown')}. Nothing left on the corpse.", show_alert=False) + location = game_world.get_location(player['location_id']) + dropped_items = await database.get_dropped_items_in_location(player['location_id']) + keyboard = await keyboards.inspect_keyboard(player['location_id'], dropped_items) + await send_or_edit_with_image(query, text="You scan the area. You notice...", reply_markup=keyboard, image_path=location.image_path if location else None) + + elif action_type == "no_op": + await query.answer() + return + elif action_type == "inspect_area_menu": + await query.answer() + location_id = data[1] + location = game_world.get_location(location_id) + dropped_items = await database.get_dropped_items_in_location(location_id) + keyboard = await keyboards.inspect_keyboard(location_id, dropped_items) + image_path = location.image_path if location else None + await send_or_edit_with_image(query, text="You scan the area. You notice...", reply_markup=keyboard, image_path=image_path) diff --git a/bot/keyboards.py b/bot/keyboards.py new file mode 100644 index 0000000..52b98dc --- /dev/null +++ b/bot/keyboards.py @@ -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) + diff --git a/bot/logic.py b/bot/logic.py new file mode 100644 index 0000000..ff893be --- /dev/null +++ b/bot/logic.py @@ -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 diff --git a/bot/spawn_manager.py b/bot/spawn_manager.py new file mode 100644 index 0000000..ed2cd05 --- /dev/null +++ b/bot/spawn_manager.py @@ -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 + } diff --git a/bot/utils.py b/bot/utils.py new file mode 100644 index 0000000..295d476 --- /dev/null +++ b/bot/utils.py @@ -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( + "๐Ÿšซ Access Denied\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 diff --git a/data/__init__.py b/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/data/items.py b/data/items.py new file mode 100644 index 0000000..f8e8111 --- /dev/null +++ b/data/items.py @@ -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() diff --git a/data/models.py b/data/models.py new file mode 100644 index 0000000..e915a94 --- /dev/null +++ b/data/models.py @@ -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) diff --git a/data/npcs.py b/data/npcs.py new file mode 100644 index 0000000..18aa7e6 --- /dev/null +++ b/data/npcs.py @@ -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 diff --git a/data/travel_helpers.py b/data/travel_helpers.py new file mode 100644 index 0000000..eb83a67 --- /dev/null +++ b/data/travel_helpers.py @@ -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 diff --git a/data/world_loader.py b/data/world_loader.py new file mode 100644 index 0000000..f19c6d3 --- /dev/null +++ b/data/world_loader.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8265c08 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/gamedata/interactables.json b/gamedata/interactables.json new file mode 100644 index 0000000..ee01954 --- /dev/null +++ b/gamedata/interactables.json @@ -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 + } + } + } + } +} \ No newline at end of file diff --git a/gamedata/items.json b/gamedata/items.json new file mode 100644 index 0000000..81a6988 --- /dev/null +++ b/gamedata/items.json @@ -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" + } + } +} \ No newline at end of file diff --git a/gamedata/locations.json b/gamedata/locations.json new file mode 100644 index 0000000..02c04ed --- /dev/null +++ b/gamedata/locations.json @@ -0,0 +1,1438 @@ +{ + "locations": [ + { + "id": "start_point", + "name": "\ud83c\udf06 Ruined Downtown Core", + "description": "The wind howls through skeletal skyscrapers. Debris litters the cracked asphalt. You sense danger, but also opportunity.", + "image_path": "images/locations/downtown.png", + "x": 0, + "y": 0, + "interactables": { + "rubble_1760793958629": { + "template_id": "rubble", + "outcomes": { + "search": { + "success_rate": 0.5, + "stamina_cost": 2, + "text": { + "success": "You successfully \ud83d\udd0e search rubble.", + "failure": "You failed to \ud83d\udd0e search rubble." + }, + "rewards": { + "items": [ + { + "item_id": "scrap_metal", + "quantity": 1, + "chance": 1 + } + ], + "damage": 0 + } + } + } + }, + "start_point_sedan": { + "template_id": "sedan", + "outcomes": { + "search_glovebox": { + "success_rate": 0.5, + "stamina_cost": 1, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You find a half-eaten [Stale Chocolate Bar].", + "failure": "The glovebox is empty except for dust and old receipts.", + "crit_success": "", + "crit_failure": "" + }, + "rewards": { + "items": [ + { + "item_id": "stale_chocolate_bar", + "quantity": 1, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 0 + } + }, + "pop_trunk": { + "success_rate": 0.5, + "stamina_cost": 3, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "With a great heave, you pry the trunk open and find a [Tire Iron]!", + "failure": "The trunk is rusted shut. You can't get it open.", + "crit_success": "", + "crit_failure": "" + }, + "rewards": { + "items": [ + { + "item_id": "tire_iron", + "quantity": 1, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 0 + } + } + } + }, + "start_point_dumpster": { + "template_id": "dumpster", + "outcomes": { + "search_dumpster": { + "success_rate": 0.5, + "stamina_cost": 2, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "Despite the smell, you find some [Plastic Bottles] and [Cloth Scraps].", + "failure": "Just rotting garbage. Nothing useful.", + "crit_success": "", + "crit_failure": "You disturb a nest of rats! They bite you! (-8 HP)" + }, + "rewards": { + "items": [ + { + "item_id": "plastic_bottles", + "quantity": 3, + "chance": 1.0 + }, + { + "item_id": "cloth_scraps", + "quantity": 2, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 8 + } + } + } + } + } + }, + { + "id": "gas_station", + "name": "\u26fd\ufe0f Abandoned Gas Station", + "description": "The smell of stale gasoline hangs in the air. A rusty sedan sits by the pumps, its door ajar. Behind the station, you spot a small tool shed.", + "image_path": "images/locations/gas_station.png", + "x": 0, + "y": 2, + "interactables": {} + }, + { + "id": "residential", + "name": "\ud83c\udfd8\ufe0f Residential Street", + "description": "A quiet suburban street lined with abandoned homes. Most are boarded up, but a few doors hang open, creaking in the wind.", + "x": 3.0, + "y": 0.0, + "image_path": "images/locations/residential.png", + "interactables": { + "residential_house1": { + "template_id": "house", + "outcomes": { + "search_house": { + "success_rate": 0.5, + "stamina_cost": 3, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You find some useful supplies: [Canned Beans], [Bottled Water], and [Cloth Scraps]!", + "failure": "The house has already been thoroughly looted. Nothing remains.", + "crit_success": "", + "crit_failure": "The floor collapses beneath you! (-10 HP)" + }, + "rewards": { + "items": [ + { + "item_id": "canned_beans", + "quantity": 1, + "chance": 1.0 + }, + { + "item_id": "bottled_water", + "quantity": 1, + "chance": 1.0 + }, + { + "item_id": "cloth_scraps", + "quantity": 3, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 10 + } + } + } + }, + "residential_rubble": { + "template_id": "rubble", + "outcomes": { + "search": { + "success_rate": 0.5, + "stamina_cost": 2, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You dig through the debris and find some [Scrap Metal].", + "failure": "The pile seems to have been picked clean already.", + "crit_success": "", + "crit_failure": "You cut your hand on a sharp piece of glass! (-5 HP)" + }, + "rewards": { + "items": [ + { + "item_id": "scrap_metal", + "quantity": 2, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 5 + } + } + } + } + } + }, + { + "id": "clinic", + "name": "\ud83c\udfe5 Old Clinic", + "description": "A small medical clinic, its windows shattered. The waiting room is a mess of overturned chairs and scattered papers. The examination rooms might still have supplies.", + "x": 2.0, + "y": 3.0, + "image_path": "images/locations/clinic.png", + "interactables": { + "clinic_medkit": { + "template_id": "medkit", + "outcomes": { + "search_medkit": { + "success_rate": 0.5, + "stamina_cost": 2, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "Jackpot! You find a [First Aid Kit] and some [Bandages]!", + "failure": "The cabinet is empty. Someone got here first.", + "crit_success": "", + "crit_failure": "" + }, + "rewards": { + "items": [ + { + "item_id": "first_aid_kit", + "quantity": 1, + "chance": 1.0 + }, + { + "item_id": "bandage", + "quantity": 2, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 0 + } + } + } + }, + "clinic_rubble": { + "template_id": "rubble", + "outcomes": { + "search": { + "success_rate": 0.5, + "stamina_cost": 2, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You dig through the debris and find some [Scrap Metal].", + "failure": "The pile seems to have been picked clean already.", + "crit_success": "", + "crit_failure": "You cut your hand on a sharp piece of glass! (-5 HP)" + }, + "rewards": { + "items": [ + { + "item_id": "scrap_metal", + "quantity": 2, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 5 + } + } + } + } + } + }, + { + "id": "plaza", + "name": "\ud83c\udfec Shopping Plaza", + "description": "A strip mall with broken storefronts. Most shops have been thoroughly ransacked, but you might find something if you search carefully.", + "image_path": "images/locations/plaza.png", + "x": -2.5, + "y": 0, + "interactables": { + "rubble_1760803638919": { + "outcomes": { + "search": { + "crit_failure_chance": 0.1, + "crit_success_chance": 0.1, + "rewards": { + "crit_damage": 0, + "crit_items": [], + "damage": 0, + "items": [] + }, + "stamina_cost": 2, + "success_rate": 0.5, + "text": { + "crit_failure": "", + "crit_success": "", + "failure": "You failed to \ud83d\udd0e search rubble.", + "success": "You successfully \ud83d\udd0e search rubble." + } + } + }, + "template_id": "rubble" + }, + "plaza_vending_machine_1760805873300": { + "template_id": "vending_machine", + "outcomes": { + "break": { + "success_rate": 0.5, + "stamina_cost": 5, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You successfully \ud83d\udd28 break open.", + "failure": "You failed to \ud83d\udd28 break open.", + "crit_success": "", + "crit_failure": "" + }, + "rewards": { + "items": [], + "damage": 0, + "crit_items": [], + "crit_damage": 0 + } + }, + "search": { + "success_rate": 0.5, + "stamina_cost": 2, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You successfully \ud83d\udd0e search machine.", + "failure": "You failed to \ud83d\udd0e search machine.", + "crit_success": "", + "crit_failure": "" + }, + "rewards": { + "items": [ + { + "item_id": "energy_bar", + "quantity": 4, + "chance": 1 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 0 + } + } + } + } + } + }, + { + "id": "park", + "name": "\ud83c\udf33 Suburban Park", + "description": "An overgrown park with rusted playground equipment. Nature is slowly reclaiming this space. A maintenance shed sits at the far end.", + "x": -1.0, + "y": -2.0, + "image_path": "images/locations/park.png", + "interactables": { + "park_shed": { + "template_id": "toolshed", + "outcomes": { + "search_shed": { + "success_rate": 0.5, + "stamina_cost": 2, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You find some tools: [Rusty Nails], [Wood Planks], and a [Flashlight]!", + "failure": "The shed has been picked clean. Only empty shelves remain.", + "crit_success": "", + "crit_failure": "" + }, + "rewards": { + "items": [ + { + "item_id": "rusty_nails", + "quantity": 5, + "chance": 1.0 + }, + { + "item_id": "wood_planks", + "quantity": 2, + "chance": 1.0 + }, + { + "item_id": "flashlight", + "quantity": 1, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 0 + } + } + } + }, + "park_rubble": { + "template_id": "rubble", + "outcomes": { + "search": { + "success_rate": 0.5, + "stamina_cost": 2, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You dig through the debris and find some [Scrap Metal].", + "failure": "The pile seems to have been picked clean already.", + "crit_success": "", + "crit_failure": "You cut your hand on a sharp piece of glass! (-5 HP)" + }, + "rewards": { + "items": [ + { + "item_id": "scrap_metal", + "quantity": 2, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 5 + } + } + } + } + } + }, + { + "id": "overpass", + "name": "\ud83d\udee3\ufe0f Highway Overpass", + "description": "A concrete overpass spanning the cracked highway below. Abandoned vehicles litter the road. This is a good vantage point to survey the area.", + "x": 1.0, + "y": 4.5, + "image_path": "images/locations/overpass.png", + "interactables": { + "overpass_sedan1": { + "template_id": "sedan", + "outcomes": { + "search_glovebox": { + "success_rate": 0.5, + "stamina_cost": 1, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You find a half-eaten [Stale Chocolate Bar].", + "failure": "The glovebox is empty except for dust and old receipts.", + "crit_success": "", + "crit_failure": "" + }, + "rewards": { + "items": [ + { + "item_id": "stale_chocolate_bar", + "quantity": 1, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 0 + } + }, + "pop_trunk": { + "success_rate": 0.5, + "stamina_cost": 3, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "With a great heave, you pry the trunk open and find a [Tire Iron]!", + "failure": "The trunk is rusted shut. You can't get it open.", + "crit_success": "", + "crit_failure": "" + }, + "rewards": { + "items": [ + { + "item_id": "tire_iron", + "quantity": 1, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 0 + } + } + } + }, + "overpass_sedan2": { + "template_id": "sedan", + "outcomes": { + "search_glovebox": { + "success_rate": 0.5, + "stamina_cost": 1, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You find a half-eaten [Stale Chocolate Bar].", + "failure": "The glovebox is empty except for dust and old receipts.", + "crit_success": "", + "crit_failure": "" + }, + "rewards": { + "items": [ + { + "item_id": "stale_chocolate_bar", + "quantity": 1, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 0 + } + }, + "pop_trunk": { + "success_rate": 0.5, + "stamina_cost": 3, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "With a great heave, you pry the trunk open and find a [Tire Iron]!", + "failure": "The trunk is rusted shut. You can't get it open.", + "crit_success": "", + "crit_failure": "" + }, + "rewards": { + "items": [ + { + "item_id": "tire_iron", + "quantity": 1, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 0 + } + } + } + } + } + }, + { + "id": "warehouse", + "name": "\ud83c\udfed Warehouse District", + "description": "Rows of industrial warehouses stretch before you. Metal doors creak in the wind. The loading docks are littered with debris and abandoned cargo.", + "x": 4.0, + "y": -1.5, + "image_path": "images/locations/warehouse.png", + "interactables": { + "warehouse_rubble": { + "template_id": "rubble", + "outcomes": { + "search": { + "success_rate": 0.5, + "stamina_cost": 2, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You dig through the debris and find some [Scrap Metal].", + "failure": "The pile seems to have been picked clean already.", + "crit_success": "", + "crit_failure": "You cut your hand on a sharp piece of glass! (-5 HP)" + }, + "rewards": { + "items": [ + { + "item_id": "scrap_metal", + "quantity": 2, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 5 + } + } + } + }, + "warehouse_dumpster": { + "template_id": "dumpster", + "outcomes": { + "search_dumpster": { + "success_rate": 0.5, + "stamina_cost": 2, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "Despite the smell, you find some [Plastic Bottles] and [Cloth Scraps].", + "failure": "Just rotting garbage. Nothing useful.", + "crit_success": "", + "crit_failure": "You disturb a nest of rats! They bite you! (-8 HP)" + }, + "rewards": { + "items": [ + { + "item_id": "plastic_bottles", + "quantity": 3, + "chance": 1.0 + }, + { + "item_id": "cloth_scraps", + "quantity": 2, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 8 + } + } + } + }, + "warehouse_toolshed": { + "template_id": "toolshed", + "outcomes": { + "search_shed": { + "success_rate": 0.5, + "stamina_cost": 2, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You find some tools: [Rusty Nails], [Wood Planks], and a [Flashlight]!", + "failure": "The shed has been picked clean. Only empty shelves remain.", + "crit_success": "", + "crit_failure": "" + }, + "rewards": { + "items": [ + { + "item_id": "rusty_nails", + "quantity": 5, + "chance": 1.0 + }, + { + "item_id": "wood_planks", + "quantity": 2, + "chance": 1.0 + }, + { + "item_id": "flashlight", + "quantity": 1, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 0 + } + } + } + } + } + }, + { + "id": "warehouse_interior", + "name": "\ud83d\udce6 Warehouse Interior", + "description": "Inside the warehouse, towering shelves cast long shadows. Scattered crates and pallets suggest this was once a distribution center. The back office door hangs open.", + "x": 4.5, + "y": -2.0, + "image_path": "images/locations/warehouse_interior.png", + "interactables": { + "warehouse_int_crate1": { + "template_id": "rubble", + "outcomes": { + "search": { + "success_rate": 0.5, + "stamina_cost": 2, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You dig through the debris and find some [Scrap Metal].", + "failure": "The pile seems to have been picked clean already.", + "crit_success": "", + "crit_failure": "You cut your hand on a sharp piece of glass! (-5 HP)" + }, + "rewards": { + "items": [ + { + "item_id": "scrap_metal", + "quantity": 2, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 5 + } + } + } + }, + "warehouse_int_crate2": { + "template_id": "rubble", + "outcomes": { + "search": { + "success_rate": 0.5, + "stamina_cost": 2, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You dig through the debris and find some [Scrap Metal].", + "failure": "The pile seems to have been picked clean already.", + "crit_success": "", + "crit_failure": "You cut your hand on a sharp piece of glass! (-5 HP)" + }, + "rewards": { + "items": [ + { + "item_id": "scrap_metal", + "quantity": 2, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 5 + } + } + } + }, + "warehouse_int_office": { + "template_id": "house", + "outcomes": { + "search_house": { + "success_rate": 0.5, + "stamina_cost": 3, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You find some useful supplies: [Canned Beans], [Bottled Water], and [Cloth Scraps]!", + "failure": "The house has already been thoroughly looted. Nothing remains.", + "crit_success": "", + "crit_failure": "The floor collapses beneath you! (-10 HP)" + }, + "rewards": { + "items": [ + { + "item_id": "canned_beans", + "quantity": 1, + "chance": 1.0 + }, + { + "item_id": "bottled_water", + "quantity": 1, + "chance": 1.0 + }, + { + "item_id": "cloth_scraps", + "quantity": 3, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 10 + } + } + } + } + } + }, + { + "id": "subway", + "name": "\ud83d\ude87 Subway Station Entrance", + "description": "Stairs descend into darkness. The entrance to an abandoned subway station yawns before you. Emergency lighting flickers somewhere below.", + "x": -4.0, + "y": -0.5, + "image_path": "images/locations/subway.png", + "interactables": { + "subway_rubble": { + "template_id": "rubble", + "outcomes": { + "search": { + "success_rate": 0.5, + "stamina_cost": 2, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You dig through the debris and find some [Scrap Metal].", + "failure": "The pile seems to have been picked clean already.", + "crit_success": "", + "crit_failure": "You cut your hand on a sharp piece of glass! (-5 HP)" + }, + "rewards": { + "items": [ + { + "item_id": "scrap_metal", + "quantity": 2, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 5 + } + } + } + }, + "subway_vending": { + "template_id": "vending", + "outcomes": { + "break_vending": { + "success_rate": 0.5, + "stamina_cost": 4, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You smash the glass and grab [Energy Bars] and [Bottled Water]!", + "failure": "The machine is tougher than it looks. You can't break it open.", + "crit_success": "", + "crit_failure": "" + }, + "rewards": { + "items": [ + { + "item_id": "energy_bar", + "quantity": 2, + "chance": 1.0 + }, + { + "item_id": "bottled_water", + "quantity": 2, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 0 + } + } + } + } + } + }, + { + "id": "subway_tunnels", + "name": "\ud83d\ude8a Subway Tunnels", + "description": "The tunnels stretch into darkness. Water drips from the ceiling. A stalled train blocks part of the track. The air is stale and oppressive.", + "image_path": "images/locations/subway_tunnels.png", + "x": -4.5, + "y": -1, + "interactables": {} + }, + { + "id": "office_building", + "name": "\ud83c\udfe2 Office Building", + "description": "A five-story office building with shattered windows. The lobby is trashed, but the stairs appear intact. You can hear the wind whistling through the upper floors.", + "image_path": "images/locations/office_building.png", + "x": 3.5, + "y": 4, + "interactables": { + "rubble_1760801399701": { + "template_id": "rubble", + "outcomes": { + "search": { + "success_rate": 0.5, + "stamina_cost": 2, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You successfully \ud83d\udd0e search rubble.", + "failure": "You failed to \ud83d\udd0e search rubble.", + "crit_success": "", + "crit_failure": "" + }, + "rewards": { + "items": [], + "damage": 0, + "crit_items": [], + "crit_damage": 0 + } + } + } + } + } + }, + { + "id": "office_interior", + "name": "\ud83d\udcbc Office Floors", + "description": "Cubicles stretch across the floor. Papers scatter in the breeze from broken windows. Filing cabinets stand open, already ransacked. A corner office looks promising.", + "image_path": "images/locations/office_interior.png", + "x": 4, + "y": 4.5, + "interactables": { + "office_desk1": { + "template_id": "rubble", + "outcomes": { + "search": { + "success_rate": 0.5, + "stamina_cost": 2, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You dig through the debris and find some [Scrap Metal].", + "failure": "The pile seems to have been picked clean already.", + "crit_success": "", + "crit_failure": "You cut your hand on a sharp piece of glass! (-5 HP)" + }, + "rewards": { + "items": [ + { + "item_id": "scrap_metal", + "quantity": 2, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 5 + } + } + } + }, + "office_desk2": { + "template_id": "rubble", + "outcomes": { + "search": { + "success_rate": 0.5, + "stamina_cost": 2, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You dig through the debris and find some [Scrap Metal].", + "failure": "The pile seems to have been picked clean already.", + "crit_success": "", + "crit_failure": "You cut your hand on a sharp piece of glass! (-5 HP)" + }, + "rewards": { + "items": [ + { + "item_id": "scrap_metal", + "quantity": 2, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 5 + } + } + } + }, + "office_corner": { + "template_id": "house", + "outcomes": { + "search_house": { + "success_rate": 0.5, + "stamina_cost": 3, + "crit_success_chance": 0.1, + "crit_failure_chance": 0.1, + "text": { + "success": "You find some useful supplies: [Canned Beans], [Bottled Water], and [Cloth Scraps]!", + "failure": "The house has already been thoroughly looted. Nothing remains.", + "crit_success": "", + "crit_failure": "The floor collapses beneath you! (-10 HP)" + }, + "rewards": { + "items": [ + { + "item_id": "canned_beans", + "quantity": 1, + "chance": 1.0 + }, + { + "item_id": "bottled_water", + "quantity": 1, + "chance": 1.0 + }, + { + "item_id": "cloth_scraps", + "quantity": 3, + "chance": 1.0 + } + ], + "damage": 0, + "crit_items": [], + "crit_damage": 10 + } + } + } + } + } + }, + { + "id": "location_1760791397492", + "name": "Subway Section A", + "description": "A shady dimly lit subway section. All you can see are abandoned train tracks and some garbage lying around. ", + "image_path": "images/locations/subway_section_a.jpg", + "x": -5, + "y": -2, + "interactables": {} + } + ], + "connections": [ + { + "from": "start_point", + "to": "gas_station", + "direction": "north", + "stamina_cost": 6 + }, + { + "from": "start_point", + "to": "residential", + "direction": "east", + "stamina_cost": 9 + }, + { + "from": "start_point", + "to": "plaza", + "direction": "west", + "stamina_cost": 8 + }, + { + "from": "gas_station", + "to": "start_point", + "direction": "south", + "stamina_cost": 6 + }, + { + "from": "gas_station", + "to": "overpass", + "direction": "north", + "stamina_cost": 8 + }, + { + "from": "residential", + "to": "start_point", + "direction": "west", + "stamina_cost": 9 + }, + { + "from": "residential", + "to": "clinic", + "direction": "north", + "stamina_cost": 9 + }, + { + "from": "residential", + "to": "park", + "direction": "south", + "stamina_cost": 13 + }, + { + "from": "residential", + "to": "warehouse", + "direction": "southeast", + "stamina_cost": 5 + }, + { + "from": "clinic", + "to": "residential", + "direction": "south", + "stamina_cost": 9 + }, + { + "from": "clinic", + "to": "gas_station", + "direction": "west", + "stamina_cost": 7 + }, + { + "from": "clinic", + "to": "office_building", + "direction": "east", + "stamina_cost": 5 + }, + { + "from": "plaza", + "to": "start_point", + "direction": "east", + "stamina_cost": 8 + }, + { + "from": "plaza", + "to": "park", + "direction": "south", + "stamina_cost": 8 + }, + { + "from": "plaza", + "to": "subway", + "direction": "west", + "stamina_cost": 5 + }, + { + "from": "park", + "to": "plaza", + "direction": "north", + "stamina_cost": 8 + }, + { + "from": "park", + "to": "residential", + "direction": "east", + "stamina_cost": 13 + }, + { + "from": "park", + "to": "warehouse", + "direction": "southeast", + "stamina_cost": 15 + }, + { + "from": "overpass", + "to": "gas_station", + "direction": "south", + "stamina_cost": 8 + }, + { + "from": "overpass", + "to": "clinic", + "direction": "east", + "stamina_cost": 5 + }, + { + "from": "warehouse", + "to": "residential", + "direction": "northwest", + "stamina_cost": 5 + }, + { + "from": "warehouse", + "to": "park", + "direction": "west", + "stamina_cost": 15 + }, + { + "from": "warehouse", + "to": "warehouse_interior", + "direction": "inside", + "stamina_cost": 2 + }, + { + "from": "warehouse_interior", + "to": "warehouse", + "direction": "outside", + "stamina_cost": 2 + }, + { + "from": "subway", + "to": "plaza", + "direction": "east", + "stamina_cost": 5 + }, + { + "from": "subway", + "to": "subway_tunnels", + "direction": "down", + "stamina_cost": 2 + }, + { + "from": "subway_tunnels", + "to": "subway", + "direction": "up", + "stamina_cost": 2 + }, + { + "from": "office_building", + "to": "clinic", + "direction": "west", + "stamina_cost": 5 + }, + { + "from": "office_building", + "to": "overpass", + "direction": "south", + "stamina_cost": 8 + }, + { + "from": "office_building", + "to": "office_interior", + "direction": "inside", + "stamina_cost": 2 + }, + { + "from": "office_interior", + "to": "office_building", + "direction": "outside", + "stamina_cost": 2 + }, + { + "from": "location_1760789845933", + "to": "subway_tunnels", + "direction": "north" + }, + { + "from": "location_1760791397492", + "to": "subway_tunnels", + "direction": "north" + }, + { + "from": "location_1760791397492", + "to": "subway_tunnels", + "direction": "north" + }, + { + "from": "location_1760799315918", + "to": "gas_station", + "direction": "southeast" + }, + { + "from": "subway_tunnels", + "to": "location_1760791397492", + "direction": "south" + } + ], + "danger_config": { + "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.1, + "wandering_chance": 0.2 + }, + "park": { + "danger_level": 1, + "encounter_rate": 0.1, + "wandering_chance": 0.2 + }, + "clinic": { + "danger_level": 2, + "encounter_rate": 0.2, + "wandering_chance": 0.35 + }, + "plaza": { + "danger_level": 2, + "encounter_rate": 0.15, + "wandering_chance": 0.3 + }, + "warehouse": { + "danger_level": 2, + "encounter_rate": 0.18, + "wandering_chance": 0.32 + }, + "warehouse_interior": { + "danger_level": 2, + "encounter_rate": 0.22, + "wandering_chance": 0.4 + }, + "overpass": { + "danger_level": 3, + "encounter_rate": 0.3, + "wandering_chance": 0.45 + }, + "office_building": { + "danger_level": 3, + "encounter_rate": 0.25, + "wandering_chance": 0.4 + }, + "office_interior": { + "danger_level": 3, + "encounter_rate": 0.35, + "wandering_chance": 0.5 + }, + "subway": { + "danger_level": 4, + "encounter_rate": 0.35, + "wandering_chance": 0.5 + }, + "subway_tunnels": { + "danger_level": 4, + "encounter_rate": 0.45, + "wandering_chance": 0.65 + } + }, + "spawn_config": { + "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 + } + ] + } +} \ No newline at end of file diff --git a/gamedata/npcs.json b/gamedata/npcs.json new file mode 100644 index 0000000..67aaa88 --- /dev/null +++ b/gamedata/npcs.json @@ -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 + } + ] + } +} diff --git a/images/README.md b/images/README.md new file mode 100644 index 0000000..c933ba8 --- /dev/null +++ b/images/README.md @@ -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. diff --git a/images/interactables/.gitkeep b/images/interactables/.gitkeep new file mode 100644 index 0000000..1cd8371 --- /dev/null +++ b/images/interactables/.gitkeep @@ -0,0 +1,2 @@ +# This is a placeholder file. +# Replace with actual interactable images. diff --git a/images/interactables/dumpster.png b/images/interactables/dumpster.png new file mode 100644 index 0000000..447bb6a Binary files /dev/null and b/images/interactables/dumpster.png differ diff --git a/images/interactables/house.png b/images/interactables/house.png new file mode 100644 index 0000000..9dfd39e Binary files /dev/null and b/images/interactables/house.png differ diff --git a/images/interactables/medkit.png b/images/interactables/medkit.png new file mode 100644 index 0000000..58470ad Binary files /dev/null and b/images/interactables/medkit.png differ diff --git a/images/interactables/rubble.png b/images/interactables/rubble.png new file mode 100644 index 0000000..9ffcd97 Binary files /dev/null and b/images/interactables/rubble.png differ diff --git a/images/interactables/sedan.png b/images/interactables/sedan.png new file mode 100644 index 0000000..1c1b85e Binary files /dev/null and b/images/interactables/sedan.png differ diff --git a/images/interactables/toolshed.png b/images/interactables/toolshed.png new file mode 100644 index 0000000..d8b9246 Binary files /dev/null and b/images/interactables/toolshed.png differ diff --git a/images/interactables/vending.png b/images/interactables/vending.png new file mode 100644 index 0000000..e68023d Binary files /dev/null and b/images/interactables/vending.png differ diff --git a/images/locations/.gitkeep b/images/locations/.gitkeep new file mode 100644 index 0000000..9500a5c --- /dev/null +++ b/images/locations/.gitkeep @@ -0,0 +1,2 @@ +# This is a placeholder file. +# Replace with actual location images. diff --git a/images/locations/clinic.png b/images/locations/clinic.png new file mode 100644 index 0000000..e0f0ac4 Binary files /dev/null and b/images/locations/clinic.png differ diff --git a/images/locations/downtown.png b/images/locations/downtown.png new file mode 100644 index 0000000..becef35 Binary files /dev/null and b/images/locations/downtown.png differ diff --git a/images/locations/gas_station.png b/images/locations/gas_station.png new file mode 100644 index 0000000..97cca4c Binary files /dev/null and b/images/locations/gas_station.png differ diff --git a/images/locations/office_building.png b/images/locations/office_building.png new file mode 100644 index 0000000..e33b5e3 Binary files /dev/null and b/images/locations/office_building.png differ diff --git a/images/locations/office_interior.png b/images/locations/office_interior.png new file mode 100644 index 0000000..b5ee47e Binary files /dev/null and b/images/locations/office_interior.png differ diff --git a/images/locations/overpass.png b/images/locations/overpass.png new file mode 100644 index 0000000..97bd0e0 Binary files /dev/null and b/images/locations/overpass.png differ diff --git a/images/locations/park.png b/images/locations/park.png new file mode 100644 index 0000000..46bfb5f Binary files /dev/null and b/images/locations/park.png differ diff --git a/images/locations/plaza.png b/images/locations/plaza.png new file mode 100644 index 0000000..3ff8525 Binary files /dev/null and b/images/locations/plaza.png differ diff --git a/images/locations/residential.png b/images/locations/residential.png new file mode 100644 index 0000000..cff9580 Binary files /dev/null and b/images/locations/residential.png differ diff --git a/images/locations/subway.png b/images/locations/subway.png new file mode 100644 index 0000000..3ef3b54 Binary files /dev/null and b/images/locations/subway.png differ diff --git a/images/locations/subway_section_a.jpg b/images/locations/subway_section_a.jpg new file mode 100644 index 0000000..13ff486 Binary files /dev/null and b/images/locations/subway_section_a.jpg differ diff --git a/images/locations/subway_tunnels.png b/images/locations/subway_tunnels.png new file mode 100644 index 0000000..f66287f Binary files /dev/null and b/images/locations/subway_tunnels.png differ diff --git a/images/locations/warehouse.png b/images/locations/warehouse.png new file mode 100644 index 0000000..1166ecb Binary files /dev/null and b/images/locations/warehouse.png differ diff --git a/images/locations/warehouse_interior.png b/images/locations/warehouse_interior.png new file mode 100644 index 0000000..a97216e Binary files /dev/null and b/images/locations/warehouse_interior.png differ diff --git a/images/npcs/.gitkeep b/images/npcs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/images/npcs/feral_dog.png b/images/npcs/feral_dog.png new file mode 100644 index 0000000..211bc02 Binary files /dev/null and b/images/npcs/feral_dog.png differ diff --git a/images/npcs/infected_human.png b/images/npcs/infected_human.png new file mode 100644 index 0000000..b2c6335 Binary files /dev/null and b/images/npcs/infected_human.png differ diff --git a/images/npcs/mutant_rat.png b/images/npcs/mutant_rat.png new file mode 100644 index 0000000..6fcf7af Binary files /dev/null and b/images/npcs/mutant_rat.png differ diff --git a/images/npcs/raider_scout.png b/images/npcs/raider_scout.png new file mode 100644 index 0000000..e24c0f0 Binary files /dev/null and b/images/npcs/raider_scout.png differ diff --git a/images/npcs/scavenger.png b/images/npcs/scavenger.png new file mode 100644 index 0000000..2eec5af Binary files /dev/null and b/images/npcs/scavenger.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..2f1c86e --- /dev/null +++ b/main.py @@ -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.") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..66ecec5 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/web-map/README.md b/web-map/README.md new file mode 100644 index 0000000..985a1ee --- /dev/null +++ b/web-map/README.md @@ -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. diff --git a/web-map/editor.html b/web-map/editor.html new file mode 100644 index 0000000..ab41f67 --- /dev/null +++ b/web-map/editor.html @@ -0,0 +1,1160 @@ + + + + + + Map Editor - Echoes of the Ashes + + + + +
+ +
+ + +
+ +
+

๐Ÿ—บ๏ธ Game Editor

+
+ + + +
+
+ +
+ + + + + +
+ + +
+ + + + +
+ + + +
+ + + +
+
+ + +
+
+

No Location Selected

+

Select a location from the list or click on the map to create a new one.

+
+ + +
+
+ + +
+
+
+
+

๐Ÿ‘น NPCs

+ +
+ +
+ +
+
+
+
+

Select an NPC or create a new one

+
+
+
+
+ + +
+
+
+
+

๐ŸŽ’ Items

+ +
+ +
+ +
+
+
+
+

Select an item or create a new one

+
+
+
+
+ + +
+
+ +
+

๐ŸŽฏ Interactable Templates

+ + +
+
+ + +
+
+

Select an interactable to edit

+

Choose from the list or create a new one.

+
+
+
+
+ + +
+
+
+

๐Ÿ“‹ Bot Logs (echoes_of_the_ashes_bot)

+
+ + + + +
+
+
+
+ Click "Refresh" to load bot logs... +
+
+
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + diff --git a/web-map/editor.js b/web-map/editor.js new file mode 100644 index 0000000..07374c9 --- /dev/null +++ b/web-map/editor.js @@ -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 = ` +
${location.name}
+
๐Ÿ“ (${location.x}, ${location.y}) | Danger: ${location.danger_level}
+ `; + + 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 = `Location image`; + } else { + preview.innerHTML = 'No image'; + } +} + +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 = ` +
+
${spawn.emoji} ${spawn.name}
+
Weight: ${spawn.weight}
+
+ + `; + 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 = ` +
${npc.emoji} ${npc.name}
+
+ HP: ${npc.hp_range[0]}-${npc.hp_range[1]} | + DMG: ${npc.damage_range[0]}-${npc.damage_range[1]} | + XP: ${npc.xp_reward} +
+ `; + + 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 = ` +
+
${npc.emoji} ${npc.name}
+
Weight: ${weight}
+
+ + `; + 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); + } +} diff --git a/web-map/editor_enhanced.js b/web-map/editor_enhanced.js new file mode 100644 index 0000000..c4b625e --- /dev/null +++ b/web-map/editor_enhanced.js @@ -0,0 +1,3025 @@ +// Enhanced Map Editor with Zoom, Pan, Connections, and Live Stats +let currentLocations = []; +let availableNPCs = []; +let availableItems = []; +let availableInteractables = []; +let selectedLocationId = null; +let selectedNPCId = null; +let selectedItemId = null; +let selectedInteractableId = null; +let connections = []; +let liveStats = {players: {}, enemies: {}}; +let canvas, ctx; +let currentTab = 'locations'; + +// View state +let scale = 50; +let minScale = 10; +let maxScale = 200; +let offsetX = 0; +let offsetY = 0; +let isDragging = false; +let dragStartX = 0; +let dragStartY = 0; +let clickStartTime = 0; +let clickStartPos = {x: 0, y: 0}; + +// Live stats refresh +let liveStatsInterval = null; + +// 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'; + } +}); + +// Ensure canvas is properly sized after everything loads +window.addEventListener('load', () => { + if (canvas) { + setTimeout(resizeCanvas, 100); + } +}); + +// 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 = 'flex'; + + // Initialize canvas + canvas = document.getElementById('editorCanvas'); + ctx = canvas.getContext('2d'); + + // Load data + await loadLocations(); + await loadConnections(); + await loadAvailableNPCs(); + await loadAvailableInteractablesForLocations(); // Load interactables for location instances + await loadAvailableItemsForLocations(); // Load items for interactable rewards + await loadLiveStats(); + + // Resize canvas after everything is loaded and rendered + setTimeout(() => { + resizeCanvas(); + window.addEventListener('resize', resizeCanvas); + }, 100); + + // Start live stats refresh (every 5 seconds) + liveStatsInterval = setInterval(loadLiveStats, 5000); + + // Draw map + drawMap(); + + // Canvas event handlers + canvas.addEventListener('mousedown', handleMouseDown); + canvas.addEventListener('mousemove', handleMouseMove); + canvas.addEventListener('mouseup', handleMouseUp); + canvas.addEventListener('wheel', handleWheel, {passive: false}); + canvas.addEventListener('mouseleave', () => { isDragging = false; }); +} + +function resizeCanvas() { + canvas.width = canvas.offsetWidth; + canvas.height = canvas.offsetHeight; + drawMap(); +} + +async function loadLocations() { + try { + const response = await fetch('/api/editor/locations', { + credentials: 'same-origin' + }); + const data = await response.json(); + currentLocations = data.locations; + renderLocationList(); + // Trigger resize after locations are rendered + setTimeout(resizeCanvas, 50); + } catch (error) { + console.error('Failed to load locations:', error); + } +} + +async function loadConnections() { + try { + const response = await fetch('/api/editor/connections', { + credentials: 'same-origin' + }); + const data = await response.json(); + connections = data.connections; + console.log('Loaded connections:', connections.length); + } catch (error) { + console.error('Failed to load connections:', error); + } +} + +async function loadAvailableNPCs() { + try { + const response = await fetch('/api/editor/available-npcs', { + credentials: 'same-origin' + }); + const data = await response.json(); + availableNPCs = data.npcs; + } catch (error) { + console.error('Failed to load NPCs:', error); + } +} + +async function loadLiveStats() { + try { + const response = await fetch('/api/editor/live-stats', { + credentials: 'same-origin' + }); + if (!response.ok) { + console.error('Live stats request failed:', response.status); + return; + } + const data = await response.json(); + liveStats.players = data.players_by_location || {}; + liveStats.enemies = data.enemies_by_location || {}; + drawMap(); // Redraw to show updated stats + } catch (error) { + console.error('Failed to load live stats:', error); + } +} + +function renderLocationList() { + const list = document.getElementById('locationList'); + list.innerHTML = ''; + + currentLocations.forEach(location => { + const item = document.createElement('div'); + item.className = 'location-item'; + item.dataset.name = location.name; + item.dataset.id = location.id; + if (location.id === selectedLocationId) { + item.classList.add('active'); + } + + const playerCount = liveStats.players[location.id] || 0; + const enemyCount = liveStats.enemies[location.id] || 0; + + item.innerHTML = ` +
${location.name}
+
๐Ÿ“ (${location.x}, ${location.y}) | Danger: ${location.danger_level}
+ ${playerCount > 0 || enemyCount > 0 ? `
๐Ÿ‘ฅ ${playerCount} | ๐Ÿ‘น ${enemyCount}
` : ''} + `; + + 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); + + // Render interactables list + renderInteractablesList(location.interactables || {}); + + // Render connections + renderConnectionsList(location.id, location.exits); +} + +function updateImagePreview(imagePath) { + const preview = document.getElementById('imagePreview'); + if (imagePath) { + preview.innerHTML = `Location image`; + } else { + preview.innerHTML = 'No image'; + } +} + +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 = ` +
+
${spawn.emoji} ${spawn.name}
+
Weight: ${spawn.weight}
+
+ + `; + list.appendChild(item); + }); +} + +function renderConnectionsList(locationId, exits) { + const list = document.getElementById('connectionsList'); + if (!list) return; + + list.innerHTML = ''; + + for (const [direction, destId] of Object.entries(exits)) { + const destLocation = currentLocations.find(l => l.id === destId); + if (destLocation) { + const item = document.createElement('div'); + item.className = 'connection-item'; + item.innerHTML = ` +
+
${direction} โ†’ ${destLocation.name}
+
+ + `; + list.appendChild(item); + } + } +} + +// ==================== CANVAS DRAWING ==================== + +function drawMap() { + if (!ctx) return; + + // Clear canvas + ctx.fillStyle = '#0f0f1e'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const centerX = canvas.width / 2 + offsetX; + const centerY = canvas.height / 2 + offsetY; + + // Draw grid + drawGrid(centerX, centerY); + + // Draw connections first (under locations) + drawConnections(centerX, centerY); + + // Draw locations + drawLocations(centerX, centerY); + + // Draw zoom indicator + ctx.fillStyle = '#e0e0e0'; + ctx.font = '12px monospace'; + ctx.textAlign = 'left'; + ctx.fillText(`Zoom: ${Math.round(scale)}px/unit`, 10, 20); +} + +function drawGrid(centerX, centerY) { + ctx.strokeStyle = '#1a1a3e'; + ctx.lineWidth = 1; + + // 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(); + } + + // 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(); +} + +function drawConnections(centerX, centerY) { + console.log('Drawing connections, total:', connections.length); + connections.forEach(conn => { + const fromLoc = currentLocations.find(l => l.id === conn.from); + const toLoc = currentLocations.find(l => l.id === conn.to); + + if (fromLoc && toLoc) { + const x1 = centerX + fromLoc.x * scale; + const y1 = centerY - fromLoc.y * scale; + const x2 = centerX + toLoc.x * scale; + const y2 = centerY - toLoc.y * scale; + + // Draw arrow + ctx.strokeStyle = '#00bcd4'; // Cyan color to avoid confusion with axes + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + + // Draw arrowhead + const angle = Math.atan2(y2 - y1, x2 - x1); + const headLength = 10; + ctx.strokeStyle = '#00bcd4'; // Match arrow color + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo( + x2 - headLength * Math.cos(angle - Math.PI / 6), + y2 - headLength * Math.sin(angle - Math.PI / 6) + ); + ctx.moveTo(x2, y2); + ctx.lineTo( + x2 - headLength * Math.cos(angle + Math.PI / 6), + y2 - headLength * Math.sin(angle + Math.PI / 6) + ); + ctx.stroke(); + + // Calculate positions for labels + const midX = (x1 + x2) / 2; + const midY = (y1 + y2) / 2; + + // Position "from" direction closer to origin (25% along the line) + const fromLabelX = x1 + (x2 - x1) * 0.25; + const fromLabelY = y1 + (y2 - y1) * 0.25; + + // Position cost in the middle + const costX = midX; + const costY = midY; + + // Position "to" direction closer to destination (75% along the line) + const toLabelX = x1 + (x2 - x1) * 0.75; + const toLabelY = y1 + (y2 - y1) * 0.75; + + // Calculate perpendicular offset for text positioning + const perpAngle = angle + Math.PI / 2; + const textOffset = 8; + + ctx.fillStyle = '#8a8aaa'; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; + + // Check if there's a reverse connection + const reverseConn = connections.find(c => c.from === conn.to && c.to === conn.from); + + if (reverseConn) { + // If bidirectional, offset labels to avoid overlap + // "from" direction on one side + ctx.fillText( + conn.direction, + fromLabelX + Math.cos(perpAngle) * textOffset, + fromLabelY + Math.sin(perpAngle) * textOffset + ); + + // "to" direction (reverse) on the other side + ctx.fillText( + reverseConn.direction, + toLabelX - Math.cos(perpAngle) * textOffset, + toLabelY - Math.sin(perpAngle) * textOffset + ); + } else { + // Single direction - center the label + ctx.fillText(conn.direction, fromLabelX, fromLabelY - 5); + } + + // Draw distance/cost in the middle + const distance = Math.sqrt(Math.pow(toLoc.x - fromLoc.x, 2) + Math.pow(toLoc.y - fromLoc.y, 2)); + const cost = conn.stamina_cost || Math.ceil(distance * 2); // Use actual cost if available + ctx.fillStyle = '#ffb74d'; + ctx.fillText(`โšก${cost}`, costX, costY + 3); + } + }); +} + +function drawLocations(centerX, centerY) { + const dangerColors = ['#4caf50', '#8bc34a', '#ffa726', '#ff5722', '#d32f2f']; + + currentLocations.forEach(location => { + const x = centerX + location.x * scale; + const y = centerY - location.y * scale; + + // Draw location circle + const color = dangerColors[location.danger_level] || '#9e9e9e'; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(x, y, 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, x, y + 30); + + // Draw live stats badges + const playerCount = liveStats.players[location.id] || 0; + const enemyCount = liveStats.enemies[location.id] || 0; + + let badgeY = y - 20; + + if (playerCount > 0) { + drawBadge(x - 10, badgeY, `๐Ÿ‘ฅ${playerCount}`, '#2196f3'); + badgeY -= 15; + } + + if (enemyCount > 0) { + drawBadge(x + 10, badgeY, `๐Ÿ‘น${enemyCount}`, '#f44336'); + } + }); +} + +function drawBadge(x, y, text, color) { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(x, y, 12, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 9px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, x, y); + ctx.textBaseline = 'alphabetic'; +} + +// ==================== MOUSE INTERACTION ==================== + +function handleMouseDown(e) { + const rect = canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + clickStartTime = Date.now(); + clickStartPos = {x: mouseX, y: mouseY}; + dragStartX = mouseX - offsetX; + dragStartY = mouseY - offsetY; +} + +function handleMouseMove(e) { + if (e.buttons === 1) { // Left mouse button + const rect = canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + // Check if moved enough to be a drag + const dx = mouseX - clickStartPos.x; + const dy = mouseY - clickStartPos.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > 5) { + isDragging = true; + canvas.style.cursor = 'grabbing'; + offsetX = mouseX - dragStartX; + offsetY = mouseY - dragStartY; + drawMap(); + } + } +} + +function handleMouseUp(e) { + const clickDuration = Date.now() - clickStartTime; + + if (!isDragging && clickDuration < 300) { + // This was a click, not a drag + handleCanvasClick(e); + } + + isDragging = false; + canvas.style.cursor = 'crosshair'; +} + +function handleWheel(e) { + e.preventDefault(); + + const rect = canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + // Get world position before zoom + const centerX = canvas.width / 2 + offsetX; + const centerY = canvas.height / 2 + offsetY; + const worldX = (mouseX - centerX) / scale; + const worldY = -(mouseY - centerY) / scale; + + // Zoom + const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; + const newScale = Math.max(minScale, Math.min(maxScale, scale * zoomFactor)); + + if (newScale !== scale) { + // Adjust offset to keep mouse position stable + const newCenterX = canvas.width / 2 + offsetX; + const newCenterY = canvas.height / 2 + offsetY; + const newScreenX = newCenterX + worldX * newScale; + const newScreenY = newCenterY - worldY * newScale; + + offsetX += mouseX - newScreenX; + offsetY += mouseY - newScreenY; + + scale = newScale; + drawMap(); + } +} + +function handleCanvasClick(e) { + const rect = canvas.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const clickY = e.clientY - rect.top; + + const centerX = canvas.width / 2 + offsetX; + const centerY = canvas.height / 2 + offsetY; + + // 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)); + } +} + +// ==================== ZOOM CONTROLS ==================== + +function zoomIn() { + scale = Math.min(maxScale, scale * 1.2); + drawMap(); +} + +function zoomOut() { + scale = Math.max(minScale, scale / 1.2); + drawMap(); +} + +function resetView() { + scale = 50; + offsetX = 0; + offsetY = 0; + drawMap(); +} + +// ==================== LOCATION MANAGEMENT ==================== + +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(), + interactables: getInteractableInstances() + }; + + 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(); + await loadConnections(); + drawMap(); + } else { + alert('Failed to save: ' + data.error); + } + } catch (error) { + alert('Failed to save location: ' + error.message); + } +} + +async function deleteCurrentLocation() { + const locationId = document.getElementById('locationId').value; + + if (!confirm(`Delete location "${locationId}"? This will also remove all connections to/from this location.`)) { + return; + } + + try { + const response = await fetch(`/api/editor/location/${locationId}`, { + method: 'DELETE' + }); + + const data = await response.json(); + + if (data.success) { + showSuccess('Location deleted successfully!'); + selectedLocationId = null; + document.getElementById('noSelectionMessage').classList.remove('hidden'); + document.getElementById('propertiesForm').classList.add('hidden'); + await loadLocations(); + await loadConnections(); + drawMap(); + } else { + alert('Failed to delete: ' + data.error); + } + } catch (error) { + alert('Failed to delete location: ' + error.message); + } +} + +// Wrapper functions for header buttons +function saveCurrentLocation() { + if (!selectedLocationId) { + alert('Please select a location first'); + return; + } + saveLocation(); +} + +// ==================== CONNECTION MANAGEMENT ==================== + +function showAddConnectionModal() { + const modal = document.getElementById('addConnectionModal'); + const searchInput = document.getElementById('connectionSearch'); + + // Clear search + searchInput.value = ''; + + // Populate the list + filterConnectionList(); + + modal.style.display = 'flex'; + searchInput.focus(); +} + +function filterConnectionList() { + const list = document.getElementById('connectionTargetList'); + const searchTerm = document.getElementById('connectionSearch').value.toLowerCase(); + const currentId = document.getElementById('locationId').value; + + list.innerHTML = ''; + + const filteredLocations = currentLocations.filter(location => { + if (location.id === currentId) return false; + if (!searchTerm) return true; + return location.name.toLowerCase().includes(searchTerm) || + location.id.toLowerCase().includes(searchTerm); + }); + + if (filteredLocations.length === 0) { + list.innerHTML = '
No locations found
'; + return; + } + + filteredLocations.forEach(location => { + const item = document.createElement('div'); + item.className = 'connection-target-item'; + item.innerHTML = ` +
${location.name}
+
+ ๐Ÿ“ (${location.x}, ${location.y}) | Danger: ${location.danger_level} +
+ `; + + item.onclick = () => promptAddConnection(currentId, location.id); + list.appendChild(item); + }); +} + +function closeAddConnectionModal() { + document.getElementById('addConnectionModal').style.display = 'none'; +} + +function filterLocationList() { + const searchTerm = document.getElementById('locationSearchInput').value.toLowerCase(); + const locationList = document.getElementById('locationList'); + const items = locationList.getElementsByClassName('location-item'); + + Array.from(items).forEach(item => { + const name = item.dataset.name ? item.dataset.name.toLowerCase() : ''; + const id = item.dataset.id ? item.dataset.id.toLowerCase() : ''; + + if (!searchTerm || name.includes(searchTerm) || id.includes(searchTerm)) { + item.style.display = ''; + } else { + item.style.display = 'none'; + } + }); +} + +function filterNPCList() { + const searchTerm = document.getElementById('npcSearch').value.toLowerCase(); + const list = document.getElementById('npcSelectList'); + const items = list.getElementsByClassName('npc-item'); + + Array.from(items).forEach(item => { + const name = item.dataset.name ? item.dataset.name.toLowerCase() : ''; + const id = item.dataset.id ? item.dataset.id.toLowerCase() : ''; + + if (!searchTerm || name.includes(searchTerm) || id.includes(searchTerm)) { + item.style.display = ''; + } else { + item.style.display = 'none'; + } + }); +} + +async function promptAddConnection(fromId, toId) { + const direction = prompt('Enter direction (north, south, east, west, northeast, etc.):'); + if (!direction) return; + + try { + const response = await fetch('/api/editor/connection', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({from: fromId, to: toId, direction: direction.toLowerCase()}) + }); + + const data = await response.json(); + + if (data.success) { + showSuccess('Connection added!'); + await loadConnections(); + await selectLocation(fromId); // Refresh form + drawMap(); + closeAddConnectionModal(); + } else { + alert('Failed to add connection: ' + data.error); + } + } catch (error) { + alert('Failed to add connection: ' + error.message); + } +} + +async function deleteConnection(fromId, toId) { + if (!confirm('Delete this connection?')) return; + + try { + const response = await fetch('/api/editor/connection', { + method: 'DELETE', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({from: fromId, to: toId}) + }); + + const data = await response.json(); + + if (data.success) { + showSuccess('Connection deleted!'); + await loadConnections(); + await selectLocation(fromId); // Refresh form + drawMap(); + } else { + alert('Failed to delete connection: ' + data.error); + } + } catch (error) { + alert('Failed to delete connection: ' + error.message); + } +} + +// ==================== SPAWN MANAGEMENT ==================== + +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: ', '')); + + 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 = ''; + + // Clear search input + document.getElementById('npcSearch').value = ''; + + availableNPCs.forEach(npc => { + const item = document.createElement('div'); + item.className = 'npc-item npc-select-item'; + item.dataset.name = npc.name; + item.dataset.id = npc.id; + item.innerHTML = ` +
${npc.emoji} ${npc.name}
+
+ HP: ${npc.hp_range[0]}-${npc.hp_range[1]} | + DMG: ${npc.damage_range[0]}-${npc.damage_range[1]} | + XP: ${npc.xp_reward} +
+ `; + + item.onclick = () => addSpawn(npc); + list.appendChild(item); + }); + + modal.style.display = 'flex'; + + // Focus the search input + setTimeout(() => document.getElementById('npcSearch').focus(), 100); +} + +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 = ` +
+
${npc.emoji} ${npc.name}
+
Weight: ${weight}
+
+ + `; + list.appendChild(item); + } + closeAddSpawnModal(); +} + +function removeSpawn(index) { + const spawnItems = document.querySelectorAll('.spawn-item'); + if (spawnItems[index]) { + spawnItems[index].remove(); + } +} + +// ==================== INTERACTABLE INSTANCES ==================== + +let currentEditingInteractableInstanceId = null; + +async function showAddInteractableModal() { + const modal = document.getElementById('addInteractableModal'); + const list = document.getElementById('interactableSelectList'); + + list.innerHTML = '
Loading interactables...
'; + modal.style.display = 'flex'; + + // Clear search input + document.getElementById('interactableSearch').value = ''; + + console.log('Current availableInteractables:', availableInteractables); + + // Ensure interactables are loaded + if (!availableInteractables || availableInteractables.length === 0) { + console.log('Loading interactables...'); + await loadAvailableInteractablesForLocations(); + } + + // Clear the loading message + list.innerHTML = ''; + + console.log('After loading, availableInteractables:', availableInteractables); + + // Check again after loading + if (!availableInteractables || availableInteractables.length === 0) { + console.log('No interactables found after loading'); + list.innerHTML = '
No interactables available. Create one in the Interactables tab first.
'; + return; + } + + console.log(`Rendering ${availableInteractables.length} interactables`); + + availableInteractables.forEach(interactable => { + const item = document.createElement('div'); + item.className = 'npc-item npc-select-item'; + item.dataset.name = interactable.name; + item.dataset.id = interactable.id; + const actionCount = Object.keys(interactable.actions || {}).length; + item.innerHTML = ` +
${interactable.name}
+
+ ${interactable.description}
+ ${actionCount} action(s) +
+ `; + + item.onclick = () => addInteractableToLocation(interactable); + list.appendChild(item); + }); + + // Focus the search input + setTimeout(() => document.getElementById('interactableSearch').focus(), 100); +} + +function closeAddInteractableModal() { + document.getElementById('addInteractableModal').style.display = 'none'; +} + +function filterInteractableSelectList() { + const search = document.getElementById('interactableSearch').value.toLowerCase(); + const items = document.querySelectorAll('#interactableSelectList .npc-select-item'); + + items.forEach(item => { + const name = item.dataset.name.toLowerCase(); + if (name.includes(search)) { + item.style.display = 'flex'; + } else { + item.style.display = 'none'; + } + }); +} + +function addInteractableToLocation(interactable) { + // Generate a unique instance ID + const instanceId = `${interactable.id}_${Date.now()}`; + + // Create instance with default outcomes + const instance = { + template_id: interactable.id, + outcomes: {} + }; + + // Initialize default outcomes for each action + // Actions is an object, not an array + for (const [actionId, action] of Object.entries(interactable.actions || {})) { + instance.outcomes[actionId] = { + success_rate: 0.5, + stamina_cost: action.stamina_cost || 0, + text: { + success: `You successfully ${action.label.toLowerCase()}.`, + failure: `You failed to ${action.label.toLowerCase()}.` + }, + rewards: { + items: [], + damage: 0 + } + }; + } + + // Add to UI list + const list = document.getElementById('interactablesList'); + const item = document.createElement('div'); + item.className = 'spawn-item'; + item.dataset.instanceId = instanceId; + const actionCount = Object.keys(interactable.actions || {}).length; + item.innerHTML = ` +
+
${interactable.name}
+
${actionCount} action(s)
+
+
+ + +
+ `; + + // Store instance data + item.dataset.instanceData = JSON.stringify(instance); + + list.appendChild(item); + closeAddInteractableModal(); +} + +function removeInteractableInstance(instanceId) { + const items = document.querySelectorAll('.spawn-item'); + items.forEach(item => { + if (item.dataset.instanceId === instanceId) { + item.remove(); + } + }); +} + +function editInteractableInstance(instanceId, templateId) { + currentEditingInteractableInstanceId = instanceId; + + // Find the instance element + const items = document.querySelectorAll('.spawn-item'); + let instanceElement = null; + items.forEach(item => { + if (item.dataset.instanceId === instanceId) { + instanceElement = item; + } + }); + + if (!instanceElement) return; + + const instanceData = JSON.parse(instanceElement.dataset.instanceData); + const template = availableInteractables.find(i => i.id === templateId); + + if (!template) { + alert('Template not found!'); + return; + } + + // Build the editor UI + const editor = document.getElementById('interactableInstanceEditor'); + editor.innerHTML = ` +
+

${template.name}

+

${template.description}

+
+ +
+ ${Object.entries(template.actions || {}).map(([actionId, action]) => { + const outcome = instanceData.outcomes[actionId] || {}; + return ` +
+

Action: ${action.label}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
Item (type to search)
+
Quantity
+
Chance (0-1)
+
+
+
+ ${(outcome.rewards?.items || []).map((reward, idx) => { + const selectedItem = availableItems.find(i => i.id === reward.item_id); + const displayValue = selectedItem ? `${selectedItem.emoji || '๐Ÿ“ฆ'} ${selectedItem.name}` : ''; + return ` +
+
+ + + +
+ + + +
+ `; + }).join('')} +
+ +
+ +
+ +
+
Item (type to search)
+
Quantity
+
Chance (0-1)
+
+
+
+ ${(outcome.rewards?.crit_items || []).map((reward, idx) => { + const selectedItem = availableItems.find(i => i.id === reward.item_id); + const displayValue = selectedItem ? `${selectedItem.emoji || '๐Ÿ“ฆ'} ${selectedItem.name}` : ''; + return ` +
+
+ + + +
+ + + +
+ `; + }).join('')} +
+ +
+
+ `; + }).join('')} +
+ `; + + document.getElementById('editInteractableInstanceModal').style.display = 'flex'; +} + +function closeEditInteractableInstanceModal() { + document.getElementById('editInteractableInstanceModal').style.display = 'none'; + currentEditingInteractableInstanceId = null; +} + +function addRewardRow(actionId) { + const container = document.getElementById(`item_rewards_${actionId}`); + const div = document.createElement('div'); + div.className = 'reward-item'; + div.style.display = 'flex'; + div.style.gap = '10px'; + div.style.marginBottom = '5px'; + div.style.alignItems = 'center'; + div.style.position = 'relative'; + + div.innerHTML = ` +
+ + + +
+ + + + `; + container.appendChild(div); +} + +function addCritRewardRow(actionId) { + const container = document.getElementById(`crit_item_rewards_${actionId}`); + const div = document.createElement('div'); + div.className = 'reward-item'; + div.style.display = 'flex'; + div.style.gap = '10px'; + div.style.marginBottom = '5px'; + div.style.alignItems = 'center'; + div.style.position = 'relative'; + + div.innerHTML = ` +
+ + + +
+ + + + `; + container.appendChild(div); +} + +// Autocomplete functions for item selection +function showItemDropdown(input) { + filterItemDropdown(input); +} + +function filterItemDropdown(input) { + const searchText = input.value.toLowerCase(); + const dropdown = input.parentElement.querySelector('.item-dropdown'); + const hiddenInput = input.parentElement.querySelector('.reward-item-id'); + + if (!dropdown) return; + + // Filter items + const filteredItems = availableItems.filter(item => { + const itemText = `${item.emoji || ''} ${item.name || ''} ${item.id}`.toLowerCase(); + return itemText.includes(searchText); + }); + + // Build dropdown HTML + dropdown.innerHTML = ''; + dropdown.style.display = 'block'; + + if (filteredItems.length === 0) { + dropdown.innerHTML = '
No items found
'; + return; + } + + // Create elements dynamically with proper event listeners + filteredItems.forEach(item => { + const optionDiv = document.createElement('div'); + optionDiv.className = 'dropdown-item'; + optionDiv.style.padding = '8px'; + optionDiv.style.cursor = 'pointer'; + optionDiv.style.borderBottom = '1px solid #2a2a4a'; + + optionDiv.innerHTML = ` + ${item.emoji || '๐Ÿ“ฆ'} ${item.name || item.id} + ${item.id} + `; + + // Add hover effects + optionDiv.addEventListener('mouseover', function() { + this.style.background = '#2a2a4a'; + }); + optionDiv.addEventListener('mouseout', function() { + this.style.background = 'transparent'; + }); + + // Add click handler + optionDiv.addEventListener('click', function(e) { + e.stopPropagation(); + // Set the visible input to show emoji + name + input.value = `${item.emoji || '๐Ÿ“ฆ'} ${item.name || item.id}`; + // Set the hidden input to store the item_id + hiddenInput.value = item.id; + dropdown.style.display = 'none'; + }); + + dropdown.appendChild(optionDiv); + }); +} + +function selectItem(element) { + const itemId = element.dataset.id; + const itemName = element.dataset.name; + const container = element.closest('.reward-item'); + const searchInput = container.querySelector('.reward-item-search'); + const hiddenInput = container.querySelector('.reward-item-id'); + const dropdown = container.querySelector('.item-dropdown'); + + searchInput.value = itemName; + hiddenInput.value = itemId; + dropdown.style.display = 'none'; +} + +// Close dropdowns when clicking outside +document.addEventListener('click', (e) => { + if (!e.target.closest('.reward-item')) { + document.querySelectorAll('.item-dropdown').forEach(dropdown => { + dropdown.style.display = 'none'; + }); + } +}); + +function saveInteractableInstance() { + if (!currentEditingInteractableInstanceId) return; + + // Find the instance element + const items = document.querySelectorAll('.spawn-item'); + let instanceElement = null; + items.forEach(item => { + if (item.dataset.instanceId === currentEditingInteractableInstanceId) { + instanceElement = item; + } + }); + + if (!instanceElement) return; + + const instanceData = JSON.parse(instanceElement.dataset.instanceData); + const template = availableInteractables.find(i => i.id === instanceData.template_id); + + if (!template) return; + + // Collect all outcomes + for (const [actionId, action] of Object.entries(template.actions || {})) { + const successRate = parseFloat(document.getElementById(`success_rate_${actionId}`).value); + const staminaCost = parseInt(document.getElementById(`stamina_cost_${actionId}`).value); + const successText = document.getElementById(`success_text_${actionId}`).value; + const failureText = document.getElementById(`failure_text_${actionId}`).value; + const critSuccessText = document.getElementById(`crit_success_text_${actionId}`).value; + const critFailureText = document.getElementById(`crit_failure_text_${actionId}`).value; + const critSuccessChance = parseFloat(document.getElementById(`crit_success_chance_${actionId}`).value); + const critFailureChance = parseFloat(document.getElementById(`crit_failure_chance_${actionId}`).value); + const damage = parseInt(document.getElementById(`damage_${actionId}`).value); + const critDamage = parseInt(document.getElementById(`crit_damage_${actionId}`).value); + + // Collect item rewards + const rewardContainer = document.getElementById(`item_rewards_${actionId}`); + const rewardItems = rewardContainer.querySelectorAll('.reward-item'); + const items = []; + + rewardItems.forEach(rewardItem => { + const itemId = rewardItem.querySelector('.reward-item-id').value; + const quantity = parseInt(rewardItem.querySelector('.reward-quantity').value); + const chance = parseFloat(rewardItem.querySelector('.reward-chance').value); + + if (itemId) { + items.push({ + item_id: itemId, + quantity: quantity || 1, + chance: chance || 1 + }); + } + }); + + // Collect critical success item rewards + const critRewardContainer = document.getElementById(`crit_item_rewards_${actionId}`); + const critRewardItems = critRewardContainer.querySelectorAll('.reward-item'); + const critItems = []; + + critRewardItems.forEach(rewardItem => { + const itemId = rewardItem.querySelector('.reward-item-id').value; + const quantity = parseInt(rewardItem.querySelector('.reward-quantity').value); + const chance = parseFloat(rewardItem.querySelector('.reward-chance').value); + + if (itemId) { + critItems.push({ + item_id: itemId, + quantity: quantity || 1, + chance: chance || 1 + }); + } + }); + + instanceData.outcomes[actionId] = { + success_rate: successRate, + stamina_cost: staminaCost, + crit_success_chance: critSuccessChance, + crit_failure_chance: critFailureChance, + text: { + success: successText, + failure: failureText, + crit_success: critSuccessText, + crit_failure: critFailureText + }, + rewards: { + items: items, + damage: damage, + crit_items: critItems, + crit_damage: critDamage + } + }; + } + + // Update the stored data + instanceElement.dataset.instanceData = JSON.stringify(instanceData); + + closeEditInteractableInstanceModal(); + showSuccess('Interactable instance updated!'); +} + +function getInteractableInstances() { + const instances = {}; + const items = document.querySelectorAll('#interactablesList .spawn-item'); + + items.forEach(item => { + const instanceId = item.dataset.instanceId; + const instanceData = JSON.parse(item.dataset.instanceData); + instances[instanceId] = instanceData; + }); + + return instances; +} + +function renderInteractablesList(interactables) { + console.log('renderInteractablesList called with:', interactables); + console.log('availableInteractables:', availableInteractables); + + const list = document.getElementById('interactablesList'); + if (!list) { + console.error('interactablesList element not found!'); + return; + } + + list.innerHTML = ''; + + if (!interactables || Object.keys(interactables).length === 0) { + console.log('No interactables to render'); + return; + } + + console.log('Rendering', Object.keys(interactables).length, 'interactables'); + + Object.entries(interactables).forEach(([instanceId, instance]) => { + // Support both old format (template_id) and new format (id) + const templateId = instance.template_id || instance.id; + let displayName = instance.name || 'Unknown'; + let actionCount = 0; + + console.log(`Processing instance ${instanceId}, template_id: ${templateId}`); + + // Try to find template in available interactables + const template = availableInteractables.find(i => i.id === templateId); + if (template) { + displayName = template.name; + actionCount = Object.keys(template.actions || {}).length; + console.log(`Found template: ${displayName} with ${actionCount} actions`); + } else { + console.log(`Template ${templateId} not found in availableInteractables`); + // For new format instances that have full data + if (instance.name) { + displayName = instance.name; + } + if (instance.actions) { + actionCount = Object.keys(instance.actions).length; + } else if (instance.outcomes) { + actionCount = Object.keys(instance.outcomes).length; + } + } + + const item = document.createElement('div'); + item.className = 'spawn-item'; + item.dataset.instanceId = instanceId; + item.dataset.instanceData = JSON.stringify(instance); + item.innerHTML = ` +
+
${displayName}
+
${actionCount} action(s)
+
+
+ + +
+ `; + + list.appendChild(item); + console.log(`Appended item for ${instanceId}`); + }); + + console.log('Finished rendering interactables'); +} + +// ==================== IMAGE UPLOAD ==================== + +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 uploadNPCImage() { + const fileInput = document.getElementById('npcImageUpload'); + 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('npcImageUrl').value = data.image_path; + updateNPCImagePreview(data.image_path); + showSuccess(data.message); + } else { + alert('Upload failed: ' + data.error); + } + } catch (error) { + alert('Upload failed: ' + error.message); + } +} + +function updateNPCImagePreview(imagePath) { + const preview = document.getElementById('npcImagePreview'); + if (imagePath && imagePath.trim() !== '') { + preview.innerHTML = `NPC image`; + } else { + preview.innerHTML = 'No image'; + } +} + +async function uploadInteractableImage() { + const fileInput = document.getElementById('interactableImageUpload'); + 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('interactableImagePath').value = data.image_path; + updateInteractableImagePreview(data.image_path); + showSuccess(data.message); + } else { + alert('Upload failed: ' + data.error); + } + } catch (error) { + alert('Upload failed: ' + error.message); + } +} + +// ==================== PYTHON EXPORT ==================== + +async function exportToPython() { + try { + const response = await fetch('/api/editor/export-python'); + const data = await response.json(); + + if (data.success) { + // Create a downloadable file + const blob = new Blob([data.python_code], {type: 'text/plain'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'world_loader_updates.py'; + a.click(); + URL.revokeObjectURL(url); + + showSuccess('Python code exported! Review before applying to world_loader.py'); + } else { + alert('Export failed: ' + data.error); + } + } catch (error) { + alert('Export failed: ' + error.message); + } +} + +// ==================== UTILITY ==================== + +async function logout() { + try { + await fetch('/api/logout', {method: 'POST'}); + if (liveStatsInterval) { + clearInterval(liveStatsInterval); + } + window.location.reload(); + } catch (error) { + console.error('Logout failed:', error); + } +} + +async function restartBot() { + if (!confirm('Are you sure you want to restart the bot? This will temporarily disconnect active users.')) { + return; + } + + try { + const response = await fetch('/api/editor/restart-bot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + + if (response.ok) { + showSuccess('โœ… Bot restarted successfully! Changes will be loaded.'); + } else { + showError(`Failed to restart bot: ${data.error || 'Unknown error'}`); + } + } catch (error) { + console.error('Restart failed:', error); + showError('Failed to restart bot. Check console for details.'); + } +} + +async function refreshBotLogs() { + try { + const lineCount = document.getElementById('logsLineCount').value || 100; + const logsViewer = document.getElementById('logsViewer'); + + logsViewer.innerHTML = '
Loading logs...
'; + + const response = await fetch(`/api/editor/bot-logs?lines=${lineCount}`); + const data = await response.json(); + + if (response.ok && data.success) { + displayLogs(data.logs); + } else { + logsViewer.innerHTML = `
Failed to load logs: ${data.error || 'Unknown error'}
`; + } + } catch (error) { + console.error('Failed to fetch logs:', error); + document.getElementById('logsViewer').innerHTML = '
Failed to fetch logs. Check console for details.
'; + } +} + +function displayLogs(logsText) { + const logsViewer = document.getElementById('logsViewer'); + + if (!logsText || logsText.trim() === '') { + logsViewer.innerHTML = '
No logs available
'; + return; + } + + const lines = logsText.split('\n'); + let html = ''; + + lines.forEach(line => { + if (!line.trim()) return; + + let className = 'log-line'; + + // Color code based on log level + if (line.includes('ERROR') || line.includes('Error') || line.includes('error')) { + className += ' log-error'; + } else if (line.includes('WARNING') || line.includes('Warning') || line.includes('warning')) { + className += ' log-warning'; + } else if (line.includes('INFO') || line.includes('Info')) { + className += ' log-info'; + } else if (line.includes('โœ…') || line.includes('SUCCESS') || line.includes('Success')) { + className += ' log-success'; + } + + // Escape HTML to prevent XSS + const escapedLine = line + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + html += `
${escapedLine}
`; + }); + + logsViewer.innerHTML = html; + + // Auto-scroll to bottom + logsViewer.scrollTop = logsViewer.scrollHeight; +} + +function clearLogsDisplay() { + document.getElementById('logsViewer').innerHTML = '
Logs cleared. Click "Refresh" to reload.
'; +} + +// ==================== TAB SWITCHING ==================== + +function switchTab(tabName) { + currentTab = tabName; + + // Update tab buttons + document.querySelectorAll('.tab-button').forEach(btn => { + if (btn.textContent.toLowerCase().includes(tabName)) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); + + // Update tab content - ensure all are hidden first + document.querySelectorAll('.tab-content').forEach(content => { + content.classList.remove('active'); + content.style.display = 'none'; + }); + + // Show only the selected tab + const activeTab = document.getElementById(`tab-${tabName}`); + if (activeTab) { + activeTab.classList.add('active'); + activeTab.style.display = (tabName === 'locations') ? 'grid' : 'flex'; + } + + // Load data for the tab + if (tabName === 'npcs') { + loadNPCManagement(); + } else if (tabName === 'items') { + loadItemManagement(); + } else if (tabName === 'interactables') { + loadInteractableManagement(); + } else if (tabName === 'logs') { + // Auto-load logs when switching to logs tab + refreshBotLogs(); + } else if (tabName === 'locations') { + // Load interactables for the locations tab (needed for adding instances) + loadAvailableInteractablesForLocations(); + // Force canvas resize when switching to locations tab + setTimeout(() => { + resizeCanvas(); + }, 10); + } +} + +async function loadAvailableInteractablesForLocations() { + try { + const response = await fetch('/api/editor/interactables'); + const data = await response.json(); + + console.log('Interactables API response:', data); + + if (data.interactables) { + // Check if it's an array or object + if (Array.isArray(data.interactables)) { + availableInteractables = data.interactables; + } else { + // If it's an object, convert to array + availableInteractables = Object.entries(data.interactables).map(([id, interactable]) => ({ + id, + ...interactable + })); + } + console.log('Loaded interactables:', availableInteractables.length); + } else { + console.error('No interactables in response'); + availableInteractables = []; + } + } catch (error) { + console.error('Failed to load interactables:', error); + availableInteractables = []; + } +} + +async function loadAvailableItemsForLocations() { + try { + const response = await fetch('/api/editor/items'); + const data = await response.json(); + availableItems = data.items || []; + } catch (error) { + console.error('Failed to load items:', error); + } +} + +function saveCurrentItem() { + if (currentTab === 'locations') { + saveCurrentLocation(); + } else if (currentTab === 'npcs') { + saveCurrentNPC(); + } else if (currentTab === 'items') { + saveCurrentItem_(); + } else if (currentTab === 'interactables') { + saveCurrentInteractable(); + } +} + +// ==================== NPC MANAGEMENT ==================== + +async function loadNPCManagement() { + try { + const response = await fetch('/api/editor/npcs', { + credentials: 'same-origin' + }); + const data = await response.json(); + availableNPCs = data.npcs || []; + renderNPCManagementList(); + } catch (error) { + console.error('Failed to load NPCs:', error); + } +} + +function renderNPCManagementList() { + const list = document.getElementById('npcManagementList'); + list.innerHTML = ''; + + availableNPCs.forEach(npc => { + const item = document.createElement('div'); + item.className = 'management-item'; + item.dataset.name = npc.name; + item.dataset.id = npc.id; + if (npc.id === selectedNPCId) { + item.classList.add('active'); + } + + item.innerHTML = ` +
${npc.emoji} ${npc.name}
+
+ HP: ${npc.hp_min}-${npc.hp_max} | DMG: ${npc.damage_min}-${npc.damage_max} +
+ `; + + item.onclick = () => selectNPC(npc.id); + list.appendChild(item); + }); +} + +function filterNPCManagementList() { + const searchTerm = document.getElementById('npcManagementSearch').value.toLowerCase(); + const items = document.querySelectorAll('#npcManagementList .management-item'); + + items.forEach(item => { + const name = item.dataset.name?.toLowerCase() || ''; + const id = item.dataset.id?.toLowerCase() || ''; + item.style.display = (!searchTerm || name.includes(searchTerm) || id.includes(searchTerm)) ? '' : 'none'; + }); +} + +function selectNPC(npcId) { + selectedNPCId = npcId; + renderNPCManagementList(); + + const npc = availableNPCs.find(n => n.id === npcId); + if (!npc) return; + + const editor = document.getElementById('npcEditor'); + editor.innerHTML = ` +

Edit NPC: ${npc.name}

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+
+ ${npc.image_url ? `NPC image` : 'No image'} +
+
+ +

Loot Table

+
+ + +

Corpse Loot

+
+ + +
+ +
+ `; + + renderNPCLootTable(npc.loot_table || []); + renderNPCCorpseLoot(npc.corpse_loot || []); +} + +function renderNPCLootTable(lootTable) { + const container = document.getElementById('npcLootTable'); + container.innerHTML = ''; + + lootTable.forEach((loot, index) => { + // Handle both old and new formats for quantities + let quantityMin = loot.quantity_min || 1; + let quantityMax = loot.quantity_max || 1; + + // Parse quantity_range if it exists (old format) + if (Array.isArray(loot.quantity_range)) { + quantityMin = loot.quantity_range[0] || 1; + quantityMax = loot.quantity_range[1] || 1; + } else if (typeof loot.quantity_range === 'string') { + try { + const parsed = JSON.parse(loot.quantity_range); + quantityMin = parsed[0] || 1; + quantityMax = parsed[1] || 1; + } catch (e) { + quantityMin = 1; + quantityMax = 1; + } + } + + const item = document.createElement('div'); + item.className = 'array-item'; + item.innerHTML = ` + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ `; + container.appendChild(item); + }); +} + +function renderNPCCorpseLoot(corpseLoot) { + const container = document.getElementById('npcCorpseLoot'); + container.innerHTML = ''; + + corpseLoot.forEach((loot, index) => { + // Handle both old and new formats for quantities + let quantityMin = loot.quantity_min || 1; + let quantityMax = loot.quantity_max || 1; + + const item = document.createElement('div'); + item.className = 'array-item'; + item.innerHTML = ` + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ `; + container.appendChild(item); + }); +} + +function createNewNPC() { + const npcId = prompt('Enter NPC ID (e.g., zombie, raider):'); + if (!npcId) return; + + const newNPC = { + id: npcId, + name: 'New NPC', + emoji: '๐Ÿ‘น', + hp_min: 10, + hp_max: 20, + damage_min: 1, + damage_max: 5, + xp_reward: 10, + defense: 0, + description: '', + image_url: '', + loot_table: [], + corpse_loot: [] + }; + + availableNPCs.push(newNPC); + renderNPCManagementList(); + selectNPC(npcId); +} + +async function saveCurrentNPC() { + if (!selectedNPCId) { + alert('No NPC selected'); + return; + } + + const npcData = { + id: document.getElementById('npcId').value, + name: document.getElementById('npcName').value, + emoji: document.getElementById('npcEmoji').value, + hp_min: parseInt(document.getElementById('npcHpMin').value), + hp_max: parseInt(document.getElementById('npcHpMax').value), + damage_min: parseInt(document.getElementById('npcDamageMin').value), + damage_max: parseInt(document.getElementById('npcDamageMax').value), + xp_reward: parseInt(document.getElementById('npcXp').value), + defense: parseInt(document.getElementById('npcDefense').value), + description: document.getElementById('npcDescription').value, + image_url: document.getElementById('npcImageUrl').value, + loot_table: getNPCLootTable(), + corpse_loot: getNPCCorpseLoot() + }; + + try { + const response = await fetch('/api/editor/npc', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + credentials: 'same-origin', + body: JSON.stringify(npcData) + }); + + const data = await response.json(); + + if (data.success) { + showSuccess('NPC saved successfully!'); + await loadNPCManagement(); + } else { + alert('Failed to save: ' + data.error); + } + } catch (error) { + alert('Failed to save NPC: ' + error.message); + } +} + +function getNPCLootTable() { + const container = document.getElementById('npcLootTable'); + const items = container.querySelectorAll('.array-item'); + const lootTable = []; + + items.forEach((item, index) => { + const itemId = document.getElementById(`lootItem${index}`).value; + const quantityMin = parseInt(document.getElementById(`lootQuantityMin${index}`).value); + const quantityMax = parseInt(document.getElementById(`lootQuantityMax${index}`).value); + const chance = parseFloat(document.getElementById(`lootChance${index}`).value); + + if (itemId) { + lootTable.push({ + item_id: itemId, + quantity_min: quantityMin, + quantity_max: quantityMax, + drop_chance: chance + }); + } + }); + + return lootTable; +} + +function getNPCCorpseLoot() { + const container = document.getElementById('npcCorpseLoot'); + const items = container.querySelectorAll('.array-item'); + const corpseLoot = []; + + items.forEach((item, index) => { + const itemId = document.getElementById(`corpseItem${index}`).value; + const quantityMin = parseInt(document.getElementById(`corpseQuantityMin${index}`).value); + const quantityMax = parseInt(document.getElementById(`corpseQuantityMax${index}`).value); + const requiredTool = document.getElementById(`corpseRequiredTool${index}`).value || null; + + if (itemId) { + corpseLoot.push({ + item_id: itemId, + quantity_min: quantityMin, + quantity_max: quantityMax, + required_tool: requiredTool + }); + } + }); + + return corpseLoot; +} + +// Item dropdown functions for NPC loot tables +function showNPCLootItemDropdown(index) { + filterNPCLootItemDropdown(index); +} + +function filterNPCLootItemDropdown(index) { + const input = document.getElementById(`lootItem${index}`); + const dropdown = document.getElementById(`lootItemDropdown${index}`); + const searchTerm = input.value.toLowerCase(); + + dropdown.innerHTML = ''; + dropdown.style.display = 'block'; + + const filtered = availableItems.filter(item => + item.id.toLowerCase().includes(searchTerm) || + (item.name && item.name.toLowerCase().includes(searchTerm)) || + (item.emoji && item.emoji.includes(searchTerm)) + ); + + if (filtered.length === 0) { + dropdown.innerHTML = '
No items found
'; + return; + } + + filtered.forEach(item => { + const option = document.createElement('div'); + option.className = 'dropdown-item'; + option.style.padding = '8px'; + option.style.cursor = 'pointer'; + option.style.borderBottom = '1px solid #2a2a4a'; + option.innerHTML = `${item.emoji || '๐Ÿ“ฆ'} ${item.name || item.id} (${item.id})`; + + // Add hover effects + option.addEventListener('mouseover', function() { + this.style.background = '#2a2a4a'; + }); + option.addEventListener('mouseout', function() { + this.style.background = 'transparent'; + }); + + option.addEventListener('click', () => { + input.value = item.id; + dropdown.style.display = 'none'; + }); + dropdown.appendChild(option); + }); +} + +function showNPCCorpseItemDropdown(index) { + filterNPCCorpseItemDropdown(index); +} + +function filterNPCCorpseItemDropdown(index) { + const input = document.getElementById(`corpseItem${index}`); + const dropdown = document.getElementById(`corpseItemDropdown${index}`); + const searchTerm = input.value.toLowerCase(); + + dropdown.innerHTML = ''; + dropdown.style.display = 'block'; + + const filtered = availableItems.filter(item => + item.id.toLowerCase().includes(searchTerm) || + (item.name && item.name.toLowerCase().includes(searchTerm)) || + (item.emoji && item.emoji.includes(searchTerm)) + ); + + if (filtered.length === 0) { + dropdown.innerHTML = '
No items found
'; + return; + } + + filtered.forEach(item => { + const option = document.createElement('div'); + option.className = 'dropdown-item'; + option.style.padding = '8px'; + option.style.cursor = 'pointer'; + option.style.borderBottom = '1px solid #2a2a4a'; + option.innerHTML = `${item.emoji || '๐Ÿ“ฆ'} ${item.name || item.id} (${item.id})`; + + // Add hover effects + option.addEventListener('mouseover', function() { + this.style.background = '#2a2a4a'; + }); + option.addEventListener('mouseout', function() { + this.style.background = 'transparent'; + }); + + option.addEventListener('click', () => { + input.value = item.id; + dropdown.style.display = 'none'; + }); + dropdown.appendChild(option); + }); +} + +// Hide dropdowns when clicking outside +document.addEventListener('click', function(e) { + if (!e.target.matches('[id^="lootItem"], [id^="corpseItem"]')) { + document.querySelectorAll('.item-dropdown').forEach(dropdown => { + dropdown.style.display = 'none'; + }); + } +}); + +function addNPCLoot() { + const npc = availableNPCs.find(n => n.id === selectedNPCId); + if (!npc) return; + + if (!npc.loot_table) npc.loot_table = []; + npc.loot_table.push({item_id: '', quantity_range: [1, 1], drop_chance: 0.5}); + renderNPCLootTable(npc.loot_table); +} + +function removeNPCLoot(index) { + const npc = availableNPCs.find(n => n.id === selectedNPCId); + if (!npc || !npc.loot_table) return; + + npc.loot_table.splice(index, 1); + renderNPCLootTable(npc.loot_table); +} + +function addNPCCorpseLoot() { + const npc = availableNPCs.find(n => n.id === selectedNPCId); + if (!npc) return; + + if (!npc.corpse_loot) npc.corpse_loot = []; + npc.corpse_loot.push({item_id: '', quantity: 1}); + renderNPCCorpseLoot(npc.corpse_loot); +} + +function removeNPCCorpseLoot(index) { + const npc = availableNPCs.find(n => n.id === selectedNPCId); + if (!npc || !npc.corpse_loot) return; + + npc.corpse_loot.splice(index, 1); + renderNPCCorpseLoot(npc.corpse_loot); +} + +async function deleteCurrentNPC() { + if (!selectedNPCId) return; + + if (!confirm(`Delete NPC "${selectedNPCId}"? This cannot be undone.`)) { + return; + } + + try { + const response = await fetch(`/api/editor/npc/${selectedNPCId}`, { + method: 'DELETE', + credentials: 'same-origin' + }); + + const data = await response.json(); + + if (data.success) { + showSuccess('NPC deleted successfully!'); + selectedNPCId = null; + document.getElementById('npcEditor').innerHTML = '

Select an NPC or create a new one

'; + await loadNPCManagement(); + } else { + alert('Failed to delete: ' + data.error); + } + } catch (error) { + alert('Failed to delete NPC: ' + error.message); + } +} + +// ==================== ITEM MANAGEMENT ==================== + +async function loadItemManagement() { + try { + const response = await fetch('/api/editor/items', { + credentials: 'same-origin' + }); + const data = await response.json(); + availableItems = data.items || []; + renderItemManagementList(); + } catch (error) { + console.error('Failed to load items:', error); + } +} + +function renderItemManagementList() { + const list = document.getElementById('itemManagementList'); + list.innerHTML = ''; + + availableItems.forEach(item => { + const elem = document.createElement('div'); + elem.className = 'management-item'; + elem.dataset.name = item.name; + elem.dataset.id = item.id; + if (item.id === selectedItemId) { + elem.classList.add('active'); + } + + elem.innerHTML = ` +
${item.emoji ? item.emoji + ' ' : ''}${item.name}
+
+ Type: ${item.type} | Weight: ${item.weight}kg +
+ `; + + elem.onclick = () => selectItem(item.id); + list.appendChild(elem); + }); +} + +function filterItemManagementList() { + const searchTerm = document.getElementById('itemManagementSearch').value.toLowerCase(); + const items = document.querySelectorAll('#itemManagementList .management-item'); + + items.forEach(item => { + const name = item.dataset.name?.toLowerCase() || ''; + const id = item.dataset.id?.toLowerCase() || ''; + item.style.display = (!searchTerm || name.includes(searchTerm) || id.includes(searchTerm)) ? '' : 'none'; + }); +} + +function selectItem(itemId) { + selectedItemId = itemId; + renderItemManagementList(); + + const item = availableItems.find(i => i.id === itemId); + if (!item) return; + + const editor = document.getElementById('itemEditor'); + editor.innerHTML = ` +

Edit Item: ${item.name}

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +

Properties

+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ +
+ +
+ `; +} + +function createNewItem() { + const itemId = prompt('Enter Item ID (e.g., health_potion, iron_sword):'); + if (!itemId) return; + + const newItem = { + id: itemId, + name: 'New Item', + emoji: '', + type: 'resource', + weight: 0.1, + volume: 0.1, + description: '', + stackable: true, + hp_restore: 0, + stamina_restore: 0, + damage: 0, + defense: 0, + capacity_weight: 0, + capacity_volume: 0 + }; + + availableItems.push(newItem); + renderItemManagementList(); + selectItem(itemId); +} + +async function saveCurrentItem_() { + if (!selectedItemId) { + alert('No item selected'); + return; + } + + const itemData = { + id: document.getElementById('itemId').value, + name: document.getElementById('itemName').value, + emoji: document.getElementById('itemEmoji').value, + type: document.getElementById('itemType').value, + weight: parseFloat(document.getElementById('itemWeight').value), + volume: parseFloat(document.getElementById('itemVolume').value), + description: document.getElementById('itemDescription').value, + stackable: document.getElementById('itemStackable').value === 'true', + hp_restore: parseInt(document.getElementById('itemHpRestore').value) || 0, + stamina_restore: parseInt(document.getElementById('itemStaminaRestore').value) || 0, + damage: parseInt(document.getElementById('itemDamage').value) || 0, + defense: parseInt(document.getElementById('itemDefense').value) || 0, + capacity_weight: parseInt(document.getElementById('itemCapacityWeight').value) || 0, + capacity_volume: parseInt(document.getElementById('itemCapacityVolume').value) || 0 + }; + + try { + const response = await fetch('/api/editor/item', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + credentials: 'same-origin', + body: JSON.stringify(itemData) + }); + + const data = await response.json(); + + if (data.success) { + showSuccess('Item saved successfully!'); + await loadItemManagement(); + } else { + alert('Failed to save: ' + data.error); + } + } catch (error) { + alert('Failed to save item: ' + error.message); + } +} + +async function deleteCurrentItem() { + if (!selectedItemId) return; + + if (!confirm(`Delete item "${selectedItemId}"? This cannot be undone.`)) { + return; + } + + try { + const response = await fetch(`/api/editor/item/${selectedItemId}`, { + method: 'DELETE', + credentials: 'same-origin' + }); + + const data = await response.json(); + + if (data.success) { + showSuccess('Item deleted successfully!'); + selectedItemId = null; + document.getElementById('itemEditor').innerHTML = '

Select an item or create a new one

'; + await loadItemManagement(); + } else { + alert('Failed to delete: ' + data.error); + } + } catch (error) { + alert('Failed to delete item: ' + error.message); + } +} + +// ==================== INTERACTABLES MANAGEMENT ==================== + +async function loadInteractableManagement() { + try { + const response = await fetch('/api/editor/interactables', { + credentials: 'same-origin' + }); + const data = await response.json(); + availableInteractables = data.interactables; + renderInteractableManagementList(); + } catch (error) { + console.error('Failed to load interactables:', error); + } +} + +function renderInteractableManagementList() { + const list = document.getElementById('interactableManagementList'); + list.innerHTML = ''; + + availableInteractables.forEach(inter => { + const elem = document.createElement('div'); + elem.className = 'management-item'; + elem.dataset.name = inter.name; + elem.dataset.id = inter.id; + if (inter.id === selectedInteractableId) { + elem.classList.add('active'); + } + + const actionCount = Object.keys(inter.actions || {}).length; + elem.innerHTML = ` +
${inter.name}
+
+ Actions: ${actionCount} +
+ `; + + elem.onclick = () => selectInteractable(inter.id); + list.appendChild(elem); + }); +} + +function filterInteractableManagementList() { + const searchTerm = document.getElementById('interactableManagementSearch').value.toLowerCase(); + const items = document.querySelectorAll('#interactableManagementList .management-item'); + + items.forEach(item => { + const name = item.dataset.name?.toLowerCase() || ''; + const id = item.dataset.id?.toLowerCase() || ''; + item.style.display = (!searchTerm || name.includes(searchTerm) || id.includes(searchTerm)) ? '' : 'none'; + }); +} + +function selectInteractable(interId) { + selectedInteractableId = interId; + renderInteractableManagementList(); + + const inter = availableInteractables.find(i => i.id === interId); + if (!inter) return; + + const editor = document.getElementById('interactableEditor'); + + // Render actions + let actionsHtml = ''; + for (const [actionId, action] of Object.entries(inter.actions || {})) { + actionsHtml += ` +
+ +

${action.label}

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ `; + } + + editor.innerHTML = ` +

Edit Interactable: ${inter.name}

+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+
+ ${inter.image_path ? `Interactable image` : 'No image'} +
+
+ +

Actions

+
+ ${actionsHtml} +
+ + +
+ +
+ `; +} + +function createNewInteractable() { + const interId = prompt('Enter Interactable ID (e.g., rubble, dumpster):'); + if (!interId) return; + + const newInteractable = { + id: interId, + name: 'New Interactable', + description: '', + image_path: '', + actions: { + 'search': { + id: 'search', + label: '๐Ÿ”Ž Search', + stamina_cost: 2 + } + } + }; + + availableInteractables.push(newInteractable); + renderInteractableManagementList(); + selectInteractable(interId); +} + +async function saveCurrentInteractable() { + if (!selectedInteractableId) { + alert('No interactable selected'); + return; + } + + // Get basic fields + const interData = { + id: document.getElementById('interactableId').value, + name: document.getElementById('interactableName').value, + description: document.getElementById('interactableDescription').value, + image_path: document.getElementById('interactableImagePath').value, + actions: {} + }; + + // Get actions + const inter = availableInteractables.find(i => i.id === selectedInteractableId); + if (inter && inter.actions) { + for (const [actionId, action] of Object.entries(inter.actions)) { + const labelInput = document.querySelector(`.action-label[data-action="${actionId}"]`); + const staminaInput = document.querySelector(`.action-stamina[data-action="${actionId}"]`); + + interData.actions[actionId] = { + id: actionId, + label: labelInput ? labelInput.value : action.label, + stamina_cost: staminaInput ? parseInt(staminaInput.value) : action.stamina_cost + }; + } + } + + try { + const response = await fetch('/api/editor/interactable', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + credentials: 'same-origin', + body: JSON.stringify(interData) + }); + + const data = await response.json(); + + if (data.success) { + showSuccess('Interactable saved successfully!'); + await loadInteractableManagement(); + } else { + alert('Failed to save: ' + data.error); + } + } catch (error) { + alert('Failed to save interactable: ' + error.message); + } +} + +async function deleteCurrentInteractable() { + if (!selectedInteractableId) { + alert('No interactable selected'); + return; + } + + if (!confirm(`Delete interactable "${selectedInteractableId}"?`)) { + return; + } + + try { + const response = await fetch(`/api/editor/interactable/${selectedInteractableId}`, { + method: 'DELETE', + credentials: 'same-origin' + }); + + const data = await response.json(); + + if (data.success) { + showSuccess('Interactable deleted successfully!'); + selectedInteractableId = null; + document.getElementById('interactableEditor').innerHTML = ` +

Select an interactable to edit

+

Choose from the list or create a new one.

+ `; + await loadInteractableManagement(); + } else { + alert('Failed to delete: ' + data.error); + } + } catch (error) { + alert('Failed to delete interactable: ' + error.message); + } +} + +function updateInteractableImagePreview(imagePath) { + const preview = document.getElementById('interactableImagePreview'); + if (imagePath && imagePath.trim()) { + preview.innerHTML = `Interactable image`; + } else { + preview.innerHTML = 'No image'; + } +} + +function addNewInteractableAction() { + const actionId = prompt('Enter Action ID (e.g., search, break, open):'); + if (!actionId) return; + + const inter = availableInteractables.find(i => i.id === selectedInteractableId); + if (!inter) return; + + // Check if action already exists + if (inter.actions && inter.actions[actionId]) { + alert('Action with this ID already exists!'); + return; + } + + // Add new action + if (!inter.actions) { + inter.actions = {}; + } + + inter.actions[actionId] = { + id: actionId, + label: actionId.charAt(0).toUpperCase() + actionId.slice(1), + stamina_cost: 2 + }; + + // Re-render the editor + selectInteractable(selectedInteractableId); +} + +function deleteInteractableAction(actionId) { + if (!confirm(`Delete action "${actionId}"?`)) { + return; + } + + const inter = availableInteractables.find(i => i.id === selectedInteractableId); + if (!inter || !inter.actions) return; + + delete inter.actions[actionId]; + + // Re-render the editor + selectInteractable(selectedInteractableId); +} diff --git a/web-map/index.html b/web-map/index.html new file mode 100644 index 0000000..3a8125b --- /dev/null +++ b/web-map/index.html @@ -0,0 +1,869 @@ + + + + + + Echoes of the Ashes - Interactive World Map + + + +
+
+
+

๐Ÿ—บ๏ธ Interactive World Map

+

Echoes of the Ashes

+ + ๐Ÿค– + Play on Telegram + +
+
+ +
+ + + + +
+
+
+ +
+
+

๐Ÿ“ Location Details

+
+

Click on a location to see details

+
+
+ +
+

๐ŸŽฏ Interactables

+
+

Select a location to see interactables

+
+
+ +
+

โš”๏ธ Enemy Encounters

+
+

Select a location to see possible enemies

+
+
+ +
+

๐Ÿ“Š Map Statistics

+
+
+
+ - + Locations +
+
+ - + Routes +
+
+ - + Interactables +
+
+ - + Enemy Types +
+
+
+
+ +
+

๐Ÿ—บ๏ธ Legend

+
+
+
+ Safe Zone +
+
+
+ Low Danger +
+
+
+ Medium Danger +
+
+
+ High Danger +
+
+
+
+
+ + +
+ × + Full size image +
Click anywhere to close
+
+ + + + diff --git a/web-map/map.js b/web-map/map.js new file mode 100644 index 0000000..56359f3 --- /dev/null +++ b/web-map/map.js @@ -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 ? + `` : + `
๐Ÿ—บ๏ธ
` + } +

${location.name}

+
${location.description}
+

Coordinates: (${location.x}, ${location.y})

+

Interactables: ${location.interactable_count}

+

๐Ÿงญ 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 `
+ ${c.direction.toUpperCase()}
+ ${dest ? dest.name : c.to}
+ ${stamina}โšก stamina +
`; + }) + .join('') + } +
+ `; + 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 = ` +
+ ${locationInteractables.map(inter => ` +
+
+
+ ${inter.image_path ? + `` : + `
๐Ÿ“ฆ
` + } +
+
${inter.name}
+
+ ${inter.actions.map(action => ` +
+
${action.label} (${action.stamina_cost}โšก)
+ ${action.outcomes.map(outcome => ` +
+ ${outcome.type}: ${outcome.text} + ${Object.keys(outcome.items).length > 0 ? + `
Items: ${Object.entries(outcome.items).map(([id, qty]) => `${id} x${qty}`).join(', ')}` : ''} + ${outcome.damage > 0 ? `
โš ๏ธ Damage: ${outcome.damage} HP` : ''} +
+ `).join('')} +
+ `).join('')} +
+ `).join('')} +
+ `; + 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 = '

No interactables at this location

'; + } + + // Enemies + const enemies = mapData.spawn_tables[location.id]; + if (enemies && enemies.length > 0) { + const enemiesHTML = ` +

Encounter Rate: ${enemies[0].encounter_rate}% when traveling

+
+ ${enemies.map(enemy => ` +
+
+
+ ${enemy.image_url ? + `` : + `
${enemy.emoji}
` + } +
+
${enemy.name}
+
${enemy.spawn_chance}%
+
+
+
โค๏ธ HP: ${enemy.hp_range[0]}-${enemy.hp_range[1]}
+
โš”๏ธ DMG: ${enemy.damage_range[0]}-${enemy.damage_range[1]}
+
โญ XP: ${enemy.xp_reward}
+
๐ŸŽฒ Weight: ${enemy.spawn_weight}
+
+
+ `).join('')} +
+ `; + 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 = '

โœ… Safe zone - no enemies spawn here

'; + } +} + +// 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'; +} diff --git a/web-map/requirements.txt b/web-map/requirements.txt new file mode 100644 index 0000000..d864272 --- /dev/null +++ b/web-map/requirements.txt @@ -0,0 +1,3 @@ +flask==3.0.0 +flask-cors==4.0.0 +werkzeug==3.0.1 diff --git a/web-map/server.py b/web-map/server.py new file mode 100644 index 0000000..67f1955 --- /dev/null +++ b/web-map/server.py @@ -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) diff --git a/web-map/server_enhanced.py b/web-map/server_enhanced.py new file mode 100644 index 0000000..3a3df43 --- /dev/null +++ b/web-map/server_enhanced.py @@ -0,0 +1,1097 @@ +""" +Enhanced Map Server with Editor and Authentication +""" +from flask import Flask, jsonify, request, send_from_directory, session, redirect, url_for +from flask_cors import CORS +from pathlib import Path +import json +import os +import sys +import secrets +from werkzeug.utils import secure_filename +from functools import wraps + +# Get the directory of this script +SCRIPT_DIR = Path(__file__).parent.resolve() +PARENT_DIR = SCRIPT_DIR.parent + +# Add parent directory to path for imports +sys.path.insert(0, str(PARENT_DIR)) + +app = Flask(__name__, static_folder=str(SCRIPT_DIR)) +app.secret_key = os.environ.get('EDITOR_SECRET_KEY', secrets.token_hex(32)) +CORS(app) + +# Configuration +ADMIN_PASSWORD = os.environ.get('EDITOR_PASSWORD', 'admin123') # Change this! +UPLOAD_FOLDER = PARENT_DIR / 'images' / 'locations' +UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True) +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} + +# JSON data files +GAMEDATA_DIR = PARENT_DIR / 'gamedata' +LOCATIONS_FILE = GAMEDATA_DIR / 'locations.json' +NPCS_FILE = GAMEDATA_DIR / 'npcs.json' +ITEMS_FILE = GAMEDATA_DIR / 'items.json' +INTERACTABLES_FILE = GAMEDATA_DIR / 'interactables.json' + +def allowed_file(filename): + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +def load_json_file(filepath): + """Helper to load JSON file""" + try: + with open(filepath, 'r') as f: + return json.load(f) + except FileNotFoundError: + return None + except Exception as e: + print(f"Error loading {filepath}: {e}") + return None + +def save_json_file(filepath, data): + """Helper to save JSON file""" + try: + with open(filepath, 'w') as f: + json.dump(data, f, indent=2) + return True + except Exception as e: + print(f"Error saving {filepath}: {e}") + return False + +def require_auth(f): + """Decorator to require authentication for editor routes""" + @wraps(f) + def decorated_function(*args, **kwargs): + if not session.get('authenticated'): + return jsonify({'error': 'Unauthorized'}), 401 + return f(*args, **kwargs) + return decorated_function + +# ==================== AUTHENTICATION ROUTES ==================== + +@app.route('/api/login', methods=['POST']) +def login(): + """Authenticate user with password""" + data = request.get_json() + password = data.get('password', '') + + if password == ADMIN_PASSWORD: + session['authenticated'] = True + session.permanent = True + return jsonify({'success': True, 'message': 'Authenticated successfully'}) + else: + return jsonify({'success': False, 'message': 'Invalid password'}), 401 + +@app.route('/api/logout', methods=['POST']) +def logout(): + """Logout user""" + session.pop('authenticated', None) + return jsonify({'success': True, 'message': 'Logged out'}) + +@app.route('/api/editor/restart-bot', methods=['POST']) +@require_auth +def restart_bot(): + """Restart the Telegram bot container""" + try: + import subprocess + + # Try to restart the bot container + result = subprocess.run( + ['docker', 'restart', 'echoes_of_the_ashes_bot'], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode == 0: + return jsonify({ + 'success': True, + 'message': 'Bot restarted successfully' + }) + else: + return jsonify({ + 'success': False, + 'error': f'Docker restart failed: {result.stderr}' + }), 500 + + except subprocess.TimeoutExpired: + return jsonify({ + 'success': False, + 'error': 'Restart command timed out' + }), 500 + except FileNotFoundError: + return jsonify({ + 'success': False, + 'error': 'Docker command not found. Is Docker installed?' + }), 500 + except Exception as e: + return jsonify({ + 'success': False, + 'error': f'Unexpected error: {str(e)}' + }), 500 + +@app.route('/api/editor/bot-logs', methods=['GET']) +@require_auth +def get_bot_logs(): + """Get logs from the Telegram bot container""" + try: + import subprocess + + # Get number of lines from query parameter (default 100) + lines = request.args.get('lines', '100') + try: + lines = int(lines) + if lines < 1 or lines > 1000: + lines = 100 + except ValueError: + lines = 100 + + # Get logs from the bot container + result = subprocess.run( + ['docker', 'logs', 'echoes_of_the_ashes_bot', '--tail', str(lines)], + capture_output=True, + text=True, + timeout=10 + ) + + # Combine stdout and stderr (Docker logs can use both) + logs = result.stdout + result.stderr + + return jsonify({ + 'success': True, + 'logs': logs, + 'lines': lines + }) + + except subprocess.TimeoutExpired: + return jsonify({ + 'success': False, + 'error': 'Logs command timed out' + }), 500 + except FileNotFoundError: + return jsonify({ + 'success': False, + 'error': 'Docker command not found. Is Docker installed?' + }), 500 + except Exception as e: + return jsonify({ + 'success': False, + 'error': f'Unexpected error: {str(e)}' + }), 500 + +@app.route('/api/check-auth', methods=['GET']) +def check_auth(): + """Check if user is authenticated""" + return jsonify({'authenticated': session.get('authenticated', False)}) + +# ==================== PUBLIC ROUTES ==================== + +@app.route('/') +def index(): + """Serve the main map viewer""" + return send_from_directory(SCRIPT_DIR, 'index.html') + +@app.route('/editor') +def editor(): + """Serve the map editor (requires auth)""" + return send_from_directory(SCRIPT_DIR, 'editor.html') + +@app.route('/') +def serve_static(path): + """Serve static files""" + return send_from_directory(SCRIPT_DIR, path) + +@app.route('/images/') +def serve_images(path): + """Serve images from parent directory""" + return send_from_directory(PARENT_DIR / 'images', path) + +@app.route('/api/map-data', methods=['GET']) +@app.route('/map_data.json', methods=['GET']) +def get_map_data(): + """Get current map data (exported from world_loader which now loads from JSON)""" + try: + from data.world_loader import export_map_data + map_data = export_map_data() + return jsonify(map_data) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +# ==================== EDITOR API ROUTES (PROTECTED) ==================== + +@app.route('/api/editor/locations', methods=['GET']) +@require_auth +def get_locations(): + """Get all locations with full details""" + try: + # Load directly from JSON files to get latest data + locations_data = load_json_file(LOCATIONS_FILE) + npcs_data = load_json_file(NPCS_FILE) + + if not locations_data: + return jsonify({'locations': []}) + + locations = [] + for loc_data in locations_data.get('locations', []): + location_id = loc_data.get('id') + + # Get danger config + danger_config = locations_data.get('danger_config', {}).get(location_id, {}) + + # Get spawn config + spawn_config = locations_data.get('spawn_config', {}).get(location_id, []) + spawn_npcs = [] + + for spawn_entry in spawn_config: + npc_id = spawn_entry.get('npc_id') + weight = spawn_entry.get('weight', 50) + + # Get NPC details + npc_def = npcs_data.get('npcs', {}).get(npc_id, {}) + if npc_def: + spawn_npcs.append({ + 'npc_id': npc_id, + 'name': npc_def.get('name', npc_id), + 'emoji': npc_def.get('emoji', '๐Ÿ‘น'), + 'weight': weight + }) + + # Get connections from locations.json + exits = [] + for conn in locations_data.get('connections', []): + if conn.get('from') == location_id: + exits.append(conn.get('to')) + + locations.append({ + 'id': location_id, + 'name': loc_data.get('name', ''), + 'description': loc_data.get('description', ''), + 'image_path': loc_data.get('image_path', ''), + 'x': loc_data.get('x', 0.0), + 'y': loc_data.get('y', 0.0), + 'danger_level': danger_config.get('danger_level', 0), + 'encounter_rate': danger_config.get('encounter_rate', 0.0), + 'wandering_chance': danger_config.get('wandering_chance', 0.0), + 'spawn_npcs': spawn_npcs, + 'exits': exits, + 'interactables_count': len(loc_data.get('interactables', {})) + }) + + return jsonify({'locations': locations}) + except Exception as e: + import traceback + print(f"Error loading locations: {e}\n{traceback.format_exc()}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/editor/location/', methods=['GET']) +@require_auth +def get_location_detail(location_id): + """Get detailed information about a specific location""" + try: + # Load directly from JSON to get latest data + locations_data = load_json_file(LOCATIONS_FILE) + npcs_data = load_json_file(NPCS_FILE) + + if not locations_data: + return jsonify({'error': 'Locations file not found'}), 404 + + # Find the location in the list + location = None + for loc in locations_data.get('locations', []): + if loc.get('id') == location_id: + location = loc + break + + if not location: + return jsonify({'error': 'Location not found'}), 404 + + # Get danger config + danger_config = locations_data.get('danger_config', {}).get(location_id, {}) + + # Get spawn config + spawn_config = locations_data.get('spawn_config', {}).get(location_id, []) + spawn_npcs = [] + + for spawn_entry in spawn_config: + npc_id = spawn_entry.get('npc_id') + weight = spawn_entry.get('weight', 50) + + # Get NPC details + npc_def = npcs_data.get('npcs', {}).get(npc_id, {}) + if npc_def: + spawn_npcs.append({ + 'npc_id': npc_id, + 'name': npc_def.get('name', npc_id), + 'emoji': npc_def.get('emoji', '๐Ÿ‘น'), + 'weight': weight + }) + + # Get exits from connections + exits = {} + for conn in locations_data.get('connections', []): + if conn.get('from') == location_id: + to_id = conn.get('to') + exits[to_id] = { + 'stamina_cost': conn.get('stamina_cost', 1), + 'distance': conn.get('distance', 1.0) + } + + return jsonify({ + 'id': location_id, + 'name': location.get('name', ''), + 'description': location.get('description', ''), + 'image_path': location.get('image_path', ''), + 'x': location.get('x', 0.0), + 'y': location.get('y', 0.0), + 'danger_level': danger_config.get('danger_level', 0), + 'encounter_rate': danger_config.get('encounter_rate', 0.0), + 'wandering_chance': danger_config.get('wandering_chance', 0.0), + 'spawn_npcs': spawn_npcs, + 'exits': exits, + 'interactables': location.get('interactables', {}) + }) + except Exception as e: + import traceback + print(f"Error loading location detail: {e}\n{traceback.format_exc()}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/editor/location', methods=['POST']) +@require_auth +def update_location(): + """Update or create a location""" + try: + data = request.get_json() + location_id = data.get('id') + + print(f"[DEBUG] Received location data: {json.dumps(data, indent=2)}") + + # Load or create config + config = {} + if LOCATIONS_FILE.exists(): + with open(LOCATIONS_FILE, 'r') as f: + config = json.load(f) + + # Ensure proper structure - locations is a list, others are dicts + if 'locations' not in config or not isinstance(config['locations'], list): + config['locations'] = [] + if 'danger_config' not in config or not isinstance(config['danger_config'], dict): + config['danger_config'] = {} + if 'spawn_config' not in config or not isinstance(config['spawn_config'], dict): + config['spawn_config'] = {} + + # Find or create location in the list + location_index = None + for i, loc in enumerate(config['locations']): + if loc.get('id') == location_id: + location_index = i + break + + location_data = { + 'id': location_id, + 'name': data.get('name'), + 'description': data.get('description'), + 'image_path': data.get('image_path'), + 'x': data.get('x'), + 'y': data.get('y'), + 'interactables': data.get('interactables', {}) + } + + if location_index is not None: + # Update existing location + config['locations'][location_index] = location_data + else: + # Add new location + config['locations'].append(location_data) + + # Update danger config + config['danger_config'][location_id] = { + 'danger_level': data.get('danger_level', 0), + 'encounter_rate': data.get('encounter_rate', 0.0), + 'wandering_chance': data.get('wandering_chance', 0.0) + } + + # Update spawn config + spawn_npcs = data.get('spawn_npcs', []) + print(f"[DEBUG] spawn_npcs type: {type(spawn_npcs)}, value: {spawn_npcs}") + + if spawn_npcs and isinstance(spawn_npcs, list): + config['spawn_config'][location_id] = [ + {'npc_id': npc.get('npc_id', npc) if isinstance(npc, dict) else npc, + 'weight': npc.get('weight', 50) if isinstance(npc, dict) else 50} + for npc in spawn_npcs + ] + else: + config['spawn_config'][location_id] = [] + + # Save config + with open(LOCATIONS_FILE, 'w') as f: + json.dump(config, f, indent=2) + + return jsonify({'success': True, 'message': 'Location updated successfully'}) + except Exception as e: + import traceback + error_msg = f"[ERROR] Failed to save location: {e}\n{traceback.format_exc()}" + print(error_msg, flush=True) + # Also write to a file for debugging + try: + with open('/tmp/location_save_error.txt', 'w') as f: + f.write(error_msg) + f.write(f"\n\nReceived data: {json.dumps(data, indent=2)}") + except: + pass + return jsonify({'error': str(e)}), 500 + +@app.route('/api/editor/location/', methods=['DELETE']) +@require_auth +def delete_location(location_id): + """Delete a location from config""" + try: + if not LOCATIONS_FILE.exists(): + return jsonify({'error': 'No config file found'}), 404 + + with open(LOCATIONS_FILE, 'r') as f: + config = json.load(f) + + # Remove location from locations array + if 'locations' in config: + config['locations'] = [loc for loc in config['locations'] if loc.get('id') != location_id] + + # Remove from connections array (both from and to) + if 'connections' in config: + config['connections'] = [ + conn for conn in config['connections'] + if conn.get('from') != location_id and conn.get('to') != location_id + ] + + # Remove from danger_config + if 'danger_config' in config and location_id in config['danger_config']: + del config['danger_config'][location_id] + + # Remove from spawn_config + if 'spawn_config' in config and location_id in config['spawn_config']: + del config['spawn_config'][location_id] + + with open(LOCATIONS_FILE, 'w') as f: + json.dump(config, f, indent=2) + + return jsonify({'success': True, 'message': 'Location deleted successfully'}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/editor/available-npcs', methods=['GET']) +@require_auth +def get_available_npcs(): + """Get list of all available NPCs for spawning""" + try: + # Load directly from JSON to get latest data + npcs_data = load_json_file(NPCS_FILE) + + if not npcs_data: + return jsonify({'npcs': []}) + + npcs = [] + for npc_id, npc_def in npcs_data.get('npcs', {}).items(): + npcs.append({ + 'id': npc_id, + 'name': npc_def.get('name', npc_id), + 'emoji': npc_def.get('emoji', '๐Ÿ‘น'), + 'hp_range': [npc_def.get('hp_min', 10), npc_def.get('hp_max', 20)], + 'damage_range': [npc_def.get('damage_min', 1), npc_def.get('damage_max', 5)], + 'xp_reward': npc_def.get('xp_reward', 10) + }) + + return jsonify({'npcs': npcs}) + except Exception as e: + import traceback + print(f"Error loading available NPCs: {e}\n{traceback.format_exc()}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/editor/upload-image', methods=['POST']) +@require_auth +def upload_image(): + """Upload a location image""" + try: + if 'image' not in request.files: + return jsonify({'error': 'No image file provided'}), 400 + + file = request.files['image'] + if file.filename == '': + return jsonify({'error': 'No selected file'}), 400 + + if file and allowed_file(file.filename): + filename = secure_filename(file.filename) + filepath = UPLOAD_FOLDER / filename + file.save(str(filepath)) + + # Return relative path + relative_path = f'images/locations/{filename}' + return jsonify({ + 'success': True, + 'image_path': relative_path, + 'message': f'Image uploaded successfully: {filename}' + }) + else: + return jsonify({'error': 'Invalid file type'}), 400 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/editor/export-config', methods=['GET']) +@require_auth +def export_config(): + """Export current config as downloadable JSON""" + try: + if LOCATIONS_FILE.exists(): + with open(LOCATIONS_FILE, 'r') as f: + config = json.load(f) + return jsonify(config) + else: + return jsonify({'locations': {}, 'danger_config': {}, 'spawn_config': {}}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/editor/import-config', methods=['POST']) +@require_auth +def import_config(): + """Import config from JSON""" + try: + config = request.get_json() + with open(LOCATIONS_FILE, 'w') as f: + json.dump(config, f, indent=2) + return jsonify({'success': True, 'message': 'Configuration imported successfully'}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/editor/connections', methods=['GET']) +@require_auth +def get_connections(): + """Get all connections between locations""" + try: + import math + + # Load directly from JSON file to get latest data + locations_data = load_json_file(LOCATIONS_FILE) + if not locations_data: + return jsonify({'connections': []}) + + # Build location lookup for calculating distances + locations_by_id = {} + for loc in locations_data.get('locations', []): + locations_by_id[loc['id']] = loc + + connections = [] + for conn in locations_data.get('connections', []): + from_id = conn.get('from') + to_id = conn.get('to') + direction = conn.get('direction') + + from_loc = locations_by_id.get(from_id) + to_loc = locations_by_id.get(to_id) + + if from_loc and to_loc: + distance = math.sqrt((to_loc.get('x', 0) - from_loc.get('x', 0))**2 + + (to_loc.get('y', 0) - from_loc.get('y', 0))**2) + connections.append({ + 'from': from_id, + 'to': to_id, + 'direction': direction, + 'distance': round(distance, 2) + }) + + return jsonify({'connections': connections}) + except Exception as e: + import traceback + print(f"Error loading connections: {e}\n{traceback.format_exc()}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/editor/connection', methods=['POST']) +@require_auth +def add_connection(): + """Add a connection between two locations""" + try: + data = request.get_json() + from_id = data.get('from') + to_id = data.get('to') + direction = data.get('direction') + + # Load or create config + config = {} + if LOCATIONS_FILE.exists(): + with open(LOCATIONS_FILE, 'r') as f: + config = json.load(f) + + if 'connections' not in config: + config['connections'] = [] + + # Add connection + config['connections'].append({ + 'from': from_id, + 'to': to_id, + 'direction': direction + }) + + with open(LOCATIONS_FILE, 'w') as f: + json.dump(config, f, indent=2) + + return jsonify({'success': True, 'message': 'Connection added successfully'}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/editor/connection', methods=['DELETE']) +@require_auth +def delete_connection(): + """Delete a connection between two locations""" + try: + data = request.get_json() + from_id = data.get('from') + to_id = data.get('to') + + if not LOCATIONS_FILE.exists(): + return jsonify({'error': 'No config file found'}), 404 + + with open(LOCATIONS_FILE, 'r') as f: + config = json.load(f) + + if 'connections' in config: + config['connections'] = [ + conn for conn in config['connections'] + if not (conn['from'] == from_id and conn['to'] == to_id) + ] + + with open(LOCATIONS_FILE, 'w') as f: + json.dump(config, f, indent=2) + + return jsonify({'success': True, 'message': 'Connection deleted successfully'}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +# ==================== NPC MANAGEMENT ==================== + +@app.route('/api/editor/npcs', methods=['GET']) +@require_auth +def get_npcs(): + """Get all NPCs""" + try: + npcs_data = load_json_file(NPCS_FILE) + if not npcs_data: + return jsonify({'npcs': []}) + + npcs = [] + for npc_id, npc_def in npcs_data.get('npcs', {}).items(): + npcs.append({ + 'id': npc_id, + 'name': npc_def.get('name', npc_id), + 'emoji': npc_def.get('emoji', '๐Ÿ‘น'), + 'hp_min': npc_def.get('hp_min', 10), + 'hp_max': npc_def.get('hp_max', 20), + 'damage_min': npc_def.get('damage_min', 1), + 'damage_max': npc_def.get('damage_max', 5), + 'xp_reward': npc_def.get('xp_reward', 10), + 'defense': npc_def.get('defense', 0), + 'description': npc_def.get('description', ''), + 'image_url': npc_def.get('image_url', ''), + 'loot_table': npc_def.get('loot_table', []), + 'corpse_loot': npc_def.get('corpse_loot', []) + }) + + return jsonify({'npcs': npcs}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/editor/npc', methods=['POST']) +@require_auth +def save_npc(): + """Save or update an NPC""" + try: + npc_data = request.get_json() + npc_id = npc_data.get('id') + + # Load current NPCs + npcs_data = load_json_file(NPCS_FILE) or {'npcs': {}, 'danger_levels': {}, 'spawn_tables': {}} + + if 'npcs' not in npcs_data: + npcs_data['npcs'] = {} + + # Update NPC + npcs_data['npcs'][npc_id] = { + 'name': npc_data.get('name'), + 'emoji': npc_data.get('emoji'), + 'hp_min': npc_data.get('hp_min'), + 'hp_max': npc_data.get('hp_max'), + 'damage_min': npc_data.get('damage_min'), + 'damage_max': npc_data.get('damage_max'), + 'xp_reward': npc_data.get('xp_reward'), + 'defense': npc_data.get('defense', 0), + 'description': npc_data.get('description', ''), + 'image_url': npc_data.get('image_url', ''), + 'loot_table': npc_data.get('loot_table', []), + 'corpse_loot': npc_data.get('corpse_loot', []) + } + + # Save back + if save_json_file(NPCS_FILE, npcs_data): + return jsonify({'success': True, 'message': 'NPC saved successfully'}) + else: + return jsonify({'error': 'Failed to save NPC'}), 500 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/editor/npc/', methods=['DELETE']) +@require_auth +def delete_npc(npc_id): + """Delete an NPC""" + try: + npcs_data = load_json_file(NPCS_FILE) + if not npcs_data or 'npcs' not in npcs_data: + return jsonify({'error': 'NPCs file not found'}), 404 + + if npc_id in npcs_data['npcs']: + del npcs_data['npcs'][npc_id] + + if save_json_file(NPCS_FILE, npcs_data): + return jsonify({'success': True, 'message': 'NPC deleted successfully'}) + else: + return jsonify({'error': 'Failed to delete NPC'}), 500 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +# ==================== ITEM MANAGEMENT ==================== + +@app.route('/api/editor/items', methods=['GET']) +@require_auth +def get_items(): + """Get all items""" + try: + items_data = load_json_file(ITEMS_FILE) + if not items_data: + return jsonify({'items': []}) + + items = [] + for item_id, item_def in items_data.get('items', {}).items(): + items.append({ + 'id': item_id, + 'name': item_def.get('name', item_id), + 'emoji': item_def.get('emoji', ''), + 'type': item_def.get('type', 'resource'), + 'weight': item_def.get('weight', 0.1), + 'volume': item_def.get('volume', 0.1), + 'description': item_def.get('description', ''), + 'stackable': item_def.get('stackable', True), + 'hp_restore': item_def.get('hp_restore', 0), + 'stamina_restore': item_def.get('stamina_restore', 0), + 'damage': item_def.get('damage', 0), + 'defense': item_def.get('defense', 0), + 'capacity_weight': item_def.get('capacity_weight', 0), + 'capacity_volume': item_def.get('capacity_volume', 0) + }) + + return jsonify({'items': items}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/editor/item', methods=['POST']) +@require_auth +def save_item(): + """Save or update an item""" + try: + item_data = request.get_json() + item_id = item_data.get('id') + + # Load current items + items_data = load_json_file(ITEMS_FILE) or {'items': {}} + + if 'items' not in items_data: + items_data['items'] = {} + + # Build item dict with required fields + item_entry = { + 'name': item_data.get('name'), + 'type': item_data.get('type'), + 'weight': item_data.get('weight'), + 'volume': item_data.get('volume'), + } + + # Add optional fields only if they have values + if item_data.get('emoji'): + item_entry['emoji'] = item_data.get('emoji') + + if item_data.get('description'): + item_entry['description'] = item_data.get('description') + + # stackable defaults to True, only include if explicitly set + stackable = item_data.get('stackable', True) + if not stackable: + item_entry['stackable'] = False + + # Only include numeric properties if they're non-zero + if item_data.get('hp_restore', 0) != 0: + item_entry['hp_restore'] = item_data.get('hp_restore') + + if item_data.get('stamina_restore', 0) != 0: + item_entry['stamina_restore'] = item_data.get('stamina_restore') + + if item_data.get('damage', 0) != 0: + item_entry['damage'] = item_data.get('damage') + + if item_data.get('defense', 0) != 0: + item_entry['defense'] = item_data.get('defense') + + if item_data.get('capacity_weight', 0) != 0: + item_entry['capacity_weight'] = item_data.get('capacity_weight') + + if item_data.get('capacity_volume', 0) != 0: + item_entry['capacity_volume'] = item_data.get('capacity_volume') + + # Update item + items_data['items'][item_id] = item_entry + + # Save back + if save_json_file(ITEMS_FILE, items_data): + return jsonify({'success': True, 'message': 'Item saved successfully'}) + else: + return jsonify({'error': 'Failed to save item'}), 500 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/editor/item/', methods=['DELETE']) +@require_auth +def delete_item(item_id): + """Delete an item""" + try: + items_data = load_json_file(ITEMS_FILE) + if not items_data or 'items' not in items_data: + return jsonify({'error': 'Items file not found'}), 404 + + if item_id in items_data['items']: + del items_data['items'][item_id] + + if save_json_file(ITEMS_FILE, items_data): + return jsonify({'success': True, 'message': 'Item deleted successfully'}) + else: + return jsonify({'error': 'Failed to delete item'}), 500 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +# ==================== INTERACTABLES ENDPOINTS ==================== + +@app.route('/api/editor/interactables', methods=['GET']) +@require_auth +def get_interactables(): + """Get all interactable templates""" + try: + interactables_data = load_json_file(INTERACTABLES_FILE) + if not interactables_data: + return jsonify({'interactables': []}) + + interactables = [] + for inter_id, inter_def in interactables_data.get('interactables', {}).items(): + interactables.append({ + 'id': inter_id, + 'name': inter_def.get('name', inter_id), + 'description': inter_def.get('description', ''), + 'image_path': inter_def.get('image_path', ''), + 'actions': inter_def.get('actions', {}) + }) + + return jsonify({'interactables': interactables}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/editor/interactable', methods=['POST']) +@require_auth +def save_interactable(): + """Save or update an interactable template""" + try: + inter_data = request.get_json() + inter_id = inter_data.get('id') + + # Load current interactables + interactables_data = load_json_file(INTERACTABLES_FILE) or {'interactables': {}} + + if 'interactables' not in interactables_data: + interactables_data['interactables'] = {} + + # Update interactable + interactables_data['interactables'][inter_id] = { + 'id': inter_id, + 'name': inter_data.get('name'), + 'description': inter_data.get('description', ''), + 'image_path': inter_data.get('image_path', ''), + 'actions': inter_data.get('actions', {}) + } + + # Save back + if save_json_file(INTERACTABLES_FILE, interactables_data): + return jsonify({'success': True, 'message': 'Interactable saved successfully'}) + else: + return jsonify({'error': 'Failed to save interactable'}), 500 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/editor/interactable/', methods=['DELETE']) +@require_auth +def delete_interactable(inter_id): + """Delete an interactable template""" + try: + interactables_data = load_json_file(INTERACTABLES_FILE) + if not interactables_data or 'interactables' not in interactables_data: + return jsonify({'error': 'Interactables file not found'}), 404 + + if inter_id in interactables_data['interactables']: + del interactables_data['interactables'][inter_id] + + if save_json_file(INTERACTABLES_FILE, interactables_data): + return jsonify({'success': True, 'message': 'Interactable deleted successfully'}) + else: + return jsonify({'error': 'Failed to delete interactable'}), 500 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/editor/live-stats', methods=['GET']) +@require_auth +def get_live_stats(): + """Get real-time player locations and enemy counts""" + # Try to import database module - may not be available in map container + sys.path.insert(0, str(PARENT_DIR)) + + try: + from bot import database + except ImportError: + # Bot module not available in this container + # Return empty stats - this is expected in map-only container + return jsonify({ + 'players_by_location': {}, + 'enemies_by_location': {} + }) + + import asyncio + from sqlalchemy import text + + # Run async queries + async def get_stats(): + players_by_location = {} + enemies_by_location = {} + + # Get all players and their locations + try: + async with database.engine.connect() as conn: + # Get player counts per location + result = await conn.execute(text( + "SELECT location_id, COUNT(*) as count FROM players GROUP BY location_id" + )) + rows = result.fetchall() + for row in rows: + players_by_location[row[0]] = row[1] + + # Get wandering enemy counts per location + result = await conn.execute(text( + "SELECT location_id, COUNT(*) as count FROM wandering_enemies WHERE despawn_timestamp > EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) GROUP BY location_id" + )) + rows = result.fetchall() + for row in rows: + enemies_by_location[row[0]] = row[1] + except Exception as e: + print(f"[LIVE-STATS] Database query error: {e}", flush=True) + import traceback + traceback.print_exc() + + return players_by_location, enemies_by_location + + # Run the async function + try: + players, enemies = asyncio.run(get_stats()) + #print(f"[LIVE-STATS] Success! Players: {players}, Enemies: {enemies}", flush=True) + except Exception as e: + #print(f"[LIVE-STATS] asyncio.run error: {e}", flush=True) + import traceback + traceback.print_exc() + return jsonify({'error': str(e), 'players_by_location': {}, 'enemies_by_location': {}}), 200 + + return jsonify({ + 'players_by_location': players, + 'enemies_by_location': enemies + }) + +# Export to JSON endpoint removed to prevent accidental data loss +# The editor now works directly with JSON files + +@app.route('/api/editor/export-python', methods=['GET']) +@require_auth +def export_python(): + """Export configuration as Python code to update world_loader.py""" + try: + if not LOCATIONS_FILE.exists(): + return jsonify({'error': 'No configuration to export'}), 404 + + with open(LOCATIONS_FILE, 'r') as f: + config = json.load(f) + + # Generate Python code + python_code = generate_world_loader_code(config) + + return jsonify({ + 'success': True, + 'python_code': python_code, + 'message': 'Python code generated. Review and manually update world_loader.py' + }) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +def generate_world_loader_code(config): + """Generate Python code from config""" + code_lines = ["# Generated configuration updates\n", "# REVIEW CAREFULLY before applying!\n\n"] + + # Generate location updates + if 'locations' in config: + code_lines.append("# ===== LOCATION UPDATES =====\n") + for loc_id, loc_data in config['locations'].items(): + code_lines.append(f"\n# Update {loc_id}\n") + code_lines.append(f"{loc_id}.name = {repr(loc_data['name'])}\n") + code_lines.append(f"{loc_id}.description = {repr(loc_data['description'])}\n") + code_lines.append(f"{loc_id}.x = {loc_data['x']}\n") + code_lines.append(f"{loc_id}.y = {loc_data['y']}\n") + if loc_data.get('image_path'): + code_lines.append(f"{loc_id}.image_path = {repr(loc_data['image_path'])}\n") + + # Generate danger config + if 'danger_config' in config: + code_lines.append("\n# ===== DANGER CONFIG (for npcs.py) =====\n") + code_lines.append("LOCATION_DANGER = {\n") + for loc_id, danger_data in config['danger_config'].items(): + code_lines.append(f" {repr(loc_id)}: ({danger_data['danger_level']}, {danger_data['encounter_rate']}, {danger_data['wandering_chance']}),\n") + code_lines.append("}\n") + + # Generate spawn config + if 'spawn_config' in config: + code_lines.append("\n# ===== SPAWN CONFIG (for npcs.py) =====\n") + code_lines.append("LOCATION_SPAWNS = {\n") + for loc_id, spawns in config['spawn_config'].items(): + code_lines.append(f" {repr(loc_id)}: [\n") + for spawn in spawns: + code_lines.append(f" ({repr(spawn['npc_id'])}, {spawn['weight']}),\n") + code_lines.append(" ],\n") + code_lines.append("}\n") + + # Generate connections + if 'connections' in config: + code_lines.append("\n# ===== CONNECTIONS =====\n") + for conn in config['connections']: + code_lines.append(f"{conn['from']}.add_exit({repr(conn['direction'])}, {repr(conn['to'])})\n") + + return ''.join(code_lines) + +if __name__ == '__main__': + port = int(os.environ.get('PORT', 8080)) + print(f""" +โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— +โ•‘ Echoes of the Ashes - Map Server โ•‘ +โ•‘ WITH EDITOR โ•‘ +โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +๐Ÿ—บ๏ธ Map Viewer: http://localhost:{port} +โœ๏ธ Map Editor: http://localhost:{port}/editor +๐Ÿ” Password: {ADMIN_PASSWORD} + +โš ๏ธ IMPORTANT: Set EDITOR_PASSWORD environment variable in production! +""") + app.run(host='0.0.0.0', port=port, debug=False) diff --git a/web-map/world-config.json b/web-map/world-config.json new file mode 100644 index 0000000..e69de29