From 592591cb9240e8fba4ddfb834e91bdc9129e25c8 Mon Sep 17 00:00:00 2001 From: Joan Date: Fri, 28 Nov 2025 10:52:23 +0100 Subject: [PATCH] Fix TypeScript build errors --- Dockerfile.api.old | 30 - README.md | 371 --- bot/__init__.py | 0 bot/action_handlers.py | 417 --- bot/api_client.old.py | 198 -- bot/api_client.py | 623 ----- bot/background_tasks.py | 201 -- bot/combat.py | 527 ---- bot/combat_handlers.py | 165 -- bot/commands.py | 109 - bot/corpse_handlers.py | 235 -- bot/database.py | 729 ------ bot/handlers.py | 174 -- bot/inventory_handlers.py | 338 --- bot/keyboards.py | 607 ----- bot/logic.py | 119 - bot/message_utils.py | 121 - bot/pickup_handlers.py | 136 - bot/profile_handlers.py | 169 -- bot/spawn_manager.py | 119 - bot/status_utils.py | 119 - bot/utils.py | 128 - main.py | 87 - pwa/src/components/Game_OLD_BACKUP.tsx | 3314 ------------------------ pwa/src/hooks/useGameWebSocket.ts | 18 +- pwa/src/services/api.ts | 1 + 26 files changed, 10 insertions(+), 9045 deletions(-) delete mode 100644 Dockerfile.api.old delete mode 100644 README.md delete mode 100644 bot/__init__.py delete mode 100644 bot/action_handlers.py delete mode 100644 bot/api_client.old.py delete mode 100644 bot/api_client.py delete mode 100644 bot/background_tasks.py delete mode 100644 bot/combat.py delete mode 100644 bot/combat_handlers.py delete mode 100644 bot/commands.py delete mode 100644 bot/corpse_handlers.py delete mode 100644 bot/database.py delete mode 100644 bot/handlers.py delete mode 100644 bot/inventory_handlers.py delete mode 100644 bot/keyboards.py delete mode 100644 bot/logic.py delete mode 100644 bot/message_utils.py delete mode 100644 bot/pickup_handlers.py delete mode 100644 bot/profile_handlers.py delete mode 100644 bot/spawn_manager.py delete mode 100644 bot/status_utils.py delete mode 100644 bot/utils.py delete mode 100644 main.py delete mode 100644 pwa/src/components/Game_OLD_BACKUP.tsx diff --git a/Dockerfile.api.old b/Dockerfile.api.old deleted file mode 100644 index 0705a7e..0000000 --- a/Dockerfile.api.old +++ /dev/null @@ -1,30 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - postgresql-client \ - && rm -rf /var/lib/apt/lists/* - -# Copy requirements -COPY requirements.txt ./ -COPY api/requirements.txt ./api-requirements.txt - -# Install Python dependencies -RUN pip install --no-cache-dir -r requirements.txt && \ - pip install --no-cache-dir -r api-requirements.txt - -# Copy application code -COPY bot/ ./bot/ -COPY data/ ./data/ -COPY api/ ./api/ -COPY gamedata/ ./gamedata/ -COPY migrate_*.py ./ - -# Expose port -EXPOSE 8000 - -# Run the API server -CMD ["python", "-m", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md deleted file mode 100644 index 412cfb6..0000000 --- a/README.md +++ /dev/null @@ -1,371 +0,0 @@ -# Echoes of the Ashes - -A post-apocalyptic survival RPG available on **Telegram** and **Web**, 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) -![React](https://img.shields.io/badge/react-18-blue) -![FastAPI](https://img.shields.io/badge/fastapi-0.104-green) - -## ๐ŸŒ Play Now - -- **Telegram Bot**: [@your_bot_username](https://t.me/your_bot_username) -- **Web/Mobile**: [echoesoftheashgame.patacuack.net](https://echoesoftheashgame.patacuack.net) - -## ๐ŸŽฎ 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 - -### Telegram Bot - -1. Get a Bot Token from [@BotFather](https://t.me/botfather) -2. Create `.env` file with your credentials -3. Run `docker-compose up -d --build` -4. Find your bot and send `/start` - -See [Installation Guide](#installation) for detailed instructions. - -### Progressive Web App (PWA) - -1. Run `./setup_pwa.sh` to set up the web version -2. Open [echoesoftheashgame.patacuack.net](https://echoesoftheashgame.patacuack.net) -3. Register an account and play! - -See [PWA_QUICKSTART.md](PWA_QUICKSTART.md) for detailed instructions. - -## ๐Ÿ“ฑ Platform Features - -### Telegram Bot -- ๐Ÿค– Native Telegram integration -- ๐Ÿ”” Instant push notifications -- ๐Ÿ’ฌ Chat-based gameplay -- ๐Ÿ‘ฅ Easy sharing with friends - -### Web/Mobile PWA -- ๐ŸŒ Play in any browser -- ๐Ÿ“ฑ Install as mobile app -- ๐ŸŽจ Modern responsive UI -- ๐Ÿ” Separate authentication -- โšก Offline support (coming soon) -- ๐Ÿ”” Web push notifications (coming soon) - -## ๐Ÿ› ๏ธ Installation - -### Prerequisites -- Docker and Docker Compose -- For Telegram: Bot Token from [@BotFather](https://t.me/botfather) -- For PWA: Node.js 20+ (for development) - -### Basic Setup - -1. Clone the repository: -```bash -cd /opt/dockers/echoes_of_the_ashes -``` - -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 -JWT_SECRET_KEY=generate-with-openssl-rand-hex-32 -``` - -3. Start services: -```bash -# Telegram bot only -docker-compose up -d --build - -# With PWA (web version) -./setup_pwa.sh -``` - -4. Check logs: -```bash -docker logs echoes_of_the_ashes_bot -f -docker logs echoes_of_the_ashes_api -f -docker logs echoes_of_the_ashes_pwa -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 deleted file mode 100644 index e69de29..0000000 diff --git a/bot/action_handlers.py b/bot/action_handlers.py deleted file mode 100644 index a4b83e7..0000000 --- a/bot/action_handlers.py +++ /dev/null @@ -1,417 +0,0 @@ -""" -Action handlers for button callbacks. -This module contains organized handler functions for different types of player actions. -""" -import logging -import json -import random -from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup -from telegram.ext import ContextTypes -from . import keyboards, logic -from .api_client import api_client -from .utils import format_stat_bar -from data.world_loader import game_world -from data.items import ITEMS - -logger = logging.getLogger(__name__) - - -# ============================================================================ -# UTILITY FUNCTIONS -# ============================================================================ - -async def check_and_redirect_if_in_combat(query, user_id: int, player: dict) -> bool: - """ - Check if player is in combat and redirect to combat view if so. - Returns True if player is in combat (and was redirected), False otherwise. - """ - combat_data = await api_client.get_combat(user_id) - if combat_data: - from data.npcs import NPCS - npc_def = NPCS.get(combat_data['npc_id']) - - message = f"โš”๏ธ You're in combat with {npc_def.emoji} {npc_def.name}!\n" - message += format_stat_bar("Your HP", "โค๏ธ", player['hp'], player['max_hp']) + "\n" - message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n" - message += "๐ŸŽฏ Your turn!" if combat_data['turn'] == 'player' else "โณ Enemy's turn..." - - keyboard = await keyboards.combat_keyboard(user_id) - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=message, - reply_markup=keyboard, - image_path=npc_def.image_url if npc_def else None - ) - await query.answer("โš”๏ธ You're in combat! Finish or flee first.", show_alert=True) - return True - return False - -async def get_player_status_text(player_id: int) -> str: - """Generate player status text with location and stats. - - Args: - player_id: The unique database ID of the player (not telegram_id) - """ - from .api_client import api_client - - player = await api_client.get_player_by_id(player_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." - - # Get inventory from API - inv_result = await api_client.get_inventory(player_id) - inventory = inv_result.get('inventory', []) - 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')}") - - # Build status with visual bars - status = f"๐Ÿ“ Location: {location.name}\n" - status += f"{format_stat_bar('HP', 'โค๏ธ', player['hp'], player['max_hp'])}\n" - status += f"{format_stat_bar('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 += "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n" - status += f"{location.description}" - return status - - -# ============================================================================ -# INSPECTION & WORLD INTERACTION HANDLERS -# ============================================================================ - -async def handle_inspect_area(query, user_id: int, player: dict, data: list = None): - """Handle inspect area action - show NPCs and interactables in current location.""" - # Check if player is in combat and redirect if so - if await check_and_redirect_if_in_combat(query, user_id, player): - return - - await query.answer() - location_id = player['location_id'] - location = game_world.get_location(location_id) - dropped_items = await api_client.get_dropped_items_in_location(location_id) - wandering_enemies = await api_client.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 - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text="You scan the area. You notice...", - reply_markup=keyboard, - image_path=image_path - ) - - -async def handle_attack_wandering(query, user_id: int, player: dict, data: list): - """Handle attacking a wandering enemy.""" - enemy_db_id = int(data[1]) - await query.answer() - - # Get the enemy from database - wandering_enemies = await api_client.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 api_client.get_dropped_items_in_location(location_id) - wandering_enemies = await api_client.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 - - from .handlers import send_or_edit_with_image - 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 api_client.remove_wandering_enemy(enemy_db_id) - - from data.npcs import NPCS - from bot import combat - - # Initiate combat - 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 += format_stat_bar("Your HP", "โค๏ธ", player['hp'], player['max_hp']) + "\n" - message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n" - message += "๐ŸŽฏ Your turn! What will you do?" - - keyboard = await keyboards.combat_keyboard(user_id) - - from .handlers import send_or_edit_with_image - 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) - - -async def handle_inspect_interactable(query, user_id: int, player: dict, data: list): - """Handle inspecting an interactable object.""" - # Check if player is in combat and redirect if so - if await check_and_redirect_if_in_combat(query, user_id, player): - return - - 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 api_client.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 - - from .handlers import send_or_edit_with_image - 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 - ) - - -async def handle_action(query, user_id: int, player: dict, data: list): - """Handle performing an action on an interactable object.""" - # Check if player is in combat and redirect if so - if await check_and_redirect_if_in_combat(query, user_id, player): - return - - location_id, instance_id, action_id = data[1], data[2], data[3] - cooldown_key = f"{instance_id}:{action_id}" - cooldown = await api_client.get_cooldown(cooldown_key) - - if cooldown > 0: - await query.answer("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 - - await query.answer() - - # Set cooldown - await api_client.set_cooldown(cooldown_key) - - # Resolve action - outcome = logic.resolve_action(player, action_obj) - new_stamina = player['stamina'] - action_obj.stamina_cost - new_hp = player['hp'] - outcome.damage_taken - await api_client.update_player(user_id, {"stamina": new_stamina, "hp": new_hp}) - - # Build detailed action result - result_details = [f"{outcome.text}"] - - if action_obj.stamina_cost > 0: - result_details.append(f"โšก๏ธ Stamina: -{action_obj.stamina_cost}") - - 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(): - can_add, reason = await logic.can_add_item_to_inventory(user_id, item_id, quantity) - - if can_add: - await api_client.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 - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=final_text, - reply_markup=keyboards.main_menu_keyboard(), - image_path=location_image - ) - - -# ============================================================================ -# NAVIGATION & MOVEMENT HANDLERS -# ============================================================================ - -async def handle_main_menu(query, user_id: int, player: dict, data: list = None): - """Return to 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 - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=status_text, - reply_markup=keyboards.main_menu_keyboard(), - image_path=location_image - ) - - -async def handle_move_menu(query, user_id: int, player: dict, data: list = None): - """Show movement options menu.""" - # Check if player is in combat and redirect if so - if await check_and_redirect_if_in_combat(query, user_id, player): - return - - await query.answer() - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - from .handlers import send_or_edit_with_image - 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 - ) - - -async def handle_move(query, user_id: int, player: dict, data: list): - """Handle player movement to a new location.""" - # Check if player is in combat and redirect if so - if await check_and_redirect_if_in_combat(query, user_id, player): - return - - destination_id = data[1] - - # Use API to move player - from .api_client import api_client - result = await api_client.move_player(player['id'], destination_id) - - if not result.get('success'): - await query.answer(result.get('message', 'Cannot move there!'), show_alert=True) - return - - await query.answer(result.get('message', 'Moving...'), show_alert=False) - - # Refresh player data from API using unique id - player = await api_client.get_player_by_id(user_id) - - # Check for random NPC encounter - 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 - logger.info(f"Encounter triggered at {destination_id} (rate: {encounter_rate})") - - npc_id = get_random_npc_for_location(destination_id) - - 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 += format_stat_bar("Your HP", "โค๏ธ", player['hp'], player['max_hp']) + "\n" - message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n" - message += "๐ŸŽฏ Your turn! What will you do?" - - keyboard = await keyboards.combat_keyboard(user_id) - - from .handlers import send_or_edit_with_image - 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 - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=status_text, - reply_markup=keyboards.main_menu_keyboard(), - image_path=location_image - ) diff --git a/bot/api_client.old.py b/bot/api_client.old.py deleted file mode 100644 index 1cc7bbe..0000000 --- a/bot/api_client.old.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -API Client for Telegram Bot -Connects bot to FastAPI game server instead of using direct database access -""" - -import os -import httpx -from typing import Optional, Dict, Any - -API_BASE_URL = os.getenv("API_BASE_URL", "http://echoes_of_the_ashes_api:8000") -API_INTERNAL_KEY = os.getenv("API_INTERNAL_KEY", "internal-bot-access-key-change-me") - - -class GameAPIClient: - """Client for interacting with the FastAPI game server""" - - def __init__(self): - self.base_url = API_BASE_URL - self.headers = { - "X-Internal-Key": API_INTERNAL_KEY, - "Content-Type": "application/json" - } - self.client = httpx.AsyncClient(timeout=30.0) - - async def close(self): - """Close the HTTP client""" - await self.client.aclose() - - # ==================== Player Management ==================== - - async def get_player(self, telegram_id: int) -> Optional[Dict[str, Any]]: - """Get player by telegram ID""" - try: - response = await self.client.get( - f"{self.base_url}/api/internal/player/telegram/{telegram_id}", - headers=self.headers - ) - if response.status_code == 404: - return None - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting player: {e}") - return None - - async def create_player(self, telegram_id: int, name: str) -> Optional[Dict[str, Any]]: - """Create a new player""" - try: - response = await self.client.post( - f"{self.base_url}/api/internal/player", - headers=self.headers, - json={"telegram_id": telegram_id, "name": name} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error creating player: {e}") - return None - - async def update_player(self, telegram_id: int, updates: Dict[str, Any]) -> bool: - """Update player data""" - try: - response = await self.client.patch( - f"{self.base_url}/api/internal/player/telegram/{telegram_id}", - headers=self.headers, - json=updates - ) - response.raise_for_status() - return True - except Exception as e: - print(f"Error updating player: {e}") - return False - - # ==================== Location & Movement ==================== - - async def get_location(self, location_id: str) -> Optional[Dict[str, Any]]: - """Get location details""" - try: - response = await self.client.get( - f"{self.base_url}/api/internal/location/{location_id}", - headers=self.headers - ) - if response.status_code == 404: - return None - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting location: {e}") - return None - - async def move_player(self, telegram_id: int, direction: str) -> Optional[Dict[str, Any]]: - """Move player in a direction""" - try: - response = await self.client.post( - f"{self.base_url}/api/internal/player/telegram/{telegram_id}/move", - headers=self.headers, - json={"direction": direction} - ) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - # Return error details - return {"success": False, "error": e.response.json().get("detail", str(e))} - except Exception as e: - print(f"Error moving player: {e}") - return {"success": False, "error": str(e)} - - # ==================== Combat ==================== - - async def start_combat(self, telegram_id: int, npc_id: str) -> Optional[Dict[str, Any]]: - """Start combat with an NPC""" - try: - response = await self.client.post( - f"{self.base_url}/api/internal/combat/start", - headers=self.headers, - json={"telegram_id": telegram_id, "npc_id": npc_id} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error starting combat: {e}") - return None - - async def get_combat(self, telegram_id: int) -> Optional[Dict[str, Any]]: - """Get active combat state""" - try: - response = await self.client.get( - f"{self.base_url}/api/internal/combat/telegram/{telegram_id}", - headers=self.headers - ) - if response.status_code == 404: - return None - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting combat: {e}") - return None - - async def combat_action(self, telegram_id: int, action: str) -> Optional[Dict[str, Any]]: - """Perform a combat action (attack, defend, flee)""" - try: - response = await self.client.post( - f"{self.base_url}/api/internal/combat/telegram/{telegram_id}/action", - headers=self.headers, - json={"action": action} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error performing combat action: {e}") - return None - - # ==================== Inventory ==================== - - async def get_inventory(self, telegram_id: int) -> Optional[Dict[str, Any]]: - """Get player's inventory""" - try: - response = await self.client.get( - f"{self.base_url}/api/internal/player/telegram/{telegram_id}/inventory", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting inventory: {e}") - return None - - async def use_item(self, telegram_id: int, item_db_id: int) -> Optional[Dict[str, Any]]: - """Use an item from inventory""" - try: - response = await self.client.post( - f"{self.base_url}/api/internal/player/telegram/{telegram_id}/use_item", - headers=self.headers, - json={"item_db_id": item_db_id} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error using item: {e}") - return None - - async def equip_item(self, telegram_id: int, item_db_id: int) -> Optional[Dict[str, Any]]: - """Equip/unequip an item""" - try: - response = await self.client.post( - f"{self.base_url}/api/internal/player/telegram/{telegram_id}/equip", - headers=self.headers, - json={"item_db_id": item_db_id} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error equipping item: {e}") - return None - - -# Global API client instance -api_client = GameAPIClient() diff --git a/bot/api_client.py b/bot/api_client.py deleted file mode 100644 index 38b9a55..0000000 --- a/bot/api_client.py +++ /dev/null @@ -1,623 +0,0 @@ -""" -API client for the bot to communicate with the standalone API. -All database operations now go through the API. -""" -import httpx -import os -from typing import Optional, Dict, Any, List - - -class APIClient: - """Client for bot-to-API communication""" - - def __init__(self): - self.api_url = os.getenv("API_BASE_URL", os.getenv("API_URL", "http://echoes_of_the_ashes_api:8000")) - self.internal_key = os.getenv("API_INTERNAL_KEY", "change-this-internal-key") - self.client = httpx.AsyncClient(timeout=30.0) - self.headers = { - "Authorization": f"Bearer {self.internal_key}", - "Content-Type": "application/json" - } - - async def close(self): - """Close the HTTP client""" - await self.client.aclose() - - # Player operations - async def get_player(self, telegram_id: int) -> Optional[Dict[str, Any]]: - """Get player by Telegram ID""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/player/{telegram_id}", - headers=self.headers - ) - if response.status_code == 404: - return None - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting player: {e}") - return None - - async def get_player_by_id(self, player_id: int) -> Optional[Dict[str, Any]]: - """Get player by unique database ID""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/player/by_id/{player_id}", - headers=self.headers - ) - if response.status_code == 404: - return None - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting player by id: {e}") - return None - - async def create_player(self, telegram_id: int, name: str = "Survivor") -> Optional[Dict[str, Any]]: - """Create a new player""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/player", - headers=self.headers, - params={"telegram_id": telegram_id, "name": name} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error creating player: {e}") - return None - - # Movement operations - async def move_player(self, player_id: int, direction: str) -> Dict[str, Any]: - """Move player in a direction""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/player/{player_id}/move", - headers=self.headers, - params={"direction": direction} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error moving player: {e}") - return {"success": False, "message": str(e)} - - # Inspection operations - async def inspect_area(self, player_id: int) -> Dict[str, Any]: - """Inspect current area""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/player/{player_id}/inspect", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error inspecting area: {e}") - return {"success": False, "message": str(e)} - - # Interaction operations - async def interact(self, player_id: int, interactable_id: str, action_id: str) -> Dict[str, Any]: - """Interact with an object""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/player/{player_id}/interact", - headers=self.headers, - params={"interactable_id": interactable_id, "action_id": action_id} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error interacting: {e}") - return {"success": False, "message": str(e)} - - # Inventory operations - async def get_inventory(self, player_id: int) -> Dict[str, Any]: - """Get player inventory""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/player/{player_id}/inventory", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting inventory: {e}") - return {"success": False, "inventory": []} - - async def use_item(self, player_id: int, item_id: str) -> Dict[str, Any]: - """Use an item""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/player/{player_id}/use_item", - headers=self.headers, - params={"item_id": item_id} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error using item: {e}") - return {"success": False, "message": str(e)} - - async def pickup_item(self, player_id: int, item_id: str) -> Dict[str, Any]: - """Pick up an item""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/player/{player_id}/pickup", - headers=self.headers, - params={"item_id": item_id} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error picking up item: {e}") - return {"success": False, "message": str(e)} - - async def drop_item(self, player_id: int, item_id: str, quantity: int = 1) -> Dict[str, Any]: - """Drop an item""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/player/{player_id}/drop_item", - headers=self.headers, - params={"item_id": item_id, "quantity": quantity} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error dropping item: {e}") - return {"success": False, "message": str(e)} - - async def equip_item(self, player_id: int, item_id: str) -> Dict[str, Any]: - """Equip an item""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/player/{player_id}/equip", - headers=self.headers, - params={"item_id": item_id} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error equipping item: {e}") - return {"success": False, "message": str(e)} - - async def unequip_item(self, player_id: int, item_id: str) -> Dict[str, Any]: - """Unequip an item""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/player/{player_id}/unequip", - headers=self.headers, - params={"item_id": item_id} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error unequipping item: {e}") - return {"success": False, "message": str(e)} - - # Combat operations - async def get_combat(self, player_id: int) -> Optional[Dict[str, Any]]: - """Get active combat for player""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/player/{player_id}/combat", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting combat: {e}") - return None - - async def create_combat(self, player_id: int, npc_id: str, npc_hp: int, npc_max_hp: int, location_id: str, from_wandering: bool = False) -> Optional[Dict[str, Any]]: - """Create new combat""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/combat/create", - headers=self.headers, - params={ - "player_id": player_id, - "npc_id": npc_id, - "npc_hp": npc_hp, - "npc_max_hp": npc_max_hp, - "location_id": location_id, - "from_wandering": from_wandering - } - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error creating combat: {e}") - return None - - async def update_combat(self, player_id: int, updates: Dict[str, Any]) -> bool: - """Update combat state""" - try: - response = await self.client.patch( - f"{self.api_url}/api/internal/combat/{player_id}", - headers=self.headers, - json=updates - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error updating combat: {e}") - return False - - async def end_combat(self, player_id: int) -> bool: - """End combat""" - try: - response = await self.client.delete( - f"{self.api_url}/api/internal/combat/{player_id}", - headers=self.headers - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error ending combat: {e}") - return False - - # Player update operations - async def update_player(self, player_id: int, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Update player fields""" - try: - response = await self.client.patch( - f"{self.api_url}/api/internal/player/{player_id}", - headers=self.headers, - json=updates - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error updating player: {e}") - return None - - # Dropped items operations - async def drop_item_to_world(self, item_id: str, quantity: int, location_id: str) -> bool: - """Drop an item to the world""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/dropped-items", - headers=self.headers, - params={"item_id": item_id, "quantity": quantity, "location_id": location_id} - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error dropping item: {e}") - return False - - async def get_dropped_item(self, dropped_item_id: int) -> Optional[Dict[str, Any]]: - """Get a specific dropped item""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/dropped-items/{dropped_item_id}", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting dropped item: {e}") - return None - - async def get_dropped_items_in_location(self, location_id: str) -> List[Dict[str, Any]]: - """Get all dropped items in a location""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/location/{location_id}/dropped-items", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting dropped items: {e}") - return [] - - async def update_dropped_item(self, dropped_item_id: int, quantity: int) -> bool: - """Update dropped item quantity""" - try: - response = await self.client.patch( - f"{self.api_url}/api/internal/dropped-items/{dropped_item_id}", - headers=self.headers, - params={"quantity": quantity} - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error updating dropped item: {e}") - return False - - async def remove_dropped_item(self, dropped_item_id: int) -> bool: - """Remove a dropped item""" - try: - response = await self.client.delete( - f"{self.api_url}/api/internal/dropped-items/{dropped_item_id}", - headers=self.headers - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error removing dropped item: {e}") - return False - - # Corpse operations - async def create_player_corpse(self, player_name: str, location_id: str, items: str) -> Optional[int]: - """Create a player corpse""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/corpses/player", - headers=self.headers, - params={"player_name": player_name, "location_id": location_id, "items": items} - ) - response.raise_for_status() - result = response.json() - return result.get('corpse_id') - except Exception as e: - print(f"Error creating player corpse: {e}") - return None - - async def get_player_corpse(self, corpse_id: int) -> Optional[Dict[str, Any]]: - """Get a player corpse""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/corpses/player/{corpse_id}", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting player corpse: {e}") - return None - - async def update_player_corpse(self, corpse_id: int, items: str) -> bool: - """Update player corpse items""" - try: - response = await self.client.patch( - f"{self.api_url}/api/internal/corpses/player/{corpse_id}", - headers=self.headers, - params={"items": items} - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error updating player corpse: {e}") - return False - - async def remove_player_corpse(self, corpse_id: int) -> bool: - """Remove a player corpse""" - try: - response = await self.client.delete( - f"{self.api_url}/api/internal/corpses/player/{corpse_id}", - headers=self.headers - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error removing player corpse: {e}") - return False - - async def create_npc_corpse(self, npc_id: str, location_id: str, loot_remaining: str) -> Optional[int]: - """Create an NPC corpse""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/corpses/npc", - headers=self.headers, - params={"npc_id": npc_id, "location_id": location_id, "loot_remaining": loot_remaining} - ) - response.raise_for_status() - result = response.json() - return result.get('corpse_id') - except Exception as e: - print(f"Error creating NPC corpse: {e}") - return None - - async def get_npc_corpse(self, corpse_id: int) -> Optional[Dict[str, Any]]: - """Get an NPC corpse""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/corpses/npc/{corpse_id}", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting NPC corpse: {e}") - return None - - async def update_npc_corpse(self, corpse_id: int, loot_remaining: str) -> bool: - """Update NPC corpse loot""" - try: - response = await self.client.patch( - f"{self.api_url}/api/internal/corpses/npc/{corpse_id}", - headers=self.headers, - params={"loot_remaining": loot_remaining} - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error updating NPC corpse: {e}") - return False - - async def remove_npc_corpse(self, corpse_id: int) -> bool: - """Remove an NPC corpse""" - try: - response = await self.client.delete( - f"{self.api_url}/api/internal/corpses/npc/{corpse_id}", - headers=self.headers - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error removing NPC corpse: {e}") - return False - - # Wandering enemies operations - async def spawn_wandering_enemy(self, npc_id: str, location_id: str, current_hp: int, max_hp: int) -> Optional[int]: - """Spawn a wandering enemy""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/wandering-enemies", - headers=self.headers, - params={"npc_id": npc_id, "location_id": location_id, "current_hp": current_hp, "max_hp": max_hp} - ) - response.raise_for_status() - result = response.json() - return result.get('enemy_id') - except Exception as e: - print(f"Error spawning wandering enemy: {e}") - return None - - async def get_wandering_enemies_in_location(self, location_id: str) -> List[Dict[str, Any]]: - """Get all wandering enemies in a location""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/location/{location_id}/wandering-enemies", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting wandering enemies: {e}") - return [] - - async def remove_wandering_enemy(self, enemy_id: int) -> bool: - """Remove a wandering enemy""" - try: - response = await self.client.delete( - f"{self.api_url}/api/internal/wandering-enemies/{enemy_id}", - headers=self.headers - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error removing wandering enemy: {e}") - return False - - async def get_inventory_item(self, item_db_id: int) -> Optional[Dict[str, Any]]: - """Get a specific inventory item by database ID""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/inventory/item/{item_db_id}", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting inventory item: {e}") - return None - - # Cooldown operations - async def get_cooldown(self, cooldown_key: str) -> int: - """Get remaining cooldown time in seconds""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/cooldown/{cooldown_key}", - headers=self.headers - ) - response.raise_for_status() - result = response.json() - return result.get('remaining_seconds', 0) - except Exception as e: - print(f"Error getting cooldown: {e}") - return 0 - - async def set_cooldown(self, cooldown_key: str, duration_seconds: int = 600) -> bool: - """Set a cooldown""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/cooldown/{cooldown_key}", - headers=self.headers, - params={"duration_seconds": duration_seconds} - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error setting cooldown: {e}") - return False - - # Corpse list operations - async def get_player_corpses_in_location(self, location_id: str) -> List[Dict[str, Any]]: - """Get all player corpses in a location""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/location/{location_id}/corpses/player", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting player corpses: {e}") - return [] - - async def get_npc_corpses_in_location(self, location_id: str) -> List[Dict[str, Any]]: - """Get all NPC corpses in a location""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/location/{location_id}/corpses/npc", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting NPC corpses: {e}") - return [] - - # Image cache operations - async def get_cached_image(self, image_path: str) -> Optional[str]: - """Get cached telegram file ID for an image""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/image-cache/{image_path}", - headers=self.headers - ) - response.raise_for_status() - result = response.json() - return result.get('telegram_file_id') - except Exception as e: - # Not found is expected, not an error - return None - - async def cache_image(self, image_path: str, telegram_file_id: str) -> bool: - """Cache a telegram file ID for an image""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/image-cache", - headers=self.headers, - params={"image_path": image_path, "telegram_file_id": telegram_file_id} - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error caching image: {e}") - return False - - # Status effects operations - async def get_player_status_effects(self, player_id: int) -> List[Dict[str, Any]]: - """Get player status effects""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/player/{player_id}/status-effects", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting status effects: {e}") - return [] - - -# Global API client instance -api_client = APIClient() diff --git a/bot/background_tasks.py b/bot/background_tasks.py deleted file mode 100644 index c4ba318..0000000 --- a/bot/background_tasks.py +++ /dev/null @@ -1,201 +0,0 @@ -""" -Background tasks for the bot. -Handles periodic maintenance, regeneration, and processing. -""" -import asyncio -import logging -import time -from bot import database - -logger = logging.getLogger(__name__) - - -async def decay_dropped_items(shutdown_event): - """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: - start_time = time.time() - 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) - - elapsed = time.time() - start_time - if items_removed > 0: - logger.info(f"Decayed and removed {items_removed} old items in {elapsed:.2f}s") - - -async def regenerate_stamina(shutdown_event): - """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: - start_time = time.time() - logger.info("Running stamina regeneration...") - - players_updated = await database.regenerate_all_players_stamina() - - elapsed = time.time() - start_time - if players_updated > 0: - logger.info(f"Regenerated stamina for {players_updated} players in {elapsed:.2f}s") - - # Alert if regeneration is taking too long (potential scaling issue) - if elapsed > 5.0: - logger.warning(f"โš ๏ธ Stamina regeneration took {elapsed:.2f}s (threshold: 5s) - check database load!") - - -async def check_combat_timers(shutdown_event): - """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: - start_time = time.time() - # 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) - - if idle_combats: - logger.info(f"Processing {len(idle_combats)} idle combats...") - - 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': - 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}") - - # Log performance for monitoring - if idle_combats: - elapsed = time.time() - start_time - logger.info(f"Processed {len(idle_combats)} idle combats in {elapsed:.2f}s") - - # Warn if taking too long (potential scaling issue) - if elapsed > 10.0: - logger.warning(f"โš ๏ธ Combat timer check took {elapsed:.2f}s (threshold: 10s) - consider batching!") - - -async def decay_corpses(shutdown_event): - """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: - start_time = time.time() - 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) - - elapsed = time.time() - start_time - 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 in {elapsed:.2f}s") - - -async def process_status_effects(shutdown_event): - """ - A background task that applies damage from persistent status effects. - Runs every 5 minutes to process status effect ticks. - """ - while not shutdown_event.is_set(): - try: - # Wait for 5 minutes before next processing cycle - await asyncio.wait_for(shutdown_event.wait(), timeout=300) - except asyncio.TimeoutError: - start_time = time.time() - logger.info("Running status effects processor...") - - try: - # Decrement all status effect ticks and get affected players - affected_players = await database.decrement_all_status_effect_ticks() - - if not affected_players: - elapsed = time.time() - start_time - logger.info(f"No active status effects to process ({elapsed:.3f}s)") - continue - - # Process each affected player - deaths = 0 - damage_dealt = 0 - - for player_id in affected_players: - try: - # Get current status effects (after decrement) - effects = await database.get_player_status_effects(player_id) - - if not effects: - continue - - # Calculate total damage - from bot.status_utils import calculate_status_damage - total_damage = calculate_status_damage(effects) - - if total_damage > 0: - damage_dealt += total_damage - player = await database.get_player(player_id) - - if not player or player['is_dead']: - continue - - new_hp = max(0, player['hp'] - total_damage) - - # Check if player died from status effects - if new_hp <= 0: - await database.update_player(player_id, {'hp': 0, 'is_dead': True}) - deaths += 1 - - # Create player corpse - inventory = await database.get_inventory(player_id) - await database.create_player_corpse( - player_name=player['name'], - location_id=player['location_id'], - items=inventory - ) - - # Remove status effects from dead player - await database.remove_all_status_effects(player_id) - - logger.info(f"Player {player['name']} (ID: {player_id}) died from status effects") - else: - # Apply damage - await database.update_player(player_id, {'hp': new_hp}) - - except Exception as e: - logger.error(f"Error processing status effects for player {player_id}: {e}") - - elapsed = time.time() - start_time - logger.info( - f"Processed status effects for {len(affected_players)} players " - f"({damage_dealt} total damage, {deaths} deaths) in {elapsed:.3f}s" - ) - - # Warn if taking too long (potential scaling issue) - if elapsed > 5.0: - logger.warning( - f"โš ๏ธ Status effects processing took {elapsed:.3f}s (threshold: 5s) " - f"- {len(affected_players)} players affected" - ) - - except Exception as e: - logger.error(f"Error in status effects processor: {e}") diff --git a/bot/combat.py b/bot/combat.py deleted file mode 100644 index 97b7427..0000000 --- a/bot/combat.py +++ /dev/null @@ -1,527 +0,0 @@ -""" -Combat system logic for turn-based NPC encounters. -""" - -import random -import json -import time -from typing import Dict, List, Tuple, Optional -from bot.api_client import api_client -from bot.utils import format_stat_bar -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 api_client.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 api_client.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 api_client.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 api_client.get_combat(player_id) - if not combat or combat['turn'] != 'player': - return ("It's not your turn!", False, False) - - player = await api_client.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 api_client.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 = "โ”โ”โ” YOUR TURN โ”โ”โ”\n" - 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 api_client.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 api_client.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 api_client.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) - }) - - # Show both health bars after player's turn - message += "\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n" - message += format_stat_bar("Your HP", "โค๏ธ", player['hp'], player['max_hp']) + "\n" - message += format_stat_bar(npc_def.name, npc_def.emoji, new_npc_hp, combat['npc_max_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 api_client.get_combat(player_id) - if not combat or combat['turn'] != 'npc': - return ("", False) - - player = await api_client.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 api_client.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 api_client.update_player(player_id, {'hp': new_player_hp}) - - message = "โ”โ”โ” ENEMY TURN โ”โ”โ”\n" - 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 api_client.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 api_client.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) - }) - - # Show both health bars after enemy's turn - message += "\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n" - message += format_stat_bar("Your HP", "โค๏ธ", new_player_hp, player['max_hp']) + "\n" - message += format_stat_bar(npc_def.name, npc_def.emoji, combat['npc_hp'], combat['npc_max_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 api_client.get_combat(player_id) - if not combat or combat['turn'] != 'player': - return ("It's not your turn!", False, False) - - player = await api_client.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 with full HP - await api_client.spawn_wandering_enemy( - npc_id=combat['npc_id'], - location_id=combat['location_id'], - current_hp=npc_def.hp, - max_hp=npc_def.hp - ) - - await api_client.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 api_client.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 with stacking. - Returns: (updated_effects, total_damage, message) - """ - from bot.status_utils import stack_status_effects - - if not effects: - return effects, 0, "" - - # Convert effect format if needed (name -> effect_name, damage_per_turn -> damage_per_tick) - normalized_effects = [] - for effect in effects: - normalized = { - 'effect_name': effect.get('name', effect.get('effect_name', 'Unknown')), - 'effect_icon': effect.get('icon', effect.get('effect_icon', 'โ“')), - 'damage_per_tick': effect.get('damage_per_turn', effect.get('damage_per_tick', 0)), - 'ticks_remaining': effect.get('turns_remaining', effect.get('ticks_remaining', 0)) - } - normalized_effects.append(normalized) - - # Stack effects - stacked = stack_status_effects(normalized_effects) - - total_damage = 0 - messages = [] - - for name, data in stacked.items(): - if data['total_damage'] > 0: - total_damage += data['total_damage'] - # Show stacked damage - if data['stacks'] > 1: - messages.append(f"{data['icon']} {name.replace('_', ' ').title()}: -{data['total_damage']} HP (ร—{data['stacks']})") - else: - messages.append(f"{data['icon']} {name.replace('_', ' ').title()}: -{data['total_damage']} 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 api_client.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 api_client.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 api_client.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 api_client.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 api_client.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 api_client.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 api_client.get_player(player_id) - inventory_items = await api_client.get_inventory(player_id) - - # Check if combat was with a wandering enemy that should respawn - combat = await api_client.get_combat(player_id) - if combat and combat.get('from_wandering_enemy', False): - # Respawn the enemy at the same location with full HP - npc_def = NPCS.get(combat['npc_id']) - await api_client.spawn_wandering_enemy( - npc_id=combat['npc_id'], - location_id=combat['location_id'], - current_hp=npc_def.hp, - max_hp=npc_def.hp - ) - - # 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 api_client.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 api_client.remove_item_from_inventory(item['id'], item['quantity']) - - # Mark player as dead and end any combat - await api_client.update_player(player_id, {'is_dead': True, 'hp': 0}) - await api_client.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 api_client.get_combat(player_id) - if not combat or combat['turn'] != 'player': - return ("It's not your turn!", False) - - item_data = await api_client.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 api_client.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 api_client.update_player(player_id, updates) - - # Remove item from inventory - if item_data['quantity'] > 1: - await api_client.update_inventory_item(item_db_id, item_data['quantity'] - 1) - else: - await api_client.remove_item_from_inventory(item_db_id, 1) - - # Using an item ends your turn - await api_client.update_combat(player_id, { - 'turn': 'npc', - 'turn_started_at': time.time() - }) - - return (message, True) diff --git a/bot/combat_handlers.py b/bot/combat_handlers.py deleted file mode 100644 index a529530..0000000 --- a/bot/combat_handlers.py +++ /dev/null @@ -1,165 +0,0 @@ -""" -Combat-related action handlers. -""" -import logging -from . import keyboards -from .api_client import api_client -from .utils import format_stat_bar -from data.world_loader import game_world - -logger = logging.getLogger(__name__) - - -async def handle_combat_attack(query, user_id: int, player: dict, data: list = None): - """Handle player attack action in combat.""" - 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 - - from .handlers import send_or_edit_with_image - 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: - from .handlers import send_or_edit_with_image - await send_or_edit_with_image(query, text=message, reply_markup=None) - else: - combat_data = await api_client.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) - - from .handlers import send_or_edit_with_image - 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) - - -async def handle_combat_flee(query, user_id: int, player: dict, data: list = None): - """Handle flee attempt from combat.""" - 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 - - from .handlers import send_or_edit_with_image - 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: - from .handlers import send_or_edit_with_image - await send_or_edit_with_image(query, text=message, reply_markup=None) - else: - combat_data = await api_client.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) - - from .handlers import send_or_edit_with_image - 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) - - -async def handle_combat_use_item_menu(query, user_id: int, player: dict, data: list = None): - """Show menu of usable items during combat.""" - await query.answer() - - -async def handle_combat_use_item(query, user_id: int, player: dict, data: list): - """Use an item during combat.""" - 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: - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=message + "\n\n" + npc_message, - reply_markup=None - ) - else: - combat_data = await api_client.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!" - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=full_message, - reply_markup=keyboard, - image_path=npc_def.image_url if npc_def else None - ) - - -async def handle_combat_back(query, user_id: int, player: dict, data: list = None): - """Return to combat menu from item selection.""" - await query.answer() - combat_data = await api_client.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 += format_stat_bar("Your HP", "โค๏ธ", player['hp'], player['max_hp']) + "\n" - message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n" - message += "๐ŸŽฏ Your turn!" if combat_data['turn'] == 'player' else "โณ Enemy's turn..." - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=message, - reply_markup=keyboard, - image_path=npc_def.image_url if npc_def else None - ) diff --git a/bot/commands.py b/bot/commands.py deleted file mode 100644 index dbd2974..0000000 --- a/bot/commands.py +++ /dev/null @@ -1,109 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Command handlers for the Telegram bot. -Handles slash commands like /start, /export_map, /spawn_stats. -""" -import logging -import os -import json -from io import BytesIO -from telegram import Update -from telegram.ext import ContextTypes -from . import keyboards -from .api_client import api_client -from .utils import admin_only -from .action_handlers import get_player_status_text -from data.world_loader import game_world - -logger = logging.getLogger(__name__) - - -async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle /start command - initialize or show player status.""" - from .api_client import api_client - - user = update.effective_user - player = await api_client.get_player(user.id) - - if not player: - player = await api_client.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 api_client.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 api_client.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 api_client.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 - - map_data = export_map_data() - json_str = json.dumps(map_data, indent=2) - - # Send as text file - 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(): - 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) diff --git a/bot/corpse_handlers.py b/bot/corpse_handlers.py deleted file mode 100644 index 6fdd205..0000000 --- a/bot/corpse_handlers.py +++ /dev/null @@ -1,235 +0,0 @@ -""" -Corpse looting handlers (player and NPC corpses). -""" -import logging -import json -import random -from . import keyboards, logic -from .api_client import api_client -from data.world_loader import game_world -from data.items import ITEMS - -logger = logging.getLogger(__name__) - - -async def handle_loot_player_corpse(query, user_id: int, player: dict, data: list): - """Show player corpse loot menu.""" - corpse_id = int(data[1]) - corpse = await api_client.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) - - 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..." - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboard, - image_path=image_path - ) - - -async def handle_take_corpse_item(query, user_id: int, player: dict, data: list): - """Take an item from a player corpse.""" - corpse_id = int(data[1]) - item_index = int(data[2]) - - corpse = await api_client.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 api_client.add_item_to_inventory(user_id, item_data['item_id'], item_data['quantity']) - - # Remove from corpse - items.pop(item_index) - - if items: - await api_client.update_player_corpse(corpse_id, json.dumps(items)) - keyboard = keyboards.player_corpse_loot_keyboard(corpse_id, items) - - 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" - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboard, - image_path=image_path - ) - else: - # Bag is empty, remove it - await api_client.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 api_client.get_dropped_items_in_location(player['location_id']) - wandering_enemies = await api_client.get_wandering_enemies_in_location(player['location_id']) - keyboard = await keyboards.inspect_keyboard(player['location_id'], dropped_items, wandering_enemies) - - from .handlers import send_or_edit_with_image - 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 - ) - - -async def handle_scavenge_npc_corpse(query, user_id: int, player: dict, data: list): - """Show NPC corpse scavenging menu.""" - corpse_id = int(data[1]) - corpse = await api_client.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) - - 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}" - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboard, - image_path=image_path - ) - - -async def handle_scavenge_corpse_item(query, user_id: int, player: dict, data: list): - """Scavenge a specific item from an NPC corpse.""" - corpse_id = int(data[1]) - loot_index = int(data[2]) - - corpse = await api_client.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 api_client.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 api_client.add_item_to_inventory(user_id, loot_data['item_id'], quantity) - - # Remove from corpse - loot_items.pop(loot_index) - - if loot_items: - await api_client.update_npc_corpse(corpse_id, json.dumps(loot_items)) - keyboard = keyboards.npc_corpse_scavenge_keyboard(corpse_id, loot_items) - - 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" - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboard, - image_path=image_path - ) - else: - # Nothing left, remove corpse - await api_client.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 api_client.get_dropped_items_in_location(player['location_id']) - wandering_enemies = await api_client.get_wandering_enemies_in_location(player['location_id']) - keyboard = await keyboards.inspect_keyboard(player['location_id'], dropped_items, wandering_enemies) - - from .handlers import send_or_edit_with_image - 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 - ) diff --git a/bot/database.py b/bot/database.py deleted file mode 100644 index ebbbcca..0000000 --- a/bot/database.py +++ /dev/null @@ -1,729 +0,0 @@ -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("id", Integer, unique=True, autoincrement=True), # Web users ID - Column("username", String(50), unique=True, nullable=True), # Web users username - Column("password_hash", String(255), nullable=True), # Web users password hash - 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 -) - -# Persistent status effects table -player_status_effects = Table( - "player_status_effects", - metadata, - Column("id", Integer, primary_key=True, autoincrement=True), - Column("player_id", Integer, ForeignKey("players.telegram_id", ondelete="CASCADE"), nullable=False), - Column("effect_name", String(50), nullable=False), - Column("effect_icon", String(10), nullable=False), - Column("damage_per_tick", Integer, nullable=False, default=0), - Column("ticks_remaining", Integer, nullable=False), - Column("applied_at", Float, nullable=False), -) - -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 = None, player_id: int = None, username: str = None): - """Get player by telegram_id, player_id (web users), or username.""" - async with engine.connect() as conn: - if telegram_id is not None: - result = await conn.execute(players.select().where(players.c.telegram_id == telegram_id)) - elif player_id is not None: - result = await conn.execute(players.select().where(players.c.id == player_id)) - elif username is not None: - result = await conn.execute(players.select().where(players.c.username == username)) - else: - return None - row = result.first() - return row._asdict() if row else None - -async def create_player(telegram_id: int = None, name: str = "Survivor", username: str = None, password_hash: str = None): - """Create a player (Telegram or web user).""" - async with engine.connect() as conn: - values = { - "name": name, - "telegram_id": telegram_id, - "username": username, - "password_hash": password_hash, - } - result = await conn.execute(players.insert().values(**values)) - await conn.commit() - - # For telegram users, the primary key is telegram_id - # For web users, we need to get the auto-generated id - if telegram_id: - # Add starting inventory for Telegram users - await conn.execute(inventory.insert().values(player_id=telegram_id, item_id="tattered_rucksack", is_equipped=True)) - await conn.commit() - - # Return the created player - if telegram_id: - return await get_player(telegram_id=telegram_id) - elif username: - return await get_player(username=username) - -async def update_player(telegram_id: int = None, player_id: int = None, updates: dict = None): - """Update player by telegram_id (Telegram users) or player_id (web users).""" - if updates is None: - updates = {} - async with engine.connect() as conn: - if telegram_id is not None: - await conn.execute(players.update().where(players.c.telegram_id == telegram_id).values(**updates)) - elif player_id is not None: - await conn.execute(players.update().where(players.c.id == player_id).values(**updates)) - else: - raise ValueError("Must provide either telegram_id or player_id") - 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 using a single optimized query. - - 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 - - PERFORMANCE: Single SQL query, scales to 100K+ players efficiently. - """ - from sqlalchemy import text - - async with engine.connect() as conn: - # Single UPDATE query with database-side calculation - # Much more efficient than fetching all players and updating individually - stmt = text(""" - UPDATE players - SET stamina = LEAST( - stamina + 1 + (endurance / 10), - max_stamina - ) - WHERE is_dead = FALSE - AND stamina < max_stamina - """) - - result = await conn.execute(stmt) - await conn.commit() - return result.rowcount - -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()] - - -# ============================================================================ -# STATUS EFFECTS -# ============================================================================ - -async def get_player_status_effects(player_id: int): - """Get all active status effects for a player.""" - async with engine.connect() as conn: - stmt = player_status_effects.select().where( - player_status_effects.c.player_id == player_id, - player_status_effects.c.ticks_remaining > 0 - ) - result = await conn.execute(stmt) - return [row._asdict() for row in result.fetchall()] - - -async def add_status_effect(player_id: int, effect_name: str, effect_icon: str, - damage_per_tick: int, ticks_remaining: int): - """Add a new status effect to a player.""" - async with engine.connect() as conn: - await conn.execute( - player_status_effects.insert().values( - player_id=player_id, - effect_name=effect_name, - effect_icon=effect_icon, - damage_per_tick=damage_per_tick, - ticks_remaining=ticks_remaining, - applied_at=time.time() - ) - ) - await conn.commit() - - -async def update_status_effect_ticks(effect_id: int, ticks_remaining: int): - """Update the remaining ticks for a status effect.""" - async with engine.connect() as conn: - await conn.execute( - player_status_effects.update().where( - player_status_effects.c.id == effect_id - ).values(ticks_remaining=ticks_remaining) - ) - await conn.commit() - - -async def remove_status_effect(effect_id: int): - """Remove a specific status effect.""" - async with engine.connect() as conn: - await conn.execute( - player_status_effects.delete().where(player_status_effects.c.id == effect_id) - ) - await conn.commit() - - -async def remove_all_status_effects(player_id: int): - """Remove all status effects from a player.""" - async with engine.connect() as conn: - await conn.execute( - player_status_effects.delete().where(player_status_effects.c.player_id == player_id) - ) - await conn.commit() - - -async def remove_status_effects_by_name(player_id: int, effect_name: str, count: int = 1): - """ - Remove a specific number of status effects by name for a player. - Used for treatment items that cure specific effects. - Returns the number of effects actually removed. - """ - async with engine.connect() as conn: - # Get the effects to remove - stmt = player_status_effects.select().where( - player_status_effects.c.player_id == player_id, - player_status_effects.c.effect_name == effect_name, - player_status_effects.c.ticks_remaining > 0 - ).limit(count) - result = await conn.execute(stmt) - effects_to_remove = result.fetchall() - - # Remove them - effect_ids = [row.id for row in effects_to_remove] - if effect_ids: - await conn.execute( - player_status_effects.delete().where( - player_status_effects.c.id.in_(effect_ids) - ) - ) - await conn.commit() - - return len(effect_ids) - - -async def get_all_players_with_status_effects(): - """Get all player IDs that have active status effects (for background processing).""" - async with engine.connect() as conn: - from sqlalchemy import distinct - stmt = player_status_effects.select().with_only_columns( - distinct(player_status_effects.c.player_id) - ).where(player_status_effects.c.ticks_remaining > 0) - result = await conn.execute(stmt) - return [row[0] for row in result.fetchall()] - - -async def decrement_all_status_effect_ticks(): - """ - Decrement ticks for all active status effects and return affected player IDs. - Used by background processor. - """ - async with engine.connect() as conn: - # Get player IDs with effects before updating - from sqlalchemy import distinct - stmt = player_status_effects.select().with_only_columns( - distinct(player_status_effects.c.player_id) - ).where(player_status_effects.c.ticks_remaining > 0) - result = await conn.execute(stmt) - affected_players = [row[0] for row in result.fetchall()] - - # Decrement ticks - await conn.execute( - player_status_effects.update().where( - player_status_effects.c.ticks_remaining > 0 - ).values(ticks_remaining=player_status_effects.c.ticks_remaining - 1) - ) - - # Remove expired effects - await conn.execute( - player_status_effects.delete().where(player_status_effects.c.ticks_remaining <= 0) - ) - - await conn.commit() - return affected_players diff --git a/bot/handlers.py b/bot/handlers.py deleted file mode 100644 index 6ee39db..0000000 --- a/bot/handlers.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -Main handlers for the Telegram bot. -This module contains the core button callback routing. -All other functionality is organized in separate modules: -- action_handlers.py - World interaction handlers -- inventory_handlers.py - Inventory management -- combat_handlers.py - Combat actions -- profile_handlers.py - Character stats -- corpse_handlers.py - Looting system -- pickup_handlers.py - Item collection -- message_utils.py - Message sending/editing utilities -- commands.py - Slash command handlers -""" -import logging -from telegram import Update -from telegram.ext import ContextTypes -from .message_utils import send_or_edit_with_image - -# Import organized action handlers -from .action_handlers import ( - handle_inspect_area, - handle_attack_wandering, - handle_inspect_interactable, - handle_action, - handle_main_menu, - handle_move_menu, - handle_move -) -from .inventory_handlers import ( - handle_inventory_menu, - handle_inventory_item, - handle_inventory_use, - handle_inventory_drop, - handle_inventory_equip, - handle_inventory_unequip -) -from .pickup_handlers import ( - handle_pickup_menu, - handle_pickup -) -from .combat_handlers import ( - handle_combat_attack, - handle_combat_flee, - handle_combat_use_item_menu, - handle_combat_use_item, - handle_combat_back -) -from .profile_handlers import ( - handle_profile, - handle_spend_points_menu, - handle_spend_point -) -from .corpse_handlers import ( - handle_loot_player_corpse, - handle_take_corpse_item, - handle_scavenge_npc_corpse, - handle_scavenge_corpse_item -) - -# Import command handlers (for main.py to register) -from .commands import start, export_map, spawn_stats - -logger = logging.getLogger(__name__) - - -# ============================================================================ -# HANDLER REGISTRY -# ============================================================================ - -# Map of action types to their handler functions -# All handlers have signature: async def handle_*(query, user_id, player, data=None) -HANDLER_MAP = { - # Inspection & World Interaction - 'inspect_area': handle_inspect_area, - 'inspect_area_menu': handle_inspect_area, - 'attack_wandering': handle_attack_wandering, - 'inspect': handle_inspect_interactable, - 'action': handle_action, - - # Navigation & Menu - 'main_menu': handle_main_menu, - 'move_menu': handle_move_menu, - 'move': handle_move, - - # Profile & Stats - 'profile': handle_profile, - 'spend_points_menu': handle_spend_points_menu, - 'spend_point': handle_spend_point, - - # Inventory Management - 'inventory_menu': handle_inventory_menu, - 'inventory_item': handle_inventory_item, - 'inventory_use': handle_inventory_use, - 'inventory_drop': handle_inventory_drop, - 'inventory_equip': handle_inventory_equip, - 'inventory_unequip': handle_inventory_unequip, - - # Item Pickup - 'pickup_menu': handle_pickup_menu, - 'pickup': handle_pickup, - - # Combat Actions - 'combat_attack': handle_combat_attack, - 'combat_flee': handle_combat_flee, - 'combat_use_item_menu': handle_combat_use_item_menu, - 'combat_use_item': handle_combat_use_item, - 'combat_back': handle_combat_back, - - # Corpse Looting - 'loot_player_corpse': handle_loot_player_corpse, - 'take_corpse_item': handle_take_corpse_item, - 'scavenge_npc_corpse': handle_scavenge_npc_corpse, - 'scavenge_corpse_item': handle_scavenge_corpse_item, -} - - -# ============================================================================ -# BUTTON CALLBACK ROUTER -# ============================================================================ - -async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """ - Main router for button callbacks. - Delegates to specific handler functions based on action type. - All handlers have a unified signature: (query, user_id, player, data=None) - - Note: user_id passed to handlers is actually the player's unique DB id (not telegram_id) - """ - from .api_client import api_client - - query = update.callback_query - telegram_id = query.from_user.id - data = query.data.split(':') - action_type = data[0] - - # Get player by telegram_id and translate to unique id - player = await api_client.get_player(telegram_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 - - # From now on, use player's unique database id - user_id = player['id'] - - # Check if player is in combat - restrict most actions - combat = await api_client.get_combat(user_id) - allowed_in_combat = { - 'combat_attack', 'combat_flee', 'combat_use_item_menu', - 'combat_use_item', 'combat_back', 'no_op' - } - if combat and action_type not in allowed_in_combat: - await query.answer("You're in combat! Focus on the fight!", show_alert=False) - return - - # Route to appropriate handler - if action_type == 'no_op': - await query.answer() - return - - handler = HANDLER_MAP.get(action_type) - if handler: - try: - await handler(query, user_id, player, data) - except Exception as e: - logger.error(f"Error handling button action {action_type}: {e}", exc_info=True) - await query.answer("An error occurred. Please try again.", show_alert=True) - else: - logger.warning(f"Unknown action type: {action_type}") - await query.answer("Unknown action", show_alert=False) diff --git a/bot/inventory_handlers.py b/bot/inventory_handlers.py deleted file mode 100644 index 376c991..0000000 --- a/bot/inventory_handlers.py +++ /dev/null @@ -1,338 +0,0 @@ -""" -Inventory-related action handlers. -""" -import logging -from telegram import InlineKeyboardButton, InlineKeyboardMarkup -from . import keyboards, logic -from data.world_loader import game_world -from data.items import ITEMS - -logger = logging.getLogger(__name__) - - -async def handle_inventory_menu(query, user_id: int, player: dict, data: list = None): - """Display player inventory with item management options.""" - from .utils import format_stat_bar - from .api_client import api_client - await query.answer() - - # Get inventory from API - inv_result = await api_client.get_inventory(player['id']) - inventory_items = inv_result.get('inventory', []) - 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"{format_stat_bar('HP', 'โค๏ธ', player['hp'], player['max_hp'])}\n" - text += f"{format_stat_bar('Stamina', 'โšก', player['stamina'], player['max_stamina'])}\n" - text += f"๐Ÿ“Š Weight: {current_weight}/{max_weight} kg\n" - text += f"๐Ÿ“ฆ Volume: {current_volume}/{max_volume} vol\n" - - if not inventory_items: - text += "\nYour inventory is empty." - - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboards.inventory_keyboard(inventory_items), - image_path=location_image - ) - - -async def handle_inventory_item(query, user_id: int, player: dict, data: list): - """Show details for a specific inventory item. - - Note: item_db_id is the inventory row id from the API response. - We need to get the full inventory and find the item by id. - """ - from .api_client import api_client - - await query.answer() - item_db_id = int(data[1]) - - # Get inventory from API - inv_result = await api_client.get_inventory(user_id) - inventory_items = inv_result.get('inventory', []) - - # Find the specific item - item = next((i for i in inventory_items if i['id'] == item_db_id), None) - if not item: - await query.answer("Item not found in inventory", show_alert=True) - return - - emoji = item.get('emoji', 'โ”') - - # Build item details text - text = f"{emoji} {item.get('name', 'Unknown')}\n" - - description = item.get('description') - if description: - text += f"{description}\n\n" - else: - text += "\n" - - text += f"Weight: {item.get('weight', 0)} kg | Volume: {item.get('volume', 0)} vol\n" - - # Add weapon stats if applicable - if item.get('type') == 'weapon': - text += f"Damage: {item.get('damage_min', 0)}-{item.get('damage_max', 0)}\n" - - # Add consumable effects if applicable - if item.get('type') == 'consumable': - effects = [] - if item.get('hp_restore'): - effects.append(f"โค๏ธ +{item.get('hp_restore')} HP") - if item.get('stamina_restore'): - effects.append(f"โšก +{item.get('stamina_restore')} Stamina") - if effects: - text += f"Effects: {', '.join(effects)}\n" - - # Add equipped status - if item.get('is_equipped'): - text += "\nโœ… Currently Equipped" - - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboards.inventory_item_actions_keyboard( - item_db_id, item, item.get('is_equipped', False), item['quantity'] - ), - image_path=location_image - ) - - -async def handle_inventory_use(query, user_id: int, player: dict, data: list): - """Use a consumable item from inventory.""" - from .utils import format_stat_bar - from .api_client import api_client - - item_db_id = int(data[1]) - - # Get inventory from API to find the item - inv_result = await api_client.get_inventory(user_id) - inventory_items = inv_result.get('inventory', []) - item = next((i for i in inventory_items if i['id'] == item_db_id), None) - - if not item: - await query.answer("Item not found.", show_alert=False) - return - - if item.get('type') != 'consumable': - await query.answer("This item cannot be used.", show_alert=False) - return - - await query.answer() - - # Use the API to use the item - result = await api_client.use_item(user_id, item['item_id']) - - if not result.get('success'): - await query.answer(result.get('message', 'Failed to use item'), show_alert=True) - return - - # Refresh player data to get updated stats - player = await api_client.get_player_by_id(user_id) - - # Get updated inventory - inv_result = await api_client.get_inventory(user_id) - inventory_items = inv_result.get('inventory', []) - current_weight, current_volume = logic.calculate_inventory_load(inventory_items) - max_weight, max_volume = logic.get_player_capacity(inventory_items, player) - - # Build status section with HP/Stamina bars - text = "๐ŸŽ’ Your Inventory:\n" - text += f"{format_stat_bar('HP', 'โค๏ธ', player['hp'], player['max_hp'])}\n" - text += f"{format_stat_bar('Stamina', 'โšก', player['stamina'], player['max_stamina'])}\n" - text += f"๐Ÿ“Š Weight: {current_weight}/{max_weight} kg\n" - text += f"๐Ÿ“ฆ Volume: {current_volume}/{max_volume} vol\n" - text += "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n" - - # Build result message from API response - text += result.get('message', 'Item used.') - - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboards.inventory_keyboard(inventory_items), - image_path=location_image - ) - - -async def handle_inventory_drop(query, user_id: int, player: dict, data: list): - """Drop an item from inventory to the world.""" - from .api_client import api_client - - item_db_id = int(data[1]) - drop_amount_str = data[2] if len(data) > 2 else None - - # Get inventory to find the item - inv_result = await api_client.get_inventory(user_id) - inventory_items = inv_result.get('inventory', []) - item = next((i for i in inventory_items if i['id'] == item_db_id), None) - - if not item: - await query.answer("Item not found.", show_alert=False) - return - - # Determine how much to drop - if drop_amount_str is None or drop_amount_str == "all": - drop_amount = item['quantity'] - else: - drop_amount = min(int(drop_amount_str), item['quantity']) - - # Use API to drop item - result = await api_client.drop_item(user_id, item['item_id'], drop_amount) - - if result.get('success'): - await query.answer(result.get('message', f"Dropped {drop_amount}x {item['name']}"), show_alert=False) - else: - await query.answer(result.get('message', 'Failed to drop item'), show_alert=True) - return - - # Get updated inventory - inv_result = await api_client.get_inventory(user_id) - inventory_items = inv_result.get('inventory', []) - 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." - - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboards.inventory_keyboard(inventory_items), - image_path=location_image - ) - - -async def handle_inventory_equip(query, user_id: int, player: dict, data: list): - """Equip an item from inventory.""" - from .api_client import api_client - - item_db_id = int(data[1]) - - # Get inventory to find the item - inv_result = await api_client.get_inventory(user_id) - inventory_items = inv_result.get('inventory', []) - item = next((i for i in inventory_items if i['id'] == item_db_id), None) - - if not item: - await query.answer("Item not found.", show_alert=False) - return - - if not item.get('equippable'): - await query.answer("This item cannot be equipped.", show_alert=False) - return - - # Use API to equip item - result = await api_client.equip_item(user_id, item['item_id']) - - if not result.get('success'): - await query.answer(result.get('message', 'Failed to equip item'), show_alert=True) - return - - await query.answer(result.get('message', f"Equipped {item['name']}!"), show_alert=False) - - # Refresh the item view - emoji = item.get('emoji', 'โ”') - text = f"{emoji} {item.get('name', 'Unknown')}\n" - - description = item.get('description') - if description: - text += f"{description}\n\n" - else: - text += "\n" - - text += f"Weight: {item.get('weight', 0)} kg | Volume: {item.get('volume', 0)} vol\n" - - if item.get('type') == 'weapon': - text += f"Damage: {item.get('damage_min', 0)}-{item.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 - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboards.inventory_item_actions_keyboard( - item_db_id, item, True, item['quantity'] - ), - image_path=location_image - ) - - -async def handle_inventory_unequip(query, user_id: int, player: dict, data: list): - """Unequip an item.""" - from .api_client import api_client - - item_db_id = int(data[1]) - - # Get inventory to find the item - inv_result = await api_client.get_inventory(user_id) - inventory_items = inv_result.get('inventory', []) - item = next((i for i in inventory_items if i['id'] == item_db_id), None) - - if not item: - await query.answer("Item not found.", show_alert=False) - return - - # Use API to unequip item - result = await api_client.unequip_item(user_id, item['item_id']) - - if not result.get('success'): - await query.answer(result.get('message', 'Failed to unequip item'), show_alert=True) - return - - await query.answer(result.get('message', f"Unequipped {item['name']}."), show_alert=False) - - # Refresh the item view - emoji = item.get('emoji', 'โ”') - text = f"{emoji} {item.get('name', 'Unknown')}\n" - - description = item.get('description') - if description: - text += f"{description}\n\n" - else: - text += "\n" - - text += f"Weight: {item.get('weight', 0)} kg | Volume: {item.get('volume', 0)} vol\n" - - if item.get('type') == 'weapon': - text += f"Damage: {item.get('damage_min', 0)}-{item.get('damage_max', 0)}\n" - - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboards.inventory_item_actions_keyboard( - item_db_id, item, False, item['quantity'] - ), - image_path=location_image - ) diff --git a/bot/keyboards.py b/bot/keyboards.py deleted file mode 100644 index 08bf7c2..0000000 --- a/bot/keyboards.py +++ /dev/null @@ -1,607 +0,0 @@ -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 logic - from bot.api_client import api_client - - keyboard = [] - location = game_world.get_location(current_location_id) - player = await api_client.get_player(player_id) - inventory = await api_client.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.api_client import api_client - 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 api_client.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 api_client.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 api_client.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.api_client import api_client - 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 api_client.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.api_client import api_client - 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 api_client.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")]) - - # Profile button (no effect on turn, just info) - keyboard.append([InlineKeyboardButton("๐Ÿ‘ค Profile", callback_data="profile")]) - - return InlineKeyboardMarkup(keyboard) - -async def combat_items_keyboard(player_id: int) -> InlineKeyboardMarkup: - """Show consumable items during combat.""" - from bot.api_client import api_client - keyboard = [] - - inventory_items = await api_client.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 deleted file mode 100644 index 527cbf8..0000000 --- a/bot/logic.py +++ /dev/null @@ -1,119 +0,0 @@ -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 .api_client import api_client - - player = await api_client.get_player(user_id) - if not player: - return False, "Player not found." - - inventory = await api_client.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/message_utils.py b/bot/message_utils.py deleted file mode 100644 index 346ca20..0000000 --- a/bot/message_utils.py +++ /dev/null @@ -1,121 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Message utility functions for sending and editing Telegram messages. -Handles image caching, smooth transitions, and message editing logic. -""" -import logging -import os -from telegram import InlineKeyboardMarkup, InputMediaPhoto -from telegram.error import BadRequest -from .api_client import api_client - -logger = logging.getLogger(__name__) - - -async def send_or_edit_with_image(query, text: str, reply_markup: InlineKeyboardMarkup, - image_path: str = None, parse_mode: str = 'HTML'): - """ - Send a message with an image (as caption) or edit existing message. - Uses edit_message_media for smooth transitions when changing images. - - Args: - query: The callback query object - text: Message text/caption - reply_markup: Inline keyboard markup - image_path: Optional path to image file - parse_mode: Parse mode for text (default 'HTML') - """ - current_message = query.message - has_photo = bool(current_message.photo) - - if image_path: - # Get or upload image - cached_file_id = await api_client.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 api_client.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 - 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}") - - # Current message has no photo - need to delete and send new - if not has_photo: - 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) diff --git a/bot/pickup_handlers.py b/bot/pickup_handlers.py deleted file mode 100644 index d817d4e..0000000 --- a/bot/pickup_handlers.py +++ /dev/null @@ -1,136 +0,0 @@ -""" -Pickup and item collection handlers. -""" -import logging -from . import keyboards, logic -from .api_client import api_client -from data.world_loader import game_world -from data.items import ITEMS - -logger = logging.getLogger(__name__) - - -async def handle_pickup_menu(query, user_id: int, player: dict, data: list): - """Show pickup options for a dropped item.""" - dropped_item_id = int(data[1]) - item_to_pickup = await api_client.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 api_client.get_dropped_items_in_location(location_id) - wandering_enemies = await api_client.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 - - from .handlers import send_or_edit_with_image - 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'] - ) - - location = game_world.get_location(player['location_id']) - image_path = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboard, - image_path=image_path - ) - - -async def handle_pickup(query, user_id: int, player: dict, data: list): - """Pick up a dropped item from the world.""" - dropped_item_id = int(data[1]) - pickup_amount_str = data[2] if len(data) > 2 else "all" - - item_to_pickup = await api_client.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 api_client.get_dropped_items_in_location(location_id) - wandering_enemies = await api_client.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 - - from .handlers import send_or_edit_with_image - 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 api_client.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 - item_def = ITEMS.get(item_to_pickup['item_id'], {}) - - if remaining > 0: - await api_client.update_dropped_item(dropped_item_id, remaining) - await query.answer( - f"Picked up {pickup_amount}x {item_def.get('name', 'item')}. {remaining} remaining.", - show_alert=False - ) - else: - await api_client.remove_dropped_item(dropped_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 api_client.get_dropped_items_in_location(location_id) - wandering_enemies = await api_client.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 - - from .handlers import send_or_edit_with_image - 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/profile_handlers.py b/bot/profile_handlers.py deleted file mode 100644 index 27eab1a..0000000 --- a/bot/profile_handlers.py +++ /dev/null @@ -1,169 +0,0 @@ -""" -Profile and character stat management handlers. -""" -import logging -from telegram import InlineKeyboardButton, InlineKeyboardMarkup -from . import keyboards -from data.world_loader import game_world - -logger = logging.getLogger(__name__) - - -async def handle_profile(query, user_id: int, player: dict, data: list = None): - """Display player profile with stats and level info.""" - from .utils import format_stat_bar - await query.answer() - from bot import combat - from .utils import format_stat_bar, create_progress_bar - - # 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) - 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) - - # Build profile with visual bars - profile_text = f"๐Ÿ‘ค {player['name']}\n" - profile_text += f"โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n\n" - profile_text += f"Level: {player['level']}\n" - - # XP bar - xp_bar = create_progress_bar(xp_progress, xp_level_requirement, length=10) - profile_text += f"โญ XP: {xp_bar} {progress_percent}% ({xp_current}/{xp_needed})\n" - - if unspent > 0: - profile_text += f"๐Ÿ’Ž Unspent Points: {unspent}\n" - - profile_text += f"\n{format_stat_bar('HP', 'โค๏ธ', player['hp'], player['max_hp'])}\n" - profile_text += f"{format_stat_bar('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\n" - - # Show status effects if any - try: - from .api_client import api_client - status_effects = await api_client.get_player_status_effects(user_id) - if status_effects: - from bot.status_utils import get_status_details - from .api_client import api_client - # Check if player is in combat - combat_state = await api_client.get_combat(user_id) - in_combat = combat_state is not None - profile_text += f"Status Effects:\n" - profile_text += get_status_details(status_effects, in_combat=in_combat) + "\n\n" - except: - pass # Status effects not critical, skip if error - - 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) - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=profile_text, - reply_markup=back_keyboard, - image_path=location_image - ) - - -async def handle_spend_points_menu(query, user_id: int, player: dict, data: list = None): - """Show menu for spending attribute points.""" - 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() - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image(query, text=text, reply_markup=keyboard) - - -async def handle_spend_point(query, user_id: int, player: dict, data: list): - """Spend a stat point on a specific attribute.""" - 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 - - from .api_client import api_client - await api_client.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() - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image(query, text=text, reply_markup=keyboard) diff --git a/bot/spawn_manager.py b/bot/spawn_manager.py deleted file mode 100644 index ed2cd05..0000000 --- a/bot/spawn_manager.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -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/status_utils.py b/bot/status_utils.py deleted file mode 100644 index 530db42..0000000 --- a/bot/status_utils.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -Status effect utilities for display and management. -""" -from collections import defaultdict - - -def stack_status_effects(effects: list) -> dict: - """ - Stack status effects by name, summing damage and counting stacks. - - Args: - effects: List of dicts with keys: effect_name, effect_icon, damage_per_tick, ticks_remaining - - Returns: - Dict with keys: effect_name -> {icon, total_damage, stacks, min_ticks, effects: [list of effect dicts]} - """ - stacked = defaultdict(lambda: { - 'icon': '', - 'total_damage': 0, - 'stacks': 0, - 'min_ticks': float('inf'), - 'max_ticks': 0, - 'effects': [] - }) - - for effect in effects: - name = effect['effect_name'] - stacked[name]['icon'] = effect['effect_icon'] - stacked[name]['total_damage'] += effect.get('damage_per_tick', 0) - stacked[name]['stacks'] += 1 - stacked[name]['min_ticks'] = min(stacked[name]['min_ticks'], effect['ticks_remaining']) - stacked[name]['max_ticks'] = max(stacked[name]['max_ticks'], effect['ticks_remaining']) - stacked[name]['effects'].append(effect) - - return dict(stacked) - - -def get_status_summary(effects: list, in_combat: bool = False) -> str: - """ - Generate compact status summary for display in menus. - - Args: - effects: List of status effect dicts - in_combat: If True, show "turns" instead of "cycles" - - Returns: - String like "Statuses: ๐Ÿฉธ (-4), โ˜ฃ๏ธ (-3)" or empty string if no effects - """ - if not effects: - return "" - - stacked = stack_status_effects(effects) - - if not stacked: - return "" - - parts = [] - for name, data in stacked.items(): - if data['total_damage'] > 0: - parts.append(f"{data['icon']} (-{data['total_damage']})") - else: - parts.append(f"{data['icon']}") - - return "Statuses: " + ", ".join(parts) - - -def get_status_details(effects: list, in_combat: bool = False) -> str: - """ - Generate detailed status display for profile menu. - - Args: - effects: List of status effect dicts - in_combat: If True, show "turns" instead of "cycles" - - Returns: - Multi-line string with detailed effect info - """ - if not effects: - return "No active status effects." - - stacked = stack_status_effects(effects) - - lines = [] - for name, data in stacked.items(): - # Build effect line - effect_line = f"{data['icon']} {name.replace('_', ' ').title()}" - - # Add damage info - if data['total_damage'] > 0: - effect_line += f": -{data['total_damage']} HP/{'turn' if in_combat else 'cycle'}" - - # Add tick info - if data['stacks'] == 1: - tick_unit = 'turn' if in_combat else 'cycle' - tick_count = data['min_ticks'] - effect_line += f" ({tick_count} {tick_unit}{'s' if tick_count != 1 else ''} left)" - else: - tick_unit = 'turns' if in_combat else 'cycles' - if data['min_ticks'] == data['max_ticks']: - effect_line += f" (ร—{data['stacks']}, {data['min_ticks']} {tick_unit} left)" - else: - effect_line += f" (ร—{data['stacks']}, {data['min_ticks']}-{data['max_ticks']} {tick_unit} left)" - - lines.append(effect_line) - - return "\n".join(lines) - - -def calculate_status_damage(effects: list) -> int: - """ - Calculate total damage from all status effects. - - Args: - effects: List of status effect dicts - - Returns: - Total damage per tick - """ - return sum(effect.get('damage_per_tick', 0) for effect in effects) diff --git a/bot/utils.py b/bot/utils.py deleted file mode 100644 index df90fe3..0000000 --- a/bot/utils.py +++ /dev/null @@ -1,128 +0,0 @@ -# -*- coding: utf-8 -*- -""" -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 create_progress_bar(current: int, maximum: int, length: int = 10, filled_char: str = "โ–ˆ", empty_char: str = "โ–‘") -> str: - """ - Create a visual progress bar. - - Args: - current: Current value - maximum: Maximum value - length: Length of the bar in characters (default 10) - filled_char: Character for filled portion (default โ–ˆ) - empty_char: Character for empty portion (default โ–‘) - - Returns: - String representation of progress bar - - Examples: - >>> create_progress_bar(75, 100) - "โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘" - >>> create_progress_bar(0, 100) - "โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘" - >>> create_progress_bar(100, 100) - "โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ" - """ - if maximum <= 0: - return empty_char * length - - percentage = min(1.0, max(0.0, current / maximum)) - filled_length = int(length * percentage) - empty_length = length - filled_length - - return filled_char * filled_length + empty_char * empty_length - - -def format_stat_bar(label: str, emoji: str, current: int, maximum: int, bar_length: int = 10, label_width: int = 7) -> str: - """ - Format a stat (HP, Stamina, etc.) with visual progress bar. - Uses right-aligned label format to avoid alignment issues with Telegram's proportional font. - - Args: - label: Stat label (e.g., "HP", "Stamina", "Your HP") - emoji: Emoji to display (e.g., "โค๏ธ", "โšก", "๐Ÿ•") - current: Current value - maximum: Maximum value - bar_length: Length of the progress bar - label_width: Not used, kept for backwards compatibility - - Returns: - Formatted string with bar on left, label on right - - Examples: - >>> format_stat_bar("HP", "โค๏ธ", 75, 100) - "โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 75% (75/100) โค๏ธ HP" - >>> format_stat_bar("Stamina", "โšก", 50, 100) - "โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘ 50% (50/100) โšก Stamina" - """ - bar = create_progress_bar(current, maximum, bar_length) - percentage = int((current / maximum * 100)) if maximum > 0 else 0 - - # Right-aligned format: bar first, then stats, then emoji + label - # This way bars are always left-aligned regardless of label length - if emoji: - return f"{bar} {percentage}% ({current}/{maximum}) {emoji} {label}" - else: - # If no emoji provided, just use label - return f"{bar} {percentage}% ({current}/{maximum}) {label}" - - - -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/main.py b/main.py deleted file mode 100644 index 3f7c08f..0000000 --- a/main.py +++ /dev/null @@ -1,87 +0,0 @@ -import asyncio -import logging -import signal -import os - -from dotenv import load_dotenv -from telegram import Update -from telegram.ext import Application, CommandHandler, CallbackQueryHandler - -from bot import database, handlers -from bot import background_tasks - -# 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 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 - logger.info("Starting background tasks...") - decay_task = asyncio.create_task(background_tasks.decay_dropped_items(shutdown_event)) - stamina_task = asyncio.create_task(background_tasks.regenerate_stamina(shutdown_event)) - combat_timer_task = asyncio.create_task(background_tasks.check_combat_timers(shutdown_event)) - corpse_decay_task = asyncio.create_task(background_tasks.decay_corpses(shutdown_event)) - status_effects_task = asyncio.create_task(background_tasks.process_status_effects(shutdown_event)) - logger.info("โœ… All background tasks started") - - await shutdown_event.wait() - - await application.updater.stop() - await application.stop() - - # Ensure the background tasks are also cancelled on shutdown - logger.info("Stopping background tasks...") - decay_task.cancel() - stamina_task.cancel() - combat_timer_task.cancel() - corpse_decay_task.cancel() - status_effects_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/pwa/src/components/Game_OLD_BACKUP.tsx b/pwa/src/components/Game_OLD_BACKUP.tsx deleted file mode 100644 index d1d084e..0000000 --- a/pwa/src/components/Game_OLD_BACKUP.tsx +++ /dev/null @@ -1,3314 +0,0 @@ -import { useState, useEffect, useRef } from 'react' -import api from '../services/api' -import GameHeader from './GameHeader' -import { useGameWebSocket } from '../hooks/useGameWebSocket' -import './Game.css' - -interface PlayerState { - location_id: string - location_name: string - health: number - max_health: number - stamina: number - max_stamina: number - inventory: any[] - status_effects: any[] -} - -interface DirectionDetail { - direction: string - stamina_cost: number - distance: number - destination: string - destination_name?: string -} - -interface Location { - id: string - name: string - description: string - directions: string[] - directions_detailed?: DirectionDetail[] - danger_level?: number - npcs: any[] - items: any[] - image_url?: string - interactables?: any[] - other_players?: any[] - corpses?: any[] - tags?: string[] // Tags for special location features like workbench -} - -interface Profile { - name: string - level: number - xp: number - hp: number - max_hp: number - stamina: number - max_stamina: number - strength: number - agility: number - endurance: number - intellect: number - unspent_points: number - is_dead: boolean - max_weight?: number - current_weight?: number - max_volume?: number - current_volume?: number -} - -function Game() { - const [playerState, setPlayerState] = useState(null) - const [location, setLocation] = useState(null) - const [profile, setProfile] = useState(null) - const [loading, setLoading] = useState(true) - const [message, setMessage] = useState('') - const [selectedItem, setSelectedItem] = useState(null) - const [combatState, setCombatState] = useState(null) - const [combatLog, setCombatLog] = useState>([]) - const [enemyName, setEnemyName] = useState('') - const [enemyImage, setEnemyImage] = useState('') - const [collapsedCategories, setCollapsedCategories] = useState>(new Set()) - const [expandedCorpse, setExpandedCorpse] = useState(null) - const [corpseDetails, setCorpseDetails] = useState(null) - const [movementCooldown, setMovementCooldown] = useState(0) - const [enemyTurnMessage, setEnemyTurnMessage] = useState('') - const [equipment, setEquipment] = useState({}) - const [showCraftingMenu, setShowCraftingMenu] = useState(false) - const [showRepairMenu, setShowRepairMenu] = useState(false) - const [craftableItems, setCraftableItems] = useState([]) - const [repairableItems, setRepairableItems] = useState([]) - const [workbenchTab, setWorkbenchTab] = useState<'craft' | 'repair' | 'uncraft'>('craft') - const [craftFilter, setCraftFilter] = useState('') - const [craftCategoryFilter, setCraftCategoryFilter] = useState('all') - const [repairFilter, setRepairFilter] = useState('') - const [uncraftFilter, setUncraftFilter] = useState('') - const [uncraftableItems, setUncraftableItems] = useState([]) - const [lastSeenPvPAction, setLastSeenPvPAction] = useState(null) - - // Use ref for synchronous duplicate checking (state updates are async) - const lastSeenPvPActionRef = useRef(null) - - // Client-side PvP timer that counts down every second - const [pvpTimeRemaining, setPvpTimeRemaining] = useState(null) - const pvpTimerRef = useRef(null) - - // Mobile menu state - const [mobileMenuOpen, setMobileMenuOpen] = useState<'none' | 'left' | 'right' | 'bottom'>('none') - const [mobileHeaderOpen, setMobileHeaderOpen] = useState(false) - - // Location message log (cleared when changing locations) - const [locationMessages, setLocationMessages] = useState>([]) - - // Interactable cooldown timers (instance_id -> expiry timestamp) - const [interactableCooldowns, setInteractableCooldowns] = useState>({}) - - // Force re-render for countdown updates - const [forceUpdate, setForceUpdate] = useState(0) - - // Get auth token from localStorage only once on mount - const [token] = useState(() => localStorage.getItem('token')) - - // Handle WebSocket messages - const handleWebSocketMessage = async (message: any) => { - console.log('๐Ÿ“จ WebSocket message:', message.type) - - switch (message.type) { - case 'connected': - console.log('โœ… WebSocket connected') - break - - case 'state_update': - // Update player state from WebSocket - if (message.data?.player) { - const player = message.data.player - setPlayerState(prev => prev ? { - ...prev, - health: player.hp ?? prev.health, - max_health: player.max_hp ?? prev.max_health, - stamina: player.stamina ?? prev.stamina, - max_stamina: player.max_stamina ?? prev.max_stamina, - location_id: player.location_id ?? prev.location_id, - location_name: message.data.location?.name ?? prev.location_name - } : null) - - // Update profile if level/xp changed - if (player.level !== undefined || player.xp !== undefined) { - setProfile(prev => prev ? { - ...prev, - level: player.level ?? prev.level, - xp: player.xp ?? prev.xp - } : null) - } - } - - // Handle movement-triggered location change - if (message.data?.location) { - fetchGameData() - } - - // Handle encounter - if (message.data?.encounter) { - setMessage(message.data.encounter.message || 'An enemy ambushes you!') - if (message.data.encounter.combat) { - setCombatState(message.data.encounter.combat) - } - // Fetch full game data to get complete combat state - fetchGameData() - } - break - - case 'combat_started': - // New combat initiated (PvE or PvP) - if (message.data) { - if (message.data.message) { - setMessage(message.data.message) - } - if (message.data.combat) { - setCombatState(message.data.combat) - } - // Fetch full game data to ensure combat UI is shown (includes PvP) - fetchGameData() - } - break - - case 'combat_update': - // Update combat state from WebSocket (both PvE and PvP) - // NOTE: Do NOT add log entries here for actions initiated by this player - // The HTTP response handler already adds them. WebSocket combat_update is - // for updating the UI state (HP, turn, combat_over) and for opponent actions - if (message.data) { - // Handle both PvE combat (combat) and PvP combat (pvp_combat) - if (message.data.combat) { - // PvE combat update - setCombatState(message.data.combat) - } else if (message.data.pvp_combat) { - // PvP combat update - need to format it like the API response - const pvpData = message.data.pvp_combat - - // Check if we have complete attacker/defender data - // If not, we need to fetch the full PvP state - if (!pvpData.attacker || !pvpData.defender) { - // WebSocket sent incomplete data, fetch full state - console.log('โš ๏ธ Incomplete PvP data in WebSocket, fetching full state...') - try { - const pvpRes = await api.get('/api/game/pvp/status') - if (pvpRes.data.in_pvp_combat) { - setCombatState(pvpRes.data) - } - } catch (err) { - console.error('Failed to fetch PvP status:', err) - } - } else { - // Update HP in the pvp_combat data - if (pvpData.attacker) { - pvpData.attacker.hp = message.data.attacker_hp - } - if (pvpData.defender) { - pvpData.defender.hp = message.data.defender_hp - } - const combatState = { - is_pvp: true, - in_pvp_combat: true, - pvp_combat: pvpData - } - setCombatState(combatState) - } - } else if (message.data.combat_over) { - setCombatState(null) - } - - // Update player HP/XP/Level from WebSocket data (no API call needed) - if (message.data.player) { - const player = message.data.player - setProfile(prev => prev ? { - ...prev, - hp: player.hp ?? prev.hp, - xp: player.xp ?? prev.xp, - level: player.level ?? prev.level - } : null) - // Also update playerState so HP bar reflects changes immediately - setPlayerState(prev => prev ? { - ...prev, - health: player.hp ?? prev.health - } : null) - } - - // Don't fetch game data on every combat update - WebSocket data is sufficient - // The combat state and player stats are already updated above - // Only fetch if combat ended to refresh location (corpses, etc.) - if (message.data.combat_over) { - await fetchLocationData() // Only fetch location, not full game data - } - } - break - - case 'inventory_update': - // Refresh inventory data - fetchGameData() - break - - case 'player_arrived': - // Handle player arriving at location (from PvP acknowledgment or regular movement) - if (message.data && message.data.message) { - addLocationMessage(message.data.message) - // Check for player data in either format (player_name or username) - const hasPlayerData = message.data.player_id && - (message.data.player_name || message.data.username) - if (!hasPlayerData) { - await fetchLocationData() - } else { - // Update local state with the new player data - // This avoids the API call - const playerName = message.data.player_name || message.data.username - const playerId = message.data.player_id - const playerLevel = message.data.player_level || 1 - const canPvp = message.data.can_pvp || false - - console.log('Player arrived, adding to location:', playerName) - - // Add player to location players list - setLocation(prev => { - if (!prev) return prev - - // Check if player already in list - const playerExists = prev.other_players?.some((p: any) => p.id === playerId) - if (playerExists) { - return prev - } - - return { - ...prev, - other_players: [ - ...(prev.other_players || []), - { - id: playerId, - name: playerName, - level: playerLevel, - can_pvp: canPvp - } - ] - } - }) - } - } - break - - case 'location_update': - // General location updates (items dropped, combat started/ended, corpses looted, etc.) - if (message.data && message.data.message) { - addLocationMessage(message.data.message) - - // Only fetch location data when location state actually changes - // Skip for: player movements, item pickups, enemy spawns/despawns (data in message) - // Fetch for: item drops (added to ground), corpse loots (state changes), etc. - const action = message.data.action - if (action === 'player_arrived') { - // Player arrived - update local state - console.log('Location update: player arrived, updating state') - const hasPlayerData = message.data.player_id && - (message.data.player_name || message.data.username) - if (hasPlayerData) { - const playerName = message.data.player_name || message.data.username - const playerId = message.data.player_id - const playerLevel = message.data.player_level || 1 - const canPvp = message.data.can_pvp || false - - setLocation(prev => { - if (!prev) return prev - - // Check if player already in list - const playerExists = prev.other_players?.some((p: any) => p.id === playerId) - if (playerExists) { - return prev - } - - return { - ...prev, - other_players: [ - ...(prev.other_players || []), - { - id: playerId, - name: playerName, - level: playerLevel, - can_pvp: canPvp - } - ] - } - }) - } - } else if (action === 'player_left' && message.data.player_id) { - // Remove player from local state without fetching - console.log('Player left, removing from location:', message.data.player_name) - setLocation(prev => { - if (!prev) return prev - return { - ...prev, - other_players: (prev.other_players || []).filter((p: any) => p.id !== message.data.player_id) - } - }) - } else if (action === 'player_died' && message.data.player_id) { - // Player died - remove from other_players and add corpse directly - console.log('Player died, adding corpse to location:', message.data.corpse) - setLocation(prev => { - if (!prev) return prev - - // Remove from other_players - const updatedOtherPlayers = (prev.other_players || []).filter((p: any) => p.id !== message.data.player_id) - - // Add corpse if provided in message - let updatedCorpses = prev.corpses || [] - if (message.data.corpse) { - updatedCorpses = [...updatedCorpses, message.data.corpse] - } - - return { - ...prev, - other_players: updatedOtherPlayers, - corpses: updatedCorpses - } - }) - } else if (action === 'player_corpse_looted' && message.data.corpse_id) { - // Someone looted from a player corpse - update the corpse's items - console.log('Player corpse looted, updating items:', message.data) - setLocation(prev => { - if (!prev || !prev.corpses) return prev - - return { - ...prev, - corpses: prev.corpses.map((corpse: any) => { - if (corpse.id === message.data.corpse_id) { - return { - ...corpse, - items: message.data.remaining_items, - loot_count: message.data.remaining_items.length - } - } - return corpse - }) - } - }) - } else if (action === 'player_corpse_emptied' && message.data.corpse_id) { - // Player corpse fully looted - but keep it visible (empty for 24h) - console.log('Player corpse emptied:', message.data.corpse_id) - setLocation(prev => { - if (!prev || !prev.corpses) return prev - - return { - ...prev, - corpses: prev.corpses.map((corpse: any) => { - if (corpse.id === message.data.corpse_id) { - return { - ...corpse, - items: [], - loot_count: 0 - } - } - return corpse - }) - } - }) - } else if (action === 'item_picked_up') { - // Don't fetch - no location state change - console.log('Location update (no fetch needed):', action) - } else if (action === 'enemy_spawned' && message.data.npc_data) { - // Add enemy to local state without fetching - console.log('Enemy spawned, updating local state:', message.data.npc_data) - setLocation(prev => { - if (!prev) return prev - return { - ...prev, - npcs: [...(prev.npcs || []), message.data.npc_data] - } - }) - } else if (action === 'enemy_despawned' && message.data.enemy_id) { - // Remove enemy from local state without fetching - console.log('Enemy despawned, updating local state:', message.data.enemy_id) - setLocation(prev => { - if (!prev) return prev - return { - ...prev, - npcs: (prev.npcs || []).filter((npc: any) => - !(npc.type === 'enemy' && npc.is_wandering && npc.id === message.data.enemy_id) - ) - } - }) - } else { - // Fetch for item_dropped, corpse_looted, and other state-changing actions - await fetchLocationData() - } - } - break - - case 'interactable_cooldown': - // An interactable was used and is now on cooldown - if (message.data) { - const { instance_id, cooldown_remaining, message: msg, action_id } = message.data - if (instance_id && action_id && cooldown_remaining) { - const cooldownKey = `${instance_id}:${action_id}` - // Convert cooldown_remaining (seconds) to expiry timestamp - const cooldownExpiry = Date.now() / 1000 + cooldown_remaining - setInteractableCooldowns(prev => ({ - ...prev, - [cooldownKey]: cooldownExpiry - })) - } - if (msg) { - addLocationMessage(msg) - } - // No need to refresh - we already have the cooldown in state - } - break - - case 'item_picked_up': - // Another player picked up an item - if (message.player_name) { - // Refresh location to update dropped items - fetchGameData() - } - break - - case 'error': - console.error('โŒ WebSocket error:', message.message) - break - - default: - console.log('โš ๏ธ Unhandled WebSocket message type:', message.type) - } - } - - // Initialize WebSocket connection - const { isConnected } = useGameWebSocket({ - token, - onMessage: handleWebSocketMessage, - enabled: !!token - }) - - // Note: sendMessage available from hook but not used yet - // Future: Use for sending chat messages, emotes, etc. - - useEffect(() => { - fetchGameData() - - // Set up fallback polling (less aggressive when WebSocket is active) - // WebSocket provides real-time updates, polling is just a backup - const pollInterval = setInterval(() => { - // Stop polling if combat is over (save server resources) - if (combatState?.pvp_combat?.combat_over) { - return - } - - // If WebSocket is connected, skip polling entirely (WebSocket provides real-time updates) - if (isConnected) { - return - } - - // Only poll if: - // 1. Not in PvP combat (need to detect incoming PvP), OR - // 2. In PvP combat but it's opponent's turn (need to see their actions) - const shouldPoll = !combatState?.in_pvp_combat || !combatState?.pvp_combat?.your_turn - - if (!document.hidden && shouldPoll) { - fetchGameData(true) - } - }, 5000) // Poll every 5s when WebSocket is NOT connected - - // Cleanup on unmount - return () => clearInterval(pollInterval) - // Only recreate interval when WebSocket connection status changes - }, [isConnected]) - - // Client-side countdown timer for PvP - runs every second - useEffect(() => { - // Clear any existing timer - if (pvpTimerRef.current) { - clearInterval(pvpTimerRef.current) - pvpTimerRef.current = null - } - - // Only run timer if in PvP combat and combat is not over - if (combatState?.in_pvp_combat && !combatState?.pvp_combat?.combat_over) { - // Initialize timer from server value - setPvpTimeRemaining(combatState.pvp_combat.time_remaining) - - // Start countdown that decreases every second - pvpTimerRef.current = setInterval(() => { - setPvpTimeRemaining(prev => { - if (prev === null || prev <= 0) return 0 - return prev - 1 - }) - }, 1000) - } else { - setPvpTimeRemaining(null) - } - - return () => { - if (pvpTimerRef.current) { - clearInterval(pvpTimerRef.current) - pvpTimerRef.current = null - } - } - }, [combatState?.in_pvp_combat, combatState?.pvp_combat?.combat_over]) - - // Sync client timer with server time on each poll - useEffect(() => { - if (combatState?.in_pvp_combat && combatState?.pvp_combat?.time_remaining !== undefined) { - setPvpTimeRemaining(combatState.pvp_combat.time_remaining) - } - }, [combatState?.pvp_combat?.time_remaining]) - - // Auto-dismiss messages after 4 seconds on mobile - useEffect(() => { - if (message && window.innerWidth <= 768) { - const timer = setTimeout(() => { - setMessage('') - }, 4000) - return () => clearTimeout(timer) - } - }, [message]) - - // Countdown effect for movement cooldown - useEffect(() => { - if (movementCooldown > 0) { - const timer = setTimeout(() => { - setMovementCooldown(prev => Math.max(0, prev - 1)) - }, 1000) - return () => clearTimeout(timer) - } - }, [movementCooldown]) - - // Countdown effect for interactable cooldowns - useEffect(() => { - const hasActiveCooldowns = Object.keys(interactableCooldowns).length > 0 - if (!hasActiveCooldowns) return - - const timer = setInterval(() => { - const now = Date.now() / 1000 // Current time in seconds - setInteractableCooldowns(prev => { - const updated = { ...prev } - let changed = false - - // Remove expired cooldowns - Object.keys(updated).forEach(instanceId => { - if (updated[instanceId] <= now) { - delete updated[instanceId] - changed = true - } - }) - - return changed ? updated : prev - }) - - // Force re-render every second to update countdown display - setForceUpdate(Date.now()) - }, 1000) - - return () => clearInterval(timer) - }, [Object.keys(interactableCooldowns).length]) // Only recreate when cooldowns are added/removed - - // Targeted fetch functions for specific data - const fetchLocationData = async () => { - try { - console.log('๐Ÿ”„ Fetching location data...') - const locationRes = await api.get('/api/game/location') - console.log('โœ… Location data received, setting location state') - setLocation(locationRes.data) - } catch (err) { - console.error('Failed to fetch location:', err) - } - } - - const fetchPlayerState = async () => { - try { - const stateRes = await api.get('/api/game/state') - const gameState = stateRes.data - setPlayerState({ - location_id: gameState.player.location_id, - location_name: gameState.location?.name || 'Unknown', - health: gameState.player.hp, - max_health: gameState.player.max_hp, - stamina: gameState.player.stamina, - max_stamina: gameState.player.max_stamina, - inventory: gameState.inventory || [], - status_effects: [] - }) - setEquipment(gameState.equipment || {}) - - // Set movement cooldown if available - if (gameState.player.movement_cooldown !== undefined) { - const cooldown = gameState.player.movement_cooldown - setMovementCooldown(cooldown > 0 ? Math.ceil(cooldown) + 1 : 0) - } - } catch (err) { - console.error('Failed to fetch player state:', err) - } - } - - const fetchGameData = async (skipCombatLogInit: boolean = false) => { - // Note: fetchPlayerState and fetchLocationData are targeted helpers for WebSocket updates - // They're kept here for use by WebSocket handlers but not by fetchGameData - void fetchPlayerState // Silence unused warning - void fetchLocationData // Silence unused warning - void forceUpdate // Silence unused warning (used in interactable countdown) - try { - const [stateRes, locationRes, profileRes, combatRes, pvpRes] = await Promise.all([ - api.get('/api/game/state'), - api.get('/api/game/location'), - api.get('/api/game/profile'), - api.get('/api/game/combat'), - api.get('/api/game/pvp/status') - ]) - - // Map game state to player state format - const gameState = stateRes.data - setPlayerState({ - location_id: gameState.player.location_id, - location_name: gameState.location?.name || 'Unknown', - health: gameState.player.hp, - max_health: gameState.player.max_hp, - stamina: gameState.player.stamina, - max_stamina: gameState.player.max_stamina, - inventory: gameState.inventory || [], - status_effects: [] - }) - - setLocation(locationRes.data) - setProfile(profileRes.data.player || profileRes.data) - setEquipment(gameState.equipment || {}) - - // Initialize interactable cooldowns from location data - if (locationRes.data.interactables) { - const cooldowns: Record = {} - for (const interactable of locationRes.data.interactables) { - if (interactable.actions) { - for (const action of interactable.actions) { - if (action.on_cooldown && action.cooldown_remaining > 0) { - const cooldownKey = `${interactable.instance_id}:${action.id}` - cooldowns[cooldownKey] = Date.now() / 1000 + action.cooldown_remaining - } - } - } - } - // Merge with existing cooldowns instead of replacing to avoid race conditions - setInteractableCooldowns(prev => ({ ...prev, ...cooldowns })) - } - - // Set movement cooldown if available (add 1 second buffer only if there's actual cooldown) - if (gameState.player.movement_cooldown !== undefined) { - const cooldown = gameState.player.movement_cooldown - setMovementCooldown(cooldown > 0 ? Math.ceil(cooldown) + 1 : 0) - } - - // Check for PvP combat first (takes priority) - if (pvpRes.data.in_pvp_combat) { - const newCombatState = { - ...pvpRes.data, - is_pvp: true - } - - setCombatState(newCombatState) - - // Check if there's a new last_action to add to combat log (avoid duplicates) - // Use ref for synchronous check to prevent race conditions with state updates - if (pvpRes.data.pvp_combat.last_action && - pvpRes.data.pvp_combat.last_action !== lastSeenPvPActionRef.current) { - - // Update both state and ref - setLastSeenPvPAction(pvpRes.data.pvp_combat.last_action) - lastSeenPvPActionRef.current = pvpRes.data.pvp_combat.last_action - - const now = new Date() - const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) - - // Parse the action message (format: "message|timestamp") - const lastActionRaw = pvpRes.data.pvp_combat.last_action - const [lastAction, _actionTimestamp] = lastActionRaw.split('|') - - const yourUsername = pvpRes.data.pvp_combat.is_attacker ? - pvpRes.data.pvp_combat.attacker.username : - pvpRes.data.pvp_combat.defender.username - - // Check if the message starts with your username (e.g., "YourName attacks" or "YourName fled") - const isYourAction = lastAction.startsWith(yourUsername + ' ') - - setCombatLog((prev: any) => [{ - time: timeStr, - message: lastAction, - isPlayer: isYourAction - }, ...prev]) - } - - // Initialize combat log if empty - if (!skipCombatLogInit && combatLog.length === 0) { - const now = new Date() - const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) - const opponent = pvpRes.data.pvp_combat.is_attacker ? - pvpRes.data.pvp_combat.defender : - pvpRes.data.pvp_combat.attacker - setCombatLog([{ - time: timeStr, - message: `PvP combat with ${opponent.username} (Lv. ${opponent.level})!`, - isPlayer: true - }]) - } - - // Combat over state is handled in the UI with an acknowledgment button - // Don't auto-close anymore - } - // If not in PvP combat anymore, clear the tracking - else if (lastSeenPvPAction !== null) { - setLastSeenPvPAction(null) - lastSeenPvPActionRef.current = null - } - // Check for active PvE combat - else if (combatRes.data.in_combat) { - setCombatState(combatRes.data) - // Only initialize combat log if it's empty AND we're not skipping initialization - // Skip initialization after encounters since they already set the combat log - if (!skipCombatLogInit && combatLog.length === 0) { - const now = new Date() - const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) - setCombatLog([{ - time: timeStr, - message: 'Combat in progress...', - isPlayer: true - }]) - } - } - } catch (error) { - console.error('Failed to fetch game data:', error) - setMessage('Failed to load game data') - } finally { - setLoading(false) - } - } - - // Helper function to add messages to location log - const addLocationMessage = (msg: string) => { - console.log('๐Ÿ“ Adding location message:', msg) - const now = new Date() - const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) - setLocationMessages(prev => { - console.log('๐Ÿ“ Current location messages:', prev.length, 'Adding:', msg) - return [...prev, { time: timeStr, message: msg }] - }) - setMessage(msg) - } - - const handleMove = async (direction: string) => { - // Prevent movement during combat - if (combatState) { - setMessage('Cannot move while in combat!') - return - } - - // Close workbench menu when moving - if (showCraftingMenu || showRepairMenu) { - handleCloseCrafting() - } - - // Close mobile menu after movement - setMobileMenuOpen('none') - - try { - setMessage('Moving...') - const response = await api.post('/api/game/move', { direction }) - setMessage(response.data.message) - - // Clear location messages when changing locations - setLocationMessages([]) - - // Check if an encounter was triggered - if (response.data.encounter && response.data.encounter.triggered) { - const encounter = response.data.encounter - setMessage(encounter.message) - - // Store enemy info - setEnemyName(encounter.combat.npc_name) - setEnemyImage(encounter.combat.npc_image) - - // Set combat state - setCombatState({ - in_combat: true, - combat_over: false, - player_won: false, - combat: encounter.combat - }) - - // Clear combat log for new encounter - setCombatLog([]) - - // Add initial message to combat log - const now = new Date() - const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) - setCombatLog([{ - time: timeStr, - message: `โš ๏ธ ${encounter.combat.npc_name} ambushes you!`, - isPlayer: false - }]) - - // Refresh all game data after movement, but skip combat log init since we just set it - await fetchGameData(true) - } else { - // Normal movement, refresh game data normally - await fetchGameData() - } - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Move failed') - } - } - - const handlePickup = async (itemId: number, quantity: number = 1) => { - try { - setMessage(`Picking up ${quantity > 1 ? quantity + ' items' : 'item'}...`) - const response = await api.post('/api/game/pickup', { item_id: itemId, quantity }) - const msg = response.data.message || 'Item picked up!' - addLocationMessage(msg) - fetchGameData() // Refresh to update inventory and ground items - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Failed to pick up item') - // Refresh to remove items that no longer exist - fetchGameData() - } - } - - const handleOpenCrafting = async () => { - try { - const response = await api.get('/api/game/craftable') - setCraftableItems(response.data.craftable_items) - setShowCraftingMenu(true) - setShowRepairMenu(false) - setWorkbenchTab('craft') - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Failed to load crafting menu') - } - } - - const handleCloseCrafting = () => { - setShowCraftingMenu(false) - setShowRepairMenu(false) - setCraftableItems([]) - setRepairableItems([]) - setUncraftableItems([]) - setCraftFilter('') - setRepairFilter('') - setUncraftFilter('') - } - - const handleCraft = async (itemId: string) => { - try { - setMessage('Crafting...') - const response = await api.post('/api/game/craft_item', { item_id: itemId }) - setMessage(response.data.message || 'Item crafted!') - await fetchGameData() - // Refresh craftable items list - const craftableRes = await api.get('/api/game/craftable') - setCraftableItems(craftableRes.data.craftable_items) - // Refresh salvageable items if on that tab - if (workbenchTab === 'uncraft') { - const salvageableRes = await api.get('/api/game/salvageable') - setUncraftableItems(salvageableRes.data.salvageable_items) - } - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Failed to craft item') - } - } - - const handleOpenRepair = async () => { - try { - const response = await api.get('/api/game/repairable') - setRepairableItems(response.data.repairable_items) - setShowRepairMenu(true) - setShowCraftingMenu(false) - setWorkbenchTab('repair') - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Failed to load repair menu') - } - } - - const handleRepairFromMenu = async (uniqueItemId: number, inventoryId?: number) => { - try { - setMessage('Repairing...') - const response = await api.post('/api/game/repair_item', { - unique_item_id: uniqueItemId, - inventory_id: inventoryId - }) - setMessage(response.data.message || 'Item repaired!') - await fetchGameData() - // Refresh repairable items list - const repairableRes = await api.get('/api/game/repairable') - setRepairableItems(repairableRes.data.repairable_items) - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Failed to repair item') - } - } - - const handleUncraft = async (uniqueItemId: number, inventoryId: number) => { - try { - setMessage('Salvaging...') - const response = await api.post('/api/game/uncraft_item', { - unique_item_id: uniqueItemId, - inventory_id: inventoryId - }) - const data = response.data - let msg = data.message || 'Item salvaged!' - if (data.materials_yielded && data.materials_yielded.length > 0) { - msg += '\nโœ… Yielded: ' + data.materials_yielded.map((m: any) => `${m.emoji} ${m.name} x${m.quantity}`).join(', ') - } - if (data.materials_lost && data.materials_lost.length > 0) { - msg += '\nโš ๏ธ Lost: ' + data.materials_lost.map((m: any) => `${m.emoji} ${m.name} x${m.quantity}`).join(', ') - } - setMessage(msg) - await fetchGameData() - // Refresh salvageable items list - const salvageableRes = await api.get('/api/game/salvageable') - setUncraftableItems(salvageableRes.data.salvageable_items) - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Failed to uncraft item') - } - } - - const handleSwitchWorkbenchTab = async (tab: 'craft' | 'repair' | 'uncraft') => { - setWorkbenchTab(tab) - try { - if (tab === 'craft') { - const response = await api.get('/api/game/craftable') - setCraftableItems(response.data.craftable_items) - } else if (tab === 'repair') { - const response = await api.get('/api/game/repairable') - setRepairableItems(response.data.repairable_items) - } else if (tab === 'uncraft') { - const salvageableRes = await api.get('/api/game/salvageable') - setUncraftableItems(salvageableRes.data.salvageable_items) - } - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Failed to load items') - } - } - - const handleSpendPoint = async (stat: string) => { - try { - setMessage(`Increasing ${stat}...`) - const response = await api.post(`/api/game/spend_point?stat=${stat}`) - setMessage(response.data.message || 'Stat increased!') - fetchGameData() // Refresh to update stats - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Failed to spend point') - } - } - - const handleUseItem = async (itemId: string) => { - try { - setMessage('Using item...') - const response = await api.post('/api/game/use_item', { item_id: itemId }) - const data = response.data - - // If in combat, add to combat log - if (combatState && data.in_combat) { - const now = new Date() - const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) - const messages = data.message.split('\n').filter((m: string) => m.trim()) - const newEntries = messages.map((msg: string) => ({ - time: timeStr, - message: msg, - isPlayer: !msg.includes('attacks') - })) - setCombatLog((prev: any) => [...newEntries, ...prev]) - - // Check if combat ended - if (data.combat_over) { - setCombatState({ - ...combatState, - combat_over: true, - player_won: data.player_won - }) - } - } else { - const msg = data.message || 'Item used!' - addLocationMessage(msg) - } - - fetchGameData() - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Failed to use item') - } - } - - const handleEquipItem = async (inventoryId: number) => { - try { - setMessage('Equipping item...') - const response = await api.post('/api/game/equip', { inventory_id: inventoryId }) - const msg = response.data.message || 'Item equipped!' - addLocationMessage(msg) - fetchGameData() - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Failed to equip item') - } - } - - const handleUnequipItem = async (slot: string) => { - try { - setMessage('Unequipping item...') - const response = await api.post('/api/game/unequip', { slot }) - const msg = response.data.message || 'Item unequipped!' - addLocationMessage(msg) - fetchGameData() - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Failed to unequip item') - } - } - - const handleDropItem = async (itemId: string, quantity: number = 1) => { - try { - setMessage(`Dropping ${quantity} item(s)...`) - const response = await api.post('/api/game/item/drop', { item_id: itemId, quantity }) - const msg = response.data.message || 'Item dropped!' - addLocationMessage(msg) - fetchGameData() - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Failed to drop item') - } - } - - const handleInteract = async (interactableId: string, actionId: string) => { - if (combatState) { - setMessage('Cannot interact with objects while in combat!') - return - } - - // Close mobile menu to show result - setMobileMenuOpen('none') - - try { - const response = await api.post('/api/game/interact', { - interactable_id: interactableId, - action_id: actionId - }) - const data = response.data - let msg = data.message - if (data.items_found && data.items_found.length > 0) { - // items_found is already an array of strings like "Item Name x2" - msg += '\n\n๐Ÿ“ฆ Found: ' + data.items_found.join(', ') - } - if (data.hp_change) { - msg += `\nโค๏ธ HP: ${data.hp_change > 0 ? '+' : ''}${data.hp_change}` - } - setMessage(msg) - fetchGameData() // Refresh stats - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Interaction failed') - } - } - - const handleViewCorpseDetails = async (corpseId: string) => { - try { - const response = await api.get(`/api/game/corpse/${corpseId}`) - setCorpseDetails(response.data) - setExpandedCorpse(corpseId) - // Don't show "examining" message - just open the details - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Failed to examine corpse') - } - } - - const handleLootCorpseItem = async (corpseId: string, itemIndex: number | null = null) => { - try { - setMessage('Looting...') - const response = await api.post('/api/game/loot_corpse', { - corpse_id: corpseId, - item_index: itemIndex - }) - - // Show message for longer - setMessage(response.data.message) - setTimeout(() => { - // Keep message visible for 5 seconds - }, 5000) - - // If corpse is empty, close the details view - if (response.data.corpse_empty) { - setExpandedCorpse(null) - setCorpseDetails(null) - } else if (expandedCorpse === corpseId) { - // Refresh corpse details if still viewing (without clearing message) - try { - const detailsResponse = await api.get(`/api/game/corpse/${corpseId}`) - setCorpseDetails(detailsResponse.data) - } catch (err) { - // If corpse details fail, just close - setExpandedCorpse(null) - setCorpseDetails(null) - } - } - - fetchGameData() // Refresh location and inventory - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Failed to loot corpse') - } - } - - const handleLootCorpse = async (corpseId: string) => { - // Show corpse details instead of looting all at once - handleViewCorpseDetails(corpseId) - } - - const handleInitiateCombat = async (enemyId: number) => { - try { - // Close mobile menu to show combat - setMobileMenuOpen('none') - - const response = await api.post('/api/game/combat/initiate', { enemy_id: enemyId }) - setCombatState(response.data) - - // Store enemy info to prevent it from disappearing - setEnemyName(response.data.combat.npc_name) - setEnemyImage(response.data.combat.npc_image) - - // Initialize combat log with timestamp - const now = new Date() - const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) - setCombatLog([{ - time: timeStr, - message: `Combat started with ${response.data.combat.npc_name}!`, - isPlayer: true - }]) - - // Refresh location to remove enemy from list - const locationRes = await api.get('/api/game/location') - setLocation(locationRes.data) - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Failed to initiate combat') - } - } - - const handleCombatAction = async (action: string) => { - try { - const response = await api.post('/api/game/combat/action', { action }) - const data = response.data - - // Add message to combat log with timestamp - const now = new Date() - const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) - - // Parse the message to separate player and enemy actions - const messages = data.message.split('\n').filter((m: string) => m.trim()) - - // Find player action and enemy action - // For PvE: Failed flee contains both, so check for "Failed to flee" first - // For PvP: Use standard logic - const isPvE = !combatState?.is_pvp - const playerMessages = messages.filter((msg: string) => - msg.includes('You ') || msg.includes('Your ') || (isPvE && msg.includes('Failed to flee')) - ) - const enemyMessages = messages.filter((msg: string) => - !(isPvE && msg.includes('Failed to flee')) && // Exclude "Failed to flee" from enemy messages in PvE only - (msg.includes('attacks') || msg.includes('hits') || msg.includes('misses') || msg.includes('The ')) - ) - - // Add player actions immediately - if (playerMessages.length > 0) { - const playerEntries = playerMessages.map((msg: string) => ({ - time: timeStr, - message: msg, - isPlayer: true - })) - setCombatLog((prev: any) => [...playerEntries, ...prev]) - - // Update enemy HP immediately (but not player HP) - if (data.combat && !data.combat_over) { - setCombatState({ - ...combatState, - combat: { - ...combatState.combat, - npc_hp: data.combat.npc_hp, - turn: data.combat.turn - } - }) - } - } - - // If there are enemy actions and combat is not over, show "Enemy's turn..." then delay - if (enemyMessages.length > 0 && !data.combat_over) { - // Show "Enemy's turn..." message - setEnemyTurnMessage("๐Ÿ—ก๏ธ Enemy's turn...") - - // Wait 2 seconds before showing enemy attack - await new Promise(resolve => setTimeout(resolve, 2000)) - - // Clear the turn message and add enemy actions to log - setEnemyTurnMessage('') - const enemyEntries = enemyMessages.map((msg: string) => ({ - time: timeStr, - message: msg, - isPlayer: false - })) - setCombatLog((prev: any) => [...enemyEntries, ...prev]) - - // NOW update player HP directly from response data instead of fetching - if (data.player) { - setProfile(prev => prev ? { - ...prev, - hp: data.player.hp, - xp: data.player.xp ?? prev.xp, - level: data.player.level ?? prev.level - } : null) - } - } else if (enemyMessages.length > 0) { - // Combat is over, add enemy messages without delay - const enemyEntries = enemyMessages.map((msg: string) => ({ - time: timeStr, - message: msg, - isPlayer: false - })) - setCombatLog((prev: any) => [...enemyEntries, ...prev]) - } - - if (data.combat_over) { - // Combat ended - keep combat view but show result with preserved enemy info - // Check if player fled successfully (message contains "fled") - const playerFled = data.message && data.message.toLowerCase().includes('fled') - - setCombatState({ - ...combatState, // Keep existing state - combat_over: true, - player_won: data.player_won, - player_fled: playerFled, // Track if player fled - combat: { - ...combatState.combat, - npc_name: enemyName, // Keep original enemy name - npc_image: enemyImage, // Keep original enemy image - npc_hp: data.player_won ? 0 : (combatState.combat?.npc_hp || 0) // Don't set HP to 0 on flee - } - }) - - // Update player stats from response (XP/level on victory, HP on defeat) - if (data.player) { - setProfile(prev => prev ? { - ...prev, - hp: data.player.hp, - xp: data.player.xp ?? prev.xp, - level: data.player.level ?? prev.level - } : null) - } - } else { - // Update combat state for next turn, but preserve enemy info - // Keep the original stored enemy name/image (from state variables) - setCombatState({ - ...data, - combat: { - ...data.combat, - npc_name: enemyName, // Use stored enemy name - npc_image: enemyImage // Use stored enemy image - } - }) - } - } catch (error: any) { - const now = new Date() - const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) - setCombatLog((prev: any) => [{ - time: timeStr, - message: error.response?.data?.detail || 'Combat action failed', - isPlayer: false - }, ...prev]) - } - } - - const handleExitCombat = () => { - setCombatState(null) - setCombatLog([]) - setEnemyName('') - setEnemyImage('') - fetchGameData() // Refresh game state - } - - const handleExitPvPCombat = async () => { - if (combatState?.pvp_combat?.id) { - try { - await api.post('/api/game/pvp/acknowledge', { combat_id: combatState.pvp_combat.id }) - } catch (error) { - console.error('Failed to acknowledge PvP combat:', error) - } - } - setCombatState(null) - setCombatLog([]) - setLastSeenPvPAction(null) - lastSeenPvPActionRef.current = null // Clear ref too - fetchGameData() // Refresh game state - } - - const handleInitiatePvP = async (targetPlayerId: number) => { - try { - const response = await api.post('/api/game/pvp/initiate', { target_player_id: targetPlayerId }) - setMessage(response.data.message || 'PvP combat initiated!') - await fetchGameData() // Refresh to show combat state - } catch (error: any) { - setMessage(error.response?.data?.detail || 'Failed to initiate PvP') - } - } - - const handlePvPAction = async (action: string) => { - try { - const response = await api.post('/api/game/pvp/action', { action }) - const data = response.data - - // Add message to combat log - const now = new Date() - const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) - - if (data.message) { - const messages = data.message.split('\n').filter((m: string) => m.trim()) - const logEntries = messages.map((msg: string) => ({ - time: timeStr, - message: msg, - isPlayer: msg.includes('You ') || msg.includes('Your ') - })) - setCombatLog((prev: any) => [...logEntries, ...prev]) - } - - // Refresh combat state (skip combat log initialization to preserve our log entries) - await fetchGameData(true) - - // If combat is over, show message - if (data.combat_over) { - setMessage(data.message || 'Combat ended!') - } - } catch (error: any) { - const now = new Date() - const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) - setCombatLog((prev: any) => [{ - time: timeStr, - message: error.response?.data?.detail || 'PvP action failed', - isPlayer: false - }, ...prev]) - } - } - - const handleItemAction = async (action: string, itemId: number) => { - switch (action) { - case 'use': - await handleUseItem(itemId.toString()) - break - case 'equip': - await handleEquipItem(itemId) - break - case 'unequip': - // Find the slot this item is equipped in - const equippedSlot = Object.keys(equipment).find(slot => equipment[slot]?.id === itemId) - if (equippedSlot) { - await handleUnequipItem(equippedSlot) - } - break - case 'drop': - await handleDropItem(itemId.toString(), 1) - break - } - setSelectedItem(null) - } - - if (loading) { - return
Loading game...
- } - - if (!playerState || !location) { - return
Failed to load game state
- } - - // Helper function to get direction details - const getDirectionDetail = (direction: string) => { - if (!location.directions_detailed) return null - return location.directions_detailed.find(d => d.direction === direction) - } - - // Helper function to get stamina cost for a direction - const getStaminaCost = (direction: string): number => { - const detail = getDirectionDetail(direction) - return detail ? detail.stamina_cost : 5 - } - - // Helper function to get destination name for a direction - const getDestinationName = (direction: string): string => { - const detail = getDirectionDetail(direction) - return detail ? (detail.destination_name || detail.destination) : '' - } - - // Helper function to get distance for a direction - const getDistance = (direction: string): number => { - const detail = getDirectionDetail(direction) - return detail ? detail.distance : 0 - } - - // Helper function to check if direction is available - const hasDirection = (direction: string): boolean => { - return location.directions.includes(direction) - } - - // Helper function to render compass button - const renderCompassButton = (direction: string, arrow: string, className: string) => { - const available = hasDirection(direction) - const stamina = getStaminaCost(direction) - const destination = getDestinationName(direction) - const distance = getDistance(direction) - const insufficientStamina = profile ? profile.stamina < stamina : false - const disabled = !available || !!combatState || movementCooldown > 0 || insufficientStamina || (profile?.is_dead ?? false) - - // Build detailed tooltip text - const tooltipText = profile?.is_dead ? 'You are dead' : - movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : - combatState ? 'Cannot travel during combat' : - insufficientStamina ? `Not enough stamina (need ${stamina}, have ${profile?.stamina ?? 0})` : - available ? `${destination}\nDistance: ${distance}m\nStamina: ${stamina}` : - `Cannot go ${direction}` - - return ( - - ) - } - - const renderExploreTab = () => ( -
- {/* Left Sidebar: Movement & Surroundings */} -
- {/* Movement Controls */} -
-

๐Ÿงญ Travel

-
- {/* Top row */} - {renderCompassButton('northwest', 'โ†–', 'nw')} - {renderCompassButton('north', 'โ†‘', 'n')} - {renderCompassButton('northeast', 'โ†—', 'ne')} - - {/* Middle row */} - {renderCompassButton('west', 'โ†', 'w')} -
-
๐Ÿงญ
-
- {renderCompassButton('east', 'โ†’', 'e')} - - {/* Bottom row */} - {renderCompassButton('southwest', 'โ†™', 'sw')} - {renderCompassButton('south', 'โ†“', 's')} - {renderCompassButton('southeast', 'โ†˜', 'se')} -
- - {/* Cooldown indicator */} - {movementCooldown > 0 && ( -
- โณ Wait {movementCooldown}s before moving -
- )} - - {/* Special movements */} -
- {location.directions.includes('up') && ( - - )} - {location.directions.includes('down') && ( - - )} - {location.directions.includes('enter') && ( - - )} - {location.directions.includes('inside') && ( - - )} - {location.directions.includes('exit') && ( - - )} - {location.directions.includes('outside') && ( - - )} -
-
- - {/* Surroundings */} - {(location.interactables && location.interactables.length > 0) && ( -
-

๐ŸŒฟ Surroundings

- - {/* Interactables */} - {location.interactables && location.interactables.map((interactable: any) => { - return ( -
- {interactable.image_path && ( -
- {interactable.name} { - e.currentTarget.style.display = 'none'; - }} - /> -
- )} -
-
- - {interactable.name} - -
- {interactable.actions && interactable.actions.length > 0 && ( -
- {interactable.actions.map((action: any) => { - // Calculate live cooldown remaining per action - const cooldownKey = `${interactable.instance_id}:${action.id}` - const cooldownExpiry = interactableCooldowns[cooldownKey] - const now = Date.now() / 1000 - const cooldownRemaining = cooldownExpiry ? Math.max(0, Math.ceil(cooldownExpiry - now)) : 0 - const isOnCooldown = cooldownRemaining > 0 - - return ( - - ) - })} -
- )} -
-
- ) - })} -
- )} -
{/* Close left-sidebar */} - - {/* Center: Location/Combat Content */} -
- {combatState ? ( - /* Combat View */ -
-
-

- {combatState.is_pvp ? 'โš”๏ธ PvP Combat' : `โš”๏ธ Combat - ${enemyName || combatState.combat?.npc_name || 'Enemy'}`} -

-
- - {combatState.is_pvp ? ( - /* PvP Combat UI */ -
-
- {/* Opponent Info */} -
- {(() => { - const opponent = combatState.pvp_combat.is_attacker ? - combatState.pvp_combat.defender : - combatState.pvp_combat.attacker - return ( - <> -

๐Ÿ—ก๏ธ {opponent.username}

-
Level {opponent.level}
-
-
-
- HP: {opponent.hp} / {opponent.max_hp} -
-
-
-
- - ) - })()} -
- - {/* Your Info */} -
- {(() => { - const you = combatState.pvp_combat.is_attacker ? - combatState.pvp_combat.attacker : - combatState.pvp_combat.defender - return ( - <> -

๐Ÿ›ก๏ธ You

-
Level {you.level}
-
-
-
- HP: {you.hp} / {you.max_hp} -
-
-
-
- - ) - })()} -
-
- -
- {combatState.pvp_combat.combat_over ? ( - - {combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "๐Ÿƒ Combat Ended" : "๐Ÿ’€ Combat Over"} - - ) : combatState.pvp_combat.your_turn ? ( - โœ… Your Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s) - ) : ( - โณ Opponent's Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s) - )} -
- -
- {!combatState.pvp_combat.combat_over ? ( - <> - - - - ) : ( - - )} -
-
- ) : ( - /* PvE Combat UI */ - <> -
-
- {enemyName -
-
-
-
-
- Enemy HP: {combatState.combat?.npc_hp || 0} / {combatState.combat?.npc_max_hp || 100} -
-
-
-
- {playerState && ( -
-
-
- Your HP: {playerState.health} / {playerState.max_health} -
-
-
-
- )} -
-
- -
- {!combatState.combat_over ? ( - enemyTurnMessage ? ( - ๐Ÿ—ก๏ธ Enemy's turn... - ) : combatState.combat?.turn === 'player' ? ( - โœ… Your Turn - ) : ( - โš ๏ธ Enemy Turn - ) - ) : ( - - {combatState.player_won ? "โœ… Victory!" : combatState.player_fled ? "๐Ÿƒ Escaped!" : "๐Ÿ’€ Defeated"} - - )} -
- -
- {!combatState.combat_over ? ( - <> - - - - ) : ( - - )} -
- - )} - - {/* Combat Log */} -
-

Combat Log:

-
- {combatLog.map((entry: any, i: number) => ( -
- {entry.time} - {entry.isPlayer ? 'โ†’' : 'โ†'} - {entry.message} -
- ))} -
-
-
- ) : ( - /* Normal Location View */ - <> -
-

- {location.name} - {location.danger_level !== undefined && location.danger_level === 0 && ( - - โœ“ Safe - - )} - {location.danger_level !== undefined && location.danger_level > 0 && ( - - โš ๏ธ {location.danger_level} - - )} -

- {location.tags && location.tags.length > 0 && ( -
- {location.tags.map((tag: string, i: number) => { - const isClickable = tag === 'workbench' || tag === 'repair_station' - const handleClick = () => { - if (tag === 'workbench') handleOpenCrafting() - else if (tag === 'repair_station') handleOpenRepair() - } - - return ( - - {tag === 'workbench' && '๐Ÿ”ง Workbench'} - {tag === 'repair_station' && '๐Ÿ› ๏ธ Repair Station'} - {tag === 'safe_zone' && '๐Ÿ›ก๏ธ Safe Zone'} - {tag === 'shop' && '๐Ÿช Shop'} - {tag === 'shelter' && '๐Ÿ  Shelter'} - {tag === 'medical' && 'โš•๏ธ Medical'} - {tag === 'storage' && '๐Ÿ“ฆ Storage'} - {tag === 'water_source' && '๐Ÿ’ง Water'} - {tag === 'food_source' && '๐ŸŽ Food'} - {tag !== 'workbench' && tag !== 'repair_station' && tag !== 'safe_zone' && tag !== 'shop' && tag !== 'shelter' && tag !== 'medical' && tag !== 'storage' && tag !== 'water_source' && tag !== 'food_source' && `๐Ÿท๏ธ ${tag}`} - - ) - })} -
- )} - - {/* Workbench Menu (Crafting, Repair, Uncraft) */} - {(showCraftingMenu || showRepairMenu) && ( -
-
-

๐Ÿ”ง Workbench

- -
- - {/* Tabs */} -
- - - -
- - {/* Craft Tab */} - {workbenchTab === 'craft' && ( -
-
- setCraftFilter(e.target.value)} - className="filter-input" - /> - -
-
- {craftableItems.filter(item => - item.name.toLowerCase().includes(craftFilter.toLowerCase()) && - (craftCategoryFilter === 'all' || item.category === craftCategoryFilter) - ).length === 0 &&

No craftable items found

} - {craftableItems - .filter(item => - item.name.toLowerCase().includes(craftFilter.toLowerCase()) && - (craftCategoryFilter === 'all' || item.category === craftCategoryFilter) - ) - .map((item: any) => ( -
-
- - {item.emoji} {item.name} - - {item.slot && [{item.slot}]} -
- {item.description &&

{item.description}

} - - {/* Level requirement */} - {item.craft_level && item.craft_level > 1 && ( -
- ๐Ÿ“Š Requires Level {item.craft_level} {item.meets_level ? 'โœ…' : `โŒ (You are level ${profile?.level || 1})`} -
- )} - - {/* Tool requirements */} - {item.tools && item.tools.length > 0 && ( -
-

๐Ÿ”ง Required Tools:

- {item.tools.map((tool: any, i: number) => ( -
- {tool.emoji} {tool.name} - - (-{tool.durability_cost} durability) - {tool.has_tool && ` [${tool.tool_durability} available]`} - {!tool.has_tool && ' โŒ'} - -
- ))} -
- )} - - {/* Materials */} -
-

๐Ÿ“ฆ Materials:

- {item.materials.map((mat: any, i: number) => ( -
- {mat.emoji} {mat.name} - {mat.available}/{mat.required} -
- ))} -
- - -
- ))} -
-
- )} - - {/* Repair Tab */} - {workbenchTab === 'repair' && ( -
-
- setRepairFilter(e.target.value)} - className="filter-input" - /> -
-
- {repairableItems.filter(item => - item.name.toLowerCase().includes(repairFilter.toLowerCase()) - ).length === 0 &&

No repairable items found

} - {repairableItems - .filter(item => item.name.toLowerCase().includes(repairFilter.toLowerCase())) - .map((item: any, idx: number) => ( -
-
- - {item.emoji} {item.name} - - {item.location === 'equipped' && โš”๏ธ Equipped} - {item.location === 'inventory' && ๐ŸŽ’ Inventory} -
-
-
- ๐Ÿ”ง Durability: -
-
-
- {item.current_durability}/{item.max_durability} -
-
- - {!item.needs_repair && ( -

โœ… At full durability

- )} - - {item.needs_repair && ( - <> - {/* Tool requirements */} - {item.tools && item.tools.length > 0 && ( -
-

๐Ÿ”ง Required Tools:

- {item.tools.map((tool: any, i: number) => ( -
- {tool.emoji} {tool.name} - - (-{tool.durability_cost} durability) - {tool.has_tool && ` [${tool.tool_durability} available]`} - {!tool.has_tool && ' โŒ'} - -
- ))} -
- )} - - {/* Materials */} -
-

Restores {item.repair_percentage}% durability

- {item.materials.map((mat: any, i: number) => ( -
- {mat.emoji} {mat.name} - {mat.available}/{mat.quantity} -
- ))} -
- - )} - - -
- ))} -
-
- )} - - {/* Uncraft Tab */} - {workbenchTab === 'uncraft' && ( -
-
- setUncraftFilter(e.target.value)} - className="filter-input" - /> -
-
- {uncraftableItems.filter(item => - item.name.toLowerCase().includes(uncraftFilter.toLowerCase()) - ).length === 0 &&

No uncraftable items found

} - {uncraftableItems - .filter((item: any) => item.name.toLowerCase().includes(uncraftFilter.toLowerCase())) - .map((item: any, idx: number) => { - // Calculate adjusted yield based on durability - const durabilityRatio = item.unique_item_data - ? item.unique_item_data.durability_percent / 100 - : 1.0 - const adjustedYield = item.base_yield.map((mat: any) => ({ - ...mat, - adjusted_quantity: Math.floor(mat.quantity * durabilityRatio) - })) - - return ( -
-
- - {item.emoji} {item.name} - -
- - {/* Unique item details */} - {item.unique_item_data && ( -
- {/* Durability bar */} -
-
- ๐Ÿ”ง Durability: -
-
-
- {item.unique_item_data.current_durability}/{item.unique_item_data.max_durability} -
-
- - {/* Format stats nicely */} - {item.unique_item_data.unique_stats && Object.keys(item.unique_item_data.unique_stats).length > 0 && ( -
- {Object.entries(item.unique_item_data.unique_stats).map(([stat, value]: [string, any]) => { - // Format stat names and values - let displayName = stat.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) - let displayValue = value - - // Combine min/max stats - if (stat === 'damage_min' && item.unique_item_data.unique_stats.damage_max) { - displayName = 'Damage' - displayValue = `${value}-${item.unique_item_data.unique_stats.damage_max}` - return ( - - โš”๏ธ {displayName}: {displayValue} - - ) - } else if (stat === 'damage_max') { - return null // Skip, already shown with damage_min - } else if (stat === 'armor') { - return ( - - ๐Ÿ›ก๏ธ {displayName}: {displayValue} - - ) - } else { - return ( - - {displayName}: {displayValue} - - ) - } - })} -
- )} -
- )} - - {/* Durability impact warning */} - {durabilityRatio < 1.0 && ( -
- โš ๏ธ Item condition will reduce yield by {Math.round((1 - durabilityRatio) * 100)}% -
- )} - - {durabilityRatio < 0.1 && ( -
- โŒ Item too damaged - will yield NO materials! -
- )} - - {/* Loss chance warning */} - {item.loss_chance && ( -
- โš ๏ธ {Math.round(item.loss_chance * 100)}% chance to lose each material -
- )} - - {/* Yield materials with durability adjustment */} - {adjustedYield && adjustedYield.length > 0 && ( -
-

โ™ป๏ธ Expected yield:

- {adjustedYield.map((mat: any, i: number) => ( -
- {mat.emoji} {mat.name} - - {durabilityRatio < 1.0 && durabilityRatio >= 0.1 ? ( - <> - x{mat.quantity} - {' โ†’ '} - x{mat.adjusted_quantity} - - ) : durabilityRatio < 0.1 ? ( - x0 - ) : ( - <>x{mat.quantity} - )} - -
- ))} - {durabilityRatio >= 0.1 && ( -

- * Subject to {Math.round((item.loss_chance || 0.3) * 100)}% random loss per material -

- )} -
- )} - - -
- ) - })} -
-
- )} -
- )} - - {location.image_url && ( -
- {location.name} (e.currentTarget.style.display = 'none')} /> -
- )} -
-

{location.description}

-
-
- - {message && ( -
setMessage('')}> - {message} -
- )} - - {/* Location Messages Log */} - {locationMessages.length > 0 && ( -
-

๐Ÿ“œ Recent Activity

-
- {locationMessages.slice(-10).reverse().map((msg, idx) => ( -
- {msg.time} - {msg.message} -
- ))} -
-
- )} - - {/* NPCs, Items, and Entities on ground - below the location image */} -
- {/* Enemies */} - {location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && ( -
-

โš”๏ธ Enemies

-
- {location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any, i: number) => ( -
- {enemy.id && ( -
- {enemy.name} { - e.currentTarget.style.display = 'none'; - }} - /> -
- )} -
-
{enemy.name}
- {enemy.level &&
Lv. {enemy.level}
} -
- -
- ))} -
-
- )} - - {/* Corpses */} - {location.corpses && location.corpses.length > 0 && ( -
-

๐Ÿ’€ Corpses

-
- {location.corpses.map((corpse: any) => ( -
-
-
-
{corpse.emoji} {corpse.name}
-
{corpse.loot_count} item(s)
-
- -
- - {/* Expanded corpse details */} - {expandedCorpse === corpse.id && corpseDetails && ( -
-
-

Lootable Items:

- -
-
- {corpseDetails.loot_items.map((item: any) => ( -
-
-
- {item.emoji} {item.item_name} -
-
- Qty: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''} -
- {item.required_tool && ( -
- ๐Ÿ”ง {item.required_tool_name} {item.has_tool ? 'โœ“' : 'โœ—'} -
- )} -
- -
- ))} -
- -
- )} -
- ))} -
-
- )} - - {/* NPCs */} - {location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && ( -
-

๐Ÿ‘ฅ NPCs

-
- {location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => ( -
- ๐Ÿง‘ -
-
{npc.name}
- {npc.level &&
Lv. {npc.level}
} -
- -
- ))} -
-
- )} - - {location.items.length > 0 && ( -
-

๐Ÿ“ฆ Items on Ground

-
- {location.items.map((item: any, i: number) => ( -
- - {item.emoji || '๐Ÿ“ฆ'} - -
-
{item.name || 'Unknown Item'}
- {item.quantity > 1 &&
ร—{item.quantity}
} -
-
- -
- {item.description &&
{item.description}
} - {item.weight !== undefined && item.weight > 0 && ( -
- โš–๏ธ Weight: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`} -
- )} - {item.volume !== undefined && item.volume > 0 && ( -
- ๐Ÿ“ฆ Volume: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`} -
- )} - {item.hp_restore && item.hp_restore > 0 && ( -
- โค๏ธ HP Restore: +{item.hp_restore} -
- )} - {item.stamina_restore && item.stamina_restore > 0 && ( -
- โšก Stamina Restore: +{item.stamina_restore} -
- )} - {item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && ( -
- โš”๏ธ Damage: {item.damage_min}-{item.damage_max} -
- )} - {item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && ( -
- ๐Ÿ”ง Durability: {item.durability}/{item.max_durability} -
- )} - {item.tier !== undefined && item.tier !== null && item.tier > 0 && ( -
- โญ Tier: {item.tier} -
- )} -
-
- {item.quantity === 1 ? ( - - ) : ( -
- -
- - {item.quantity >= 5 && ( - - )} - {item.quantity >= 10 && ( - - )} - -
-
- )} -
- ))} -
-
- )} - - {/* Other Players */} - {location.other_players && location.other_players.length > 0 && ( -
-

๐Ÿ‘ฅ Other Players

-
- {location.other_players.map((player: any, i: number) => ( -
- ๐Ÿง -
-
{player.username}
-
Lv. {player.level}
- {player.level_diff !== undefined && ( -
- {player.level_diff > 0 ? `+${player.level_diff}` : player.level_diff} levels -
- )} -
- {player.can_pvp && ( - - )} - {!player.can_pvp && player.level_diff !== undefined && Math.abs(player.level_diff) > 3 && ( -
- Level difference too high -
- )} - {!player.can_pvp && location.danger_level !== undefined && location.danger_level < 3 && ( -
- Area too safe for PvP -
- )} -
- ))} -
-
- )} -
- - )} -
- - {/* Right Sidebar: Profile & Inventory */} -
- {/* Profile Stats */} -
-

๐Ÿ‘ค Character

- - {/* Health & Stamina Bars */} -
-
-
- โค๏ธ HP - {playerState.health}/{playerState.max_health} -
-
-
- {Math.round((playerState.health / playerState.max_health) * 100)}% -
-
- -
-
- โšก Stamina - {playerState.stamina}/{playerState.max_stamina} -
-
-
- {Math.round((playerState.stamina / playerState.max_stamina) * 100)}% -
-
-
- - {/* Character Info */} - {profile && ( -
-
- Level: - {profile.level} -
- - {/* XP Progress Bar */} -
-
- โญ XP - {profile.xp} / {(profile.level * 100)} -
-
-
- {Math.round((profile.xp / (profile.level * 100)) * 100)}% -
-
- - {profile.unspent_points > 0 && ( -
- โญ Unspent: - {profile.unspent_points} -
- )} - -
- -
- ๐Ÿ’ช STR: - {profile.strength} - {profile.unspent_points > 0 && ( - - )} -
-
- ๐Ÿƒ AGI: - {profile.agility} - {profile.unspent_points > 0 && ( - - )} -
-
- ๐Ÿ›ก๏ธ END: - {profile.endurance} - {profile.unspent_points > 0 && ( - - )} -
-
- ๐Ÿง  INT: - {profile.intellect} - {profile.unspent_points > 0 && ( - - )} -
-
- )} -
- - {/* Equipment Display */} -
-

โš”๏ธ Equipment

-
- {/* Row 1: Head */} -
-
- {equipment.head ? ( - <> - -
- {equipment.head.emoji} - {equipment.head.name} - {equipment.head.durability && equipment.head.durability !== null && ( - {equipment.head.durability}/{equipment.head.max_durability} - )} -
-
- {equipment.head.description &&
{equipment.head.description}
} - {equipment.head.stats && Object.keys(equipment.head.stats).length > 0 && ( - <> - {equipment.head.stats.armor && ( -
- ๐Ÿ›ก๏ธ Armor: +{equipment.head.stats.armor} -
- )} - {equipment.head.stats.hp_max && ( -
- โค๏ธ Max HP: +{equipment.head.stats.hp_max} -
- )} - {equipment.head.stats.stamina_max && ( -
- โšก Max Stamina: +{equipment.head.stats.stamina_max} -
- )} - - )} - {equipment.head.durability !== undefined && equipment.head.durability !== null && ( -
- ๐Ÿ”ง Durability: {equipment.head.durability}/{equipment.head.max_durability} -
- )} - {equipment.head.tier !== undefined && equipment.head.tier !== null && equipment.head.tier > 0 && ( -
- โญ Tier: {equipment.head.tier} -
- )} -
- - ) : ( - <> - ๐Ÿช– - Head - - )} -
-
- - {/* Row 2: Weapon, Torso, Backpack */} -
-
- {equipment.weapon ? ( - <> - -
- {equipment.weapon.emoji} - {equipment.weapon.name} - {equipment.weapon.durability && equipment.weapon.durability !== null && ( - {equipment.weapon.durability}/{equipment.weapon.max_durability} - )} -
-
- {equipment.weapon.description &&
{equipment.weapon.description}
} - {equipment.weapon.stats && Object.keys(equipment.weapon.stats).length > 0 && ( -
- โš”๏ธ Damage: {equipment.weapon.stats.damage_min}-{equipment.weapon.stats.damage_max} -
- )} - {equipment.weapon.weapon_effects && Object.keys(equipment.weapon.weapon_effects).length > 0 && ( -
- โœจ Effects: {Object.entries(equipment.weapon.weapon_effects).map(([key, val]: [string, any]) => `${key} (${(val.chance * 100).toFixed(0)}%)`).join(', ')} -
- )} - {equipment.weapon.durability !== undefined && equipment.weapon.durability !== null && ( -
- ๐Ÿ”ง Durability: {equipment.weapon.durability}/{equipment.weapon.max_durability} -
- )} - {equipment.weapon.tier !== undefined && equipment.weapon.tier !== null && equipment.weapon.tier > 0 && ( -
- โญ Tier: {equipment.weapon.tier} -
- )} -
- - ) : ( - <> - โš”๏ธ - Weapon - - )} -
- -
- {equipment.torso ? ( - <> - -
- {equipment.torso.emoji} - {equipment.torso.name} - {equipment.torso.durability && equipment.torso.durability !== null && ( - {equipment.torso.durability}/{equipment.torso.max_durability} - )} -
-
- {equipment.torso.description &&
{equipment.torso.description}
} - {equipment.torso.stats && Object.keys(equipment.torso.stats).length > 0 && ( - <> - {equipment.torso.stats.armor && ( -
- ๐Ÿ›ก๏ธ Armor: +{equipment.torso.stats.armor} -
- )} - {equipment.torso.stats.hp_max && ( -
- โค๏ธ Max HP: +{equipment.torso.stats.hp_max} -
- )} - {equipment.torso.stats.stamina_max && ( -
- โšก Max Stamina: +{equipment.torso.stats.stamina_max} -
- )} - - )} - {equipment.torso.durability !== undefined && equipment.torso.durability !== null && ( -
- ๐Ÿ”ง Durability: {equipment.torso.durability}/{equipment.torso.max_durability} -
- )} - {equipment.torso.tier !== undefined && equipment.torso.tier !== null && equipment.torso.tier > 0 && ( -
- โญ Tier: {equipment.torso.tier} -
- )} -
- - ) : ( - <> - ๐Ÿ‘• - Torso - - )} -
- -
- {equipment.backpack ? ( - <> - -
- {equipment.backpack.emoji} - {equipment.backpack.name} - {equipment.backpack.durability && equipment.backpack.durability !== null && ( - {equipment.backpack.durability}/{equipment.backpack.max_durability} - )} -
-
- {equipment.backpack.description &&
{equipment.backpack.description}
} - {equipment.backpack.stats && Object.keys(equipment.backpack.stats).length > 0 && ( - <> - {equipment.backpack.stats.weight_capacity && ( -
- โš–๏ธ Weight: +{equipment.backpack.stats.weight_capacity}kg -
- )} - {equipment.backpack.stats.volume_capacity && ( -
- ๐Ÿ“ฆ Volume: +{equipment.backpack.stats.volume_capacity}L -
- )} - - )} - {equipment.backpack.durability !== undefined && equipment.backpack.durability !== null && ( -
- ๐Ÿ”ง Durability: {equipment.backpack.durability}/{equipment.backpack.max_durability} -
- )} - {equipment.backpack.tier !== undefined && equipment.backpack.tier !== null && equipment.backpack.tier > 0 && ( -
- โญ Tier: {equipment.backpack.tier} -
- )} -
- - ) : ( - <> - ๐ŸŽ’ - Backpack - - )} -
-
- - {/* Row 3: Legs */} -
-
- {equipment.legs ? ( - <> - -
- {equipment.legs.emoji} - {equipment.legs.name} - {equipment.legs.durability && equipment.legs.durability !== null && ( - {equipment.legs.durability}/{equipment.legs.max_durability} - )} -
-
- {equipment.legs.description &&
{equipment.legs.description}
} - {equipment.legs.stats && Object.keys(equipment.legs.stats).length > 0 && ( - <> - {equipment.legs.stats.armor && ( -
- ๐Ÿ›ก๏ธ Armor: +{equipment.legs.stats.armor} -
- )} - {equipment.legs.stats.hp_max && ( -
- โค๏ธ Max HP: +{equipment.legs.stats.hp_max} -
- )} - {equipment.legs.stats.stamina_max && ( -
- โšก Max Stamina: +{equipment.legs.stats.stamina_max} -
- )} - - )} - {equipment.legs.durability !== undefined && equipment.legs.durability !== null && ( -
- ๐Ÿ”ง Durability: {equipment.legs.durability}/{equipment.legs.max_durability} -
- )} - {equipment.legs.tier !== undefined && equipment.legs.tier !== null && equipment.legs.tier > 0 && ( -
- โญ Tier: {equipment.legs.tier} -
- )} -
- - ) : ( - <> - ๐Ÿ‘– - Legs - - )} -
-
- - {/* Row 4: Feet */} -
-
- {equipment.feet ? ( - <> - -
- {equipment.feet.emoji} - {equipment.feet.name} - {equipment.feet.durability && equipment.feet.durability !== null && ( - {equipment.feet.durability}/{equipment.feet.max_durability} - )} -
-
- {equipment.feet.description &&
{equipment.feet.description}
} - {equipment.feet.stats && Object.keys(equipment.feet.stats).length > 0 && ( - <> - {equipment.feet.stats.armor && ( -
- ๐Ÿ›ก๏ธ Armor: +{equipment.feet.stats.armor} -
- )} - {equipment.feet.stats.hp_max && ( -
- โค๏ธ Max HP: +{equipment.feet.stats.hp_max} -
- )} - {equipment.feet.stats.stamina_max && ( -
- โšก Max Stamina: +{equipment.feet.stats.stamina_max} -
- )} - - )} - {equipment.feet.durability !== undefined && equipment.feet.durability !== null && ( -
- ๐Ÿ”ง Durability: {equipment.feet.durability}/{equipment.feet.max_durability} -
- )} - {equipment.feet.tier !== undefined && equipment.feet.tier !== null && equipment.feet.tier > 0 && ( -
- โญ Tier: {equipment.feet.tier} -
- )} -
- - ) : ( - <> - ๐Ÿ‘Ÿ - Feet - - )} -
-
-
-
- - {/* Enhanced Inventory */} -
-

๐ŸŽ’ Inventory

- - {/* Weight and Volume Bars */} -
-
-
- โš–๏ธ Weight - - {profile?.current_weight || 0}/{profile?.max_weight || 100} - -
-
-
- - {Math.round(Math.min(((profile?.current_weight || 0) / (profile?.max_weight || 100)) * 100, 100))}% - -
-
- -
-
- ๐Ÿ“ฆ Volume - - {profile?.current_volume || 0}/{profile?.max_volume || 100} - -
-
-
- - {Math.round(Math.min(((profile?.current_volume || 0) / (profile?.max_volume || 100)) * 100, 100))}% - -
-
-
- - {/* Inventory Items - Grouped by Category */} -
- {playerState.inventory.filter((item: any) => !item.is_equipped).length === 0 ? ( -

No items

- ) : ( - Object.entries( - playerState.inventory - .filter((item: any) => !item.is_equipped) - .reduce((acc: any, item: any) => { - const category = item.type || 'misc' - if (!acc[category]) acc[category] = [] - acc[category].push(item) - return acc - }, {}) - ).sort(([catA], [catB]) => catA.localeCompare(catB)) - .map(([category, items]: [string, any]) => { - const isCollapsed = collapsedCategories.has(category) - const sortedItems = (items as any[]).sort((a, b) => a.name.localeCompare(b.name)) - - return ( -
-
{ - const newSet = new Set(collapsedCategories) - if (isCollapsed) { - newSet.delete(category) - } else { - newSet.add(category) - } - setCollapsedCategories(newSet) - }} - > - {isCollapsed ? 'โ–ถ' : 'โ–ผ'} - {category === 'weapon' ? 'โš”๏ธ Weapons' : - category === 'armor' ? '๐Ÿ›ก๏ธ Armor' : - category === 'consumable' ? '๐Ÿ– Consumables' : - category === 'resource' ? '๐Ÿ“ฆ Resources' : - category === 'quest' ? '๐Ÿ“œ Quest Items' : - `๐Ÿ“ฆ ${category.charAt(0).toUpperCase() + category.slice(1)}`} - ({sortedItems.length}) -
- {!isCollapsed && sortedItems.map((item: any, i: number) => ( -
-
-
- {item.emoji || '๐Ÿ“ฆ'} -
-
- - {item.name} - {item.quantity > 1 && ร—{item.quantity}} - {item.hp_restore > 0 && +{item.hp_restore}โค๏ธ} - {item.stamina_restore > 0 && +{item.stamina_restore}โšก} - -
-
- - {/* Hover tooltip */} -
- {item.description &&
{item.description}
} - {item.weight !== undefined && item.weight > 0 && ( -
- โš–๏ธ Weight: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`} -
- )} - {item.volume !== undefined && item.volume > 0 && ( -
- ๐Ÿ“ฆ Volume: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`} -
- )} - {/* Equipment stats */} - {item.stats && item.stats.weight_capacity && ( -
- โš–๏ธ Weight Capacity: +{item.stats.weight_capacity}kg -
- )} - {item.stats && item.stats.volume_capacity && ( -
- ๐Ÿ“ฆ Volume Capacity: +{item.stats.volume_capacity}L -
- )} - {item.stats && item.stats.armor && ( -
- ๐Ÿ›ก๏ธ Armor: +{item.stats.armor} -
- )} - {item.stats && item.stats.hp_max && ( -
- โค๏ธ Max HP: +{item.stats.hp_max} -
- )} - {item.stats && item.stats.stamina_max && ( -
- โšก Max Stamina: +{item.stats.stamina_max} -
- )} - {item.hp_restore && item.hp_restore > 0 && ( -
- โค๏ธ HP Restore: +{item.hp_restore} -
- )} - {item.stamina_restore && item.stamina_restore > 0 && ( -
- โšก Stamina Restore: +{item.stamina_restore} -
- )} - {item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && ( -
- โš”๏ธ Damage: {item.damage_min}-{item.damage_max} -
- )} - {item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && ( -
- ๐Ÿ”ง Durability: {item.durability}/{item.max_durability} -
- )} - {item.tier !== undefined && item.tier !== null && item.tier > 0 && ( -
- โญ Tier: {item.tier} -
- )} -
- -
- {item.consumable && ( - - )} - {item.equippable && !item.is_equipped && ( - - )} - {item.quantity === 1 ? ( - - ) : ( -
- -
- - {item.quantity >= 5 && ( - - )} - {item.quantity >= 10 && ( - - )} - -
-
- )} -
-
- ))} -
- ) - }) - )} -
- - {/* Item Actions Panel */} - {selectedItem && ( -
-
- {selectedItem.name} - -
- {selectedItem.description && ( -

{selectedItem.description}

- )} -
- {selectedItem.usable && ( - - )} - {selectedItem.equippable && !selectedItem.is_equipped && ( - - )} - {selectedItem.is_equipped && ( - - )} - -
-
- )} -
-
-
- ) - - return ( -
- {/* Death Overlay */} - {profile?.is_dead && ( -
-
-

๐Ÿ’€ You Have Died

-

Your character has been defeated in combat.

-

All your items have been placed in a corpse at your death location.

-

You can retrieve them with another character before they decay (24 hours).

- -
-
- )} - - - - {/* Mobile Header Toggle - only show in main view */} - {mobileMenuOpen === 'none' && ( - - )} - -
- {renderExploreTab()} - - {/* Mobile Tab Navigation */} -
- - - -
- - {/* Mobile Menu Overlays */} - {mobileMenuOpen !== 'none' && ( -
setMobileMenuOpen('none')} - /> - )} -
-
- ) -} - -export default Game diff --git a/pwa/src/hooks/useGameWebSocket.ts b/pwa/src/hooks/useGameWebSocket.ts index e49368d..4394455 100644 --- a/pwa/src/hooks/useGameWebSocket.ts +++ b/pwa/src/hooks/useGameWebSocket.ts @@ -31,8 +31,8 @@ export const useGameWebSocket = ({ }: UseGameWebSocketProps): UseGameWebSocketReturn => { const [isConnected, setIsConnected] = useState(false); const wsRef = useRef(null); - const reconnectTimeoutRef = useRef(null); - const heartbeatIntervalRef = useRef(null); + const reconnectTimeoutRef = useRef | null>(null); + const heartbeatIntervalRef = useRef | null>(null); const reconnectAttemptsRef = useRef(0); const maxReconnectAttempts = 5; const reconnectDelay = 3000; // 3 seconds @@ -40,14 +40,14 @@ export const useGameWebSocket = ({ // Get WebSocket URL based on current environment const getWebSocketUrl = useCallback(() => { const API_BASE = import.meta.env.VITE_API_URL || ( - import.meta.env.PROD - ? 'https://api-staging.echoesoftheash.com' + import.meta.env.PROD + ? 'https://api-staging.echoesoftheash.com' : 'http://localhost:8000' ); - + // Remove /api suffix if present and convert http(s) to ws(s) const wsBase = API_BASE.replace(/\/api$/, '').replace(/^http/, 'ws'); - + return `${wsBase}/ws/game/${token}`; }, [token]); @@ -65,7 +65,7 @@ export const useGameWebSocket = ({ try { const wsUrl = getWebSocketUrl(); console.log('๐Ÿ”Œ Connecting to WebSocket:', wsUrl); - + const ws = new WebSocket(wsUrl); wsRef.current = ws; @@ -81,7 +81,7 @@ export const useGameWebSocket = ({ ws.onmessage = (event) => { try { const message: WebSocketMessage = JSON.parse(event.data); - + // Handle heartbeat acks silently if (message.type === 'heartbeat_ack' || message.type === 'pong') { return; @@ -114,7 +114,7 @@ export const useGameWebSocket = ({ console.log( `๐Ÿ”„ Reconnecting... (attempt ${reconnectAttemptsRef.current}/${maxReconnectAttempts})` ); - + reconnectTimeoutRef.current = setTimeout(() => { connect(); }, reconnectDelay); diff --git a/pwa/src/services/api.ts b/pwa/src/services/api.ts index 5a758df..375d7a2 100644 --- a/pwa/src/services/api.ts +++ b/pwa/src/services/api.ts @@ -56,6 +56,7 @@ export interface LoginResponse { token_type: string account: Account characters: Character[] + needs_character_creation?: boolean } export interface RegisterResponse {