diff --git a/README.md b/README.md index f8d0959..21aef83 100644 --- a/README.md +++ b/README.md @@ -1,627 +1,188 @@ -# Echoes of the Ash ๐ŸŒ† +# Echoes of the Ash -A dark fantasy post-apocalyptic survival RPG featuring exploration, combat, crafting, and scavenging in a ruined world. +> A post-apocalyptic survival RPG - Browser-based MUD-style game -## ๐ŸŽฎ Game Features +![Status](https://img.shields.io/badge/Status-In%20Development-yellow) +![Platform](https://img.shields.io/badge/Platform-Web%20%7C%20PWA%20%7C%20Electron%20%7C%20Steam-blue) +![License](https://img.shields.io/badge/License-Proprietary-red) -### Core Gameplay +## ๐ŸŽฎ What is Echoes of the Ash? -#### ๐Ÿ—บ๏ธ Exploration & Movement -- **Grid-based world navigation** with coordinates (x, y) -- **Stamina-based movement system** - each move costs stamina based on distance -- **Multiple biomes and locations** with varying danger levels (0-4) -- **Dynamic location discovery** as you explore -- **Compass-based directional movement** (North, South, East, West) - -#### โš”๏ธ Combat System -- **Turn-based combat** with real-time intent preview -- **NPC enemy encounters** with weighted spawn tables per location -- **Status effects system**: Bleeding, Infected, Radiation -- **Weapon effects**: Bleeding, Stun, Armor Break -- **Flee mechanics** - escape combat with success/failure chance -- **XP and leveling system** - gain XP from defeating enemies -- **PvP (Player vs Player) combat** - challenge other players -- **Death and respawn mechanics** - -#### ๐ŸŽ’ Inventory & Equipment -- **Weight and volume-based inventory** system -- **Equipment slots**: Weapon, Backpack, Armor, Head, Tool -- **Durability system** - items degrade with use -- **Item tiers** (1-3) affecting quality and stats -- **Encumbrance system** - affects stamina costs -- **Ground item drops** - pick up and drop items - -#### ๐Ÿ”จ Crafting & Repair -- **Crafting system** with material requirements -- **Tool requirements** for certain recipes -- **Repair mechanics** - restore item durability -- **Uncrafting/Disassembly** - break down items for materials -- **Workbench locations** for advanced crafting -- **Craft level requirements** - unlocked through progression - -#### ๐Ÿ” Scavenging & Interactables -- **Searchable objects** in each location (dumpsters, cars, houses, etc.) -- **Action-based interaction** system with stamina costs -- **Success/failure mechanics** with critical outcomes -- **Loot tables** with item drop chances -- **One-time and respawning interactables** -- **Status tracking** per player (already looted, depleted, etc.) - -#### ๐Ÿ“Š Character Progression -- **Level system** (1-50+) with XP requirements -- **Stat points** - allocate to Strength, Defense, Stamina -- **Character customization** on creation -- **Skill progression** tied to crafting levels - -#### ๐ŸŒ World Features -- **Multi-location world** (Downtown, Gas Station, Residential, Clinic, Plaza, Park, Warehouse, Office Buildings, Subway, etc.) -- **Location tags** - workbench, repair_station, safe_zone -- **Danger zones** with varying encounter rates -- **Location-specific loot** and enemy spawns - -#### ๐Ÿ’ฌ Social & Multiplayer -- **Online player tracking** via WebSockets -- **Real-time player position updates** -- **PvP combat system** with challenge mechanics -- **Character browsing** - see other players' stats - -#### ๐ŸŽจ PWA Features -- **Progressive Web App** - installable on mobile/desktop -- **Multi-language support** (English, Spanish) -- **Responsive UI** with mobile-first design -- **Real-time updates** via WebSockets -- **Offline capabilities** (service worker) +Echoes of the Ash is a **browser-based RPG** set in a dark, post-apocalyptic world. Inspired by classic MUD (Multi-User Dungeon) games, it combines text-driven exploration with visual elements, real-time combat, and survival mechanics. --- -## ๐Ÿ“ Gamedata Structure +## ๐ŸŒŸ Current Game Features -The game uses JSON files in the `gamedata/` directory to define all game content. This modular approach makes it easy to add new content without code changes. +### Core Systems -### Directory Layout +| Feature | Status | Description | +|---------|--------|-------------| +| **Character System** | โœ… Complete | Create characters with 4 stats (Strength, Agility, Endurance, Intellect) | +| **Health & Stamina** | โœ… Complete | HP/Stamina management with visual progress bars | +| **Leveling & XP** | โœ… Complete | XP-based progression with stat point allocation | +| **Inventory** | โœ… Complete | Weight/volume-based carrying capacity | +| **Equipment** | โœ… Complete | Weapon, armor, and backpack slots | +| **Combat (PvE)** | โœ… Complete | Turn-based combat with visual effects | +| **Combat (PvP)** | โœ… Complete | Player vs Player combat system | +| **Real-time Updates** | โœ… Complete | WebSocket-based live game state | -``` -gamedata/ -โ”œโ”€โ”€ npcs.json # Enemy NPCs and combat encounters -โ”œโ”€โ”€ items.json # All items, weapons, consumables, and resources -โ”œโ”€โ”€ locations.json # World map locations and interactables -โ””โ”€โ”€ interactables.json # Interactable object templates -``` +### Exploration & Interaction + +| Feature | Status | Description | +|---------|--------|-------------| +| **World Map** | โœ… Complete | Graph-based location system with connections | +| **Movement** | โœ… Complete | Navigate between connected locations | +| **Interactables** | โœ… Complete | Search containers, objects for loot | +| **Enemy Spawning** | โœ… Complete | Static and wandering NPCs | +| **Corpse Looting** | โœ… Complete | Loot fallen enemies and players | +| **Dropped Items** | โœ… Complete | Pick up items on the ground | + +### Crafting & Economy + +| Feature | Status | Description | +|---------|--------|-------------| +| **Workbench** | โœ… Complete | Craft, repair, and salvage items | +| **Crafting System** | โœ… Complete | Create items from materials | +| **Repair System** | โœ… Complete | Restore durability to equipment | +| **Salvage System** | โœ… Complete | Break down items for materials | + +### Social & Multiplayer + +| Feature | Status | Description | +|---------|--------|-------------| +| **Accounts** | โœ… Complete | Registration, login, JWT authentication | +| **Multiple Characters** | โœ… Complete | Create up to 3 characters per account | +| **Leaderboards** | โœ… Complete | Rankings by level, kills, XP | +| **Player Profiles** | โœ… Complete | View player stats and equipment | +| **Online Players** | โœ… Complete | See who's currently online | + +### Platforms + +| Platform | Status | Description | +|----------|--------|-------------| +| **Web Browser** | โœ… Complete | Play at any time via modern browser | +| **PWA (Mobile)** | โœ… Complete | Install as app on mobile devices | +| **Electron Desktop** | โœ… Complete | Standalone Windows/Mac/Linux app | +| **Steam Integration** | ๐Ÿ”ง Setup | Steamworks SDK ready for deployment | --- -## ๐Ÿ“‹ `npcs.json` Structure +## ๐ŸŽฏ What Can Players Do? -Defines all enemy NPCs, their stats, loot tables, and spawn locations. +### Getting Started +1. **Create an Account** - Register with username and password +2. **Create a Character** - Name your survivor and choose starting stats +3. **Enter the World** - Spawn at the starting location -### Top-Level Structure -```json -{ - "npcs": { ... }, // NPC definitions - "danger_levels": { ... }, // Danger settings per location - "spawn_tables": { ... } // Enemy spawn weights per location -} -``` +### Gameplay Loop +1. **Explore** - Move between connected locations to discover new areas +2. **Scavenge** - Search containers, corpses, and interactables for supplies +3. **Fight** - Engage hostile NPCs in turn-based combat +4. **Craft** - Use workbenches to create, repair, or salvage items +5. **Level Up** - Gain XP from combat and allocate stat points +6. **Survive** - Manage HP, stamina, and inventory weight -### NPC Definition -```json -"npc_id": { - "npc_id": "unique_npc_identifier", - "name": { - "en": "English Name", - "es": "Spanish Name" - }, - "description": { - "en": "English description", - "es": "Spanish description" - }, - "emoji": "๐Ÿ•", - "hp_min": 15, // Minimum HP when spawned - "hp_max": 25, // Maximum HP when spawned - "damage_min": 3, // Minimum attack damage - "damage_max": 7, // Maximum attack damage - "defense": 0, // Damage reduction - "xp_reward": 10, // XP given on defeat - "loot_table": [ // Items dropped on death (automatic) - { - "item_id": "raw_meat", - "quantity_min": 1, - "quantity_max": 2, - "drop_chance": 0.6 // 60% chance to drop - } - ], - "corpse_loot": [ // Items harvestable from corpse - { - "item_id": "animal_hide", - "quantity_min": 1, - "quantity_max": 1, - "required_tool": "knife" // Tool needed to harvest (null = no requirement) - } - ], - "flee_chance": 0.3, // Chance NPC flees from combat - "status_inflict_chance": 0.15, // Chance to inflict status effect on hit - "image_path": "images/npcs/feral_dog.webp", - "death_message": "The feral dog whimpers and collapses..." -} -``` +### Combat +- **Attack** enemies with equipped weapons +- **Use Items** during battle (healing, buffs) +- **Flee** when outmatched (success based on Agility) +- **PvP** - Challenge other players in combat -### Danger Levels -```json -"location_id": { - "danger_level": 2, // 0-4 scale - "encounter_rate": 0.2, // 20% chance per movement - "wandering_chance": 0.35 // 35% chance for random encounter while idle -} -``` - -### Spawn Tables -```json -"location_id": [ - { - "npc_id": "raider_scout", - "weight": 50 // Weighted random spawn (higher = more common) - }, - { - "npc_id": "infected_human", - "weight": 30 - } -] -``` - -**Available NPCs:** -- `feral_dog` - Wild, hungry canine (Tier 1) -- `mutant_rat` - Radiation-mutated rodent (Tier 1) -- `raider_scout` - Hostile human raider (Tier 2) -- `scavenger` - Aggressive survivor (Tier 2) -- `infected_human` - Virus-infected zombie-like human (Tier 3) +### Character Progression +- **4 Core Stats**: Strength, Agility, Endurance, Intellect +- **Equipment**: Weapons, armor, backpacks +- **Stat Points**: Earn 1 per level to customize your build --- -## ๐ŸŽ’ `items.json` Structure +## ๐Ÿ› ๏ธ Technical Stack -Defines all items, equipment, weapons, consumables, and crafting materials. +### Frontend (PWA) +- **Framework**: React 18 + TypeScript +- **Build Tool**: Vite +- **State Management**: Zustand +- **Real-time**: WebSocket connections +- **Styling**: Custom CSS with dark theme -### Item Categories (Types) -- `resource` - Raw materials for crafting -- `consumable` - Food, medicine, usable items -- `weapon` - Melee and ranged weapons -- `backpack` - Inventory capacity upgrades -- `armor` - Protective equipment -- `tool` - Utility items (flashlight, etc.) -- `quest` - Story/quest items +### Backend (API) +- **Framework**: FastAPI (Python) +- **Database**: SQLite (development) / PostgreSQL (production) +- **Cache**: Redis for real-time state +- **Auth**: JWT tokens -### Basic Item Structure -```json -"item_id": { - "name": { - "en": "Item Name", - "es": "Spanish Name" - }, - "description": { - "en": "Description text", - "es": "Spanish description" - }, - "type": "resource", - "weight": 0.5, // Kilograms - "volume": 0.2, // Liters - "emoji": "โš™๏ธ", - "image_path": "images/items/scrap_metal.webp" -} -``` - -### Consumable Items -```json -"item_id": { - ...basic fields..., - "type": "consumable", - "hp_restore": 20, // Health restored - "stamina_restore": 10, // Stamina restored - "treats": "Bleeding" // Status effect cured (optional) -} -``` - -### Weapon/Equipment Items -```json -"item_id": { - ...basic fields..., - "type": "weapon", - "equippable": true, - "slot": "weapon", // Equipment slot: weapon, backpack, armor, head, tool - "durability": 100, // Max durability - "tier": 2, // 1-3 quality tier - "encumbrance": 2, // Stamina penalty when equipped - "stats": { - "damage_min": 5, - "damage_max": 10, - "weight_capacity": 20, // For backpacks - "volume_capacity": 20, - "defense": 5 // For armor - }, - "weapon_effects": { // Status effects inflicted (optional) - "bleeding": { - "chance": 0.15, // 15% chance on hit - "damage": 2, // Damage per turn - "duration": 3 // Turns - } - } -} -``` - -### Craftable Items -```json -"item_id": { - ...other fields..., - "craftable": true, - "craft_level": 2, // Required crafting level - "craft_materials": [ - { - "item_id": "scrap_metal", - "quantity": 3 - } - ], - "craft_tools": [ // Tools consumed during crafting - { - "item_id": "hammer", - "durability_cost": 3 // Durability consumed - } - ] -} -``` - -### Repairable Items -```json -"item_id": { - ...other fields..., - "repairable": true, - "repair_materials": [ - { - "item_id": "scrap_metal", - "quantity": 2 - } - ], - "repair_tools": [ - { - "item_id": "hammer", - "durability_cost": 2 - } - ], - "repair_percentage": 30 // % of max durability restored -} -``` - -### Uncraftable Items (Disassembly) -```json -"item_id": { - ...other fields..., - "uncraftable": true, - "uncraft_yield": [ // Materials returned - { - "item_id": "scrap_metal", - "quantity": 2 - } - ], - "uncraft_loss_chance": 0.25, // 25% chance to lose materials - "uncraft_tools": [ - { - "item_id": "hammer", - "durability_cost": 1 - } - ] -} -``` - -**Item Examples:** -- **Resources:** `scrap_metal`, `cloth_scraps`, `wood_planks`, `bone`, `raw_meat` -- **Consumables:** `canned_food`, `water_bottle`, `bandage`, `antibiotics`, `rad_pills` -- **Weapons:** `rusty_knife`, `knife`, `tire_iron`, `makeshift_spear`, `reinforced_bat` -- **Backpacks:** `tattered_rucksack`, `hiking_backpack` -- **Tools:** `flashlight`, `hammer` +### Desktop (Electron) +- **Framework**: Electron 28 +- **Steam SDK**: steamworks.js integration +- **Builds**: Windows (NSIS, Portable), Linux (AppImage, DEB), macOS --- -## ๐Ÿ—บ๏ธ `locations.json` Structure +## ๐Ÿ“Š Asset Summary -Defines the game world, all locations, coordinates, and interactable objects. - -### Location Definition -```json -{ - "id": "location_id", - "name": { - "en": "๐Ÿš๏ธ Location Name", - "es": "Spanish Name" - }, - "description": { - "en": "Atmospheric description of the location...", - "es": "Spanish description" - }, - "image_path": "images/locations/location.webp", - "x": 0, // Grid X coordinate - "y": 2, // Grid Y coordinate - "tags": [ // Optional tags - "workbench", // Has crafting bench - "repair_station", // Can repair items - "safe_zone" // No random encounters - ], - "interactables": { ... } // Interactable objects at this location -} -``` - -### Interactable Object Instance -```json -"unique_interactable_id": { - "template_id": "dumpster", // References interactables.json - "outcomes": { - "action_id": { - "stamina_cost": 2, - "success_rate": 0.5, // 50% base success chance - "crit_success_chance": 0.1, // 10% chance for critical success - "crit_failure_chance": 0.1, // 10% chance for critical failure - "rewards": { - "damage": 0, // Damage on normal failure - "crit_damage": 8, // Damage on critical failure - "items": [ // Items on normal success - { - "item_id": "plastic_bottles", - "quantity": 3, - "chance": 1.0 // 100% drop rate - } - ], - "crit_items": [ // Items on critical success - { - "item_id": "rare_item", - "quantity": 1, - "chance": 0.5 - } - ] - }, - "text": { // Locale-specific text responses - "success": { - "en": "You find something useful!", - "es": "ยกEncuentras algo รบtil!" - }, - "failure": { - "en": "Nothing here.", - "es": "Nada aquรญ." - }, - "crit_success": { ... }, - "crit_failure": { ... } - } - } - } -} -``` - -**Available Locations:** -- `start_point` - Ruined Downtown Core (0, 0) - Starting location -- `gas_station` - Abandoned Gas Station (0, 2) - Has workbench -- `residential` - Residential Street (3, 0) -- `clinic` - Old Clinic (2, 3) - Medical supplies -- `plaza` - Shopping Plaza (-2.5, 0) -- `park` - Suburban Park (-1, -2) -- `overpass` - Highway Overpass (1.0, 4.5) -- `warehouse` - Warehouse District -- `office_building` - Office Tower -- `subway` - Subway Station +| Category | Count | Size | +|----------|-------|------| +| Location Images | 14 | - | +| Item Images | 40 | - | +| NPC Images | 5 | - | +| Interactable Images | 8 | - | +| Icon Sets | 1 | - | +| **Total Images** | **134 files** | **~79 MB** | +| Sound Effects | 0 | 0 | +| Music | 0 | 0 | --- -## ๐Ÿ” `interactables.json` Structure +## ๐Ÿ—บ๏ธ Roadmap -Defines templates for interactable objects that can be placed in locations. +### In Progress +- [ ] Sound effects and ambient music +- [ ] Quest/mission system +- [ ] NPC dialogue trees -### Interactable Template -```json -"template_id": { - "id": "template_id", - "name": { - "en": "๐Ÿ—‘๏ธ Object Name", - "es": "Spanish Name" - }, - "description": { - "en": "Object description", - "es": "Spanish description" - }, - "image_path": "images/interactables/object.webp", - "actions": { // Available actions for this object - "action_id": { - "id": "action_id", - "label": { - "en": "๐Ÿ”Ž Action Label", - "es": "Spanish Label" - }, - "stamina_cost": 2 // Base stamina cost (can be overridden in locations) - } - } -} -``` - -**Available Interactable Templates:** -- `rubble` - Pile of debris (Action: search) -- `dumpster` - Trash container (Action: search_dumpster) -- `sedan` - Abandoned car (Actions: search_glovebox, pop_trunk) -- `house` - Abandoned house (Action: search_house) -- `toolshed` - Tool shed (Action: search_shed) -- `medkit` - Medical supply cabinet (Action: search_medkit) -- `storage_box` - Storage container (Action: search) -- `vending_machine` - Vending machine (Actions: break, search) +### Planned Features +- [ ] Crafting recipes expansion +- [ ] Faction/reputation system +- [ ] Player trading +- [ ] Housing/storage +- [ ] Skill tree system +- [ ] Status effects (poison, bleeding, etc.) +- [ ] Weather/day-night cycle +- [ ] Achievements --- -## ๐Ÿ› ๏ธ Replicating Gamedata - -### Adding a New NPC - -1. **Create NPC definition** in `npcs.json` under `"npcs"`: -```json -"my_new_npc": { - "npc_id": "my_new_npc", - "name": { "en": "My NPC", "es": "Mi NPC" }, - "description": { "en": "Description", "es": "Descripciรณn" }, - "emoji": "๐Ÿ‘น", - "hp_min": 20, "hp_max": 30, - "damage_min": 4, "damage_max": 8, - "defense": 1, - "xp_reward": 15, - "loot_table": [...], - "corpse_loot": [...], - "flee_chance": 0.2, - "status_inflict_chance": 0.1, - "image_path": "images/npcs/my_new_npc.webp", - "death_message": "The creature falls..." -} -``` - -2. **Add to spawn table** in `npcs.json` under `"spawn_tables"`: -```json -"location_id": [ - { "npc_id": "my_new_npc", "weight": 40 } -] -``` - -3. **Add image** at `images/npcs/my_new_npc.webp` - -### Adding a New Item - -1. **Create item definition** in `items.json`: -```json -"my_new_item": { - "name": { "en": "My Item", "es": "Mi Objeto" }, - "description": { "en": "Description", "es": "Descripciรณn" }, - "type": "resource", - "weight": 1.0, - "volume": 0.5, - "emoji": "๐Ÿ”ฎ", - "image_path": "images/items/my_new_item.webp" -} -``` - -2. **Add to loot tables** (optional) in locations or NPCs - -3. **Add image** at `images/items/my_new_item.webp` - -### Adding a New Location - -1. **Create location** in `locations.json`: -```json -{ - "id": "my_location", - "name": { "en": "๐Ÿญ My Location", "es": "Mi Ubicaciรณn" }, - "description": { "en": "Description", "es": "Descripciรณn" }, - "image_path": "images/locations/my_location.webp", - "x": 5, - "y": 3, - "tags": ["workbench"], - "interactables": { - "my_location_box": { - "template_id": "storage_box", - "outcomes": { - "search": { ...outcome definition... } - } - } - } -} -``` - -2. **Add danger level** in `npcs.json`: -```json -"my_location": { - "danger_level": 2, - "encounter_rate": 0.15, - "wandering_chance": 0.3 -} -``` - -3. **Add spawn table** in `npcs.json`: -```json -"my_location": [ - { "npc_id": "raider_scout", "weight": 60 }, - { "npc_id": "mutant_rat", "weight": 40 } -] -``` - -4. **Add image** at `images/locations/my_location.webp` - -### Adding a New Interactable Template - -1. **Create template** in `interactables.json`: -```json -"my_interactable": { - "id": "my_interactable", - "name": { "en": "๐ŸŽฐ My Object", "es": "Mi Objeto" }, - "description": { "en": "Description", "es": "Descripciรณn" }, - "image_path": "images/interactables/my_object.webp", - "actions": { - "my_action": { - "id": "my_action", - "label": { "en": "๐Ÿ”จ Do Action", "es": "Hacer Acciรณn" }, - "stamina_cost": 3 - } - } -} -``` - -2. **Use in locations** in `locations.json` interactables - -3. **Add image** at `images/interactables/my_object.webp` - ---- - -## ๐ŸŽฏ Key Game Mechanics - -### Stamina System -- Base stamina pool (increases with Stamina stat) -- Regenerates passively over time -- Consumed by: Movement, Combat Actions, Interactions, Crafting -- Encumbrance from equipment increases stamina costs - -### Combat Flow -1. Player or NPC initiates combat -2. Turn-based with initiative system -3. NPCs show **intent preview** (next planned action) -4. Player chooses: Attack, Defend, Use Item, Flee -5. Status effects tick each turn -6. Combat ends on death or successful flee - -### Loot System -- **Immediate drops** from loot_table (on death) -- **Corpse harvesting** from corpse_loot (requires tools) -- **Interactable loot** with success/failure mechanics -- **Respawn timers** for interactables - -### Crafting Requirements -- Sufficient materials in inventory -- Required tools with durability -- Crafting level unlocked -- Optional: Workbench location tag - ---- - -## ๐Ÿ“š Additional Documentation - -- **[CLAUDE.md](./CLAUDE.md)** - Project structure and development commands -- **[QUICK_REFERENCE.md](./QUICK_REFERENCE.md)** - API endpoints and architecture -- **[docker-compose.yml](./docker-compose.yml)** - Infrastructure setup - ---- - -## ๐Ÿš€ Quick Start +## ๐Ÿš€ Running the Game +### Web/PWA (Docker) ```bash -# Start the game -docker compose up -d - -# View API logs -docker compose logs -f echoes_of_the_ashes_api - -# Rebuild after changes -docker compose build && docker compose up -d +docker compose up echoes_of_the_ashes_pwa echoes_of_the_ashes_api ``` -Game runs at: `http://localhost` (PWA) and `http://localhost/api` (API) +### Electron Development +```bash +cd pwa +npm install +npm run electron:dev +``` + +### Build Electron Apps +```bash +npm run electron:build:win # Windows +npm run electron:build:linux # Linux +npm run electron:build:mac # macOS +``` --- -## ๐Ÿ“ License +## ๐Ÿ“ Additional Documentation -All rights reserved. Post-apocalyptic survival simulation for educational purposes. +- [Game Mechanics](docs/game/MECHANICS.md) - Detailed gameplay systems +- [API Documentation](docs/api/) - Backend endpoints reference +- [Development Guide](docs/development/) - Contributing and architecture +- [Map Editor](web-map/README.md) - World building tools + +--- + +**Version**: 1.0.0-alpha +**Last Updated**: December 2025 diff --git a/api/internal.old.py b/api/internal.old.py deleted file mode 100644 index 3da1454..0000000 --- a/api/internal.old.py +++ /dev/null @@ -1,283 +0,0 @@ -""" -Internal API endpoints for Telegram Bot -These endpoints are protected by an internal key and handle game logic -""" - -from fastapi import APIRouter, Header, HTTPException, Depends -from pydantic import BaseModel -from typing import Optional, Dict, Any, List -import os - -# Internal API key for bot authentication -INTERNAL_API_KEY = os.getenv("API_INTERNAL_KEY", "internal-bot-access-key-change-me") - -router = APIRouter(prefix="/api/internal", tags=["internal"]) - - -def verify_internal_key(x_internal_key: str = Header(...)): - """Verify internal API key""" - if x_internal_key != INTERNAL_API_KEY: - raise HTTPException(status_code=403, detail="Invalid internal API key") - return True - - -# ==================== Pydantic Models ==================== - -class PlayerCreate(BaseModel): - telegram_id: int - name: str = "Survivor" - -class PlayerUpdate(BaseModel): - name: Optional[str] = None - hp: Optional[int] = None - stamina: Optional[int] = None - location_id: Optional[str] = None - level: Optional[int] = None - xp: Optional[int] = None - strength: Optional[int] = None - agility: Optional[int] = None - endurance: Optional[int] = None - intellect: Optional[int] = None - -class MoveRequest(BaseModel): - direction: str - -class CombatStart(BaseModel): - telegram_id: int - npc_id: str - -class CombatAction(BaseModel): - action: str # "attack", "defend", "flee" - -class UseItem(BaseModel): - item_db_id: int - -class EquipItem(BaseModel): - item_db_id: int - - -# ==================== Player Endpoints ==================== - -@router.get("/player/telegram/{telegram_id}") -async def get_player_by_telegram( - telegram_id: int, - _: bool = Depends(verify_internal_key) -): - """Get player by Telegram ID""" - from bot.database import get_player - player = await get_player(telegram_id=telegram_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - return player - - -@router.post("/player") -async def create_player_internal( - player_data: PlayerCreate, - _: bool = Depends(verify_internal_key) -): - """Create a new player (Telegram bot)""" - from bot.database import create_player - player = await create_player(telegram_id=player_data.telegram_id, name=player_data.name) - if not player: - raise HTTPException(status_code=500, detail="Failed to create player") - return player - - -@router.patch("/player/telegram/{telegram_id}") -async def update_player_internal( - telegram_id: int, - updates: PlayerUpdate, - _: bool = Depends(verify_internal_key) -): - """Update player data""" - from bot.database import update_player - - # Convert to dict and remove None values - update_dict = {k: v for k, v in updates.dict().items() if v is not None} - - if not update_dict: - return {"success": True, "message": "No updates provided"} - - await update_player(telegram_id=telegram_id, updates=update_dict) - return {"success": True, "message": "Player updated"} - - -# ==================== Location Endpoints ==================== - -@router.get("/location/{location_id}") -async def get_location_internal( - location_id: str, - _: bool = Depends(verify_internal_key) -): - """Get location details""" - from api.main import LOCATIONS - - location = LOCATIONS.get(location_id) - if not location: - raise HTTPException(status_code=404, detail="Location not found") - - return { - "id": location.id, - "name": location.name, - "description": location.description, - "exits": location.exits, - "interactables": {k: { - "id": v.id, - "name": v.name, - "actions": list(v.actions.keys()) - } for k, v in location.interactables.items()}, - "image_path": location.image_path - } - - -@router.post("/player/telegram/{telegram_id}/move") -async def move_player_internal( - telegram_id: int, - move_data: MoveRequest, - _: bool = Depends(verify_internal_key) -): - """Move player in a direction""" - from bot.database import get_player, update_player - from api.main import LOCATIONS - - player = await get_player(telegram_id=telegram_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - current_location = LOCATIONS.get(player['location_id']) - if not current_location: - raise HTTPException(status_code=400, detail="Invalid current location") - - # Check stamina - if player['stamina'] < 1: - raise HTTPException(status_code=400, detail="Not enough stamina to move") - - # Find exit - destination_id = current_location.exits.get(move_data.direction.lower()) - if not destination_id: - raise HTTPException(status_code=400, detail=f"Cannot move {move_data.direction} from here") - - new_location = LOCATIONS.get(destination_id) - if not new_location: - raise HTTPException(status_code=400, detail="Invalid destination") - - # Update player - await update_player(telegram_id=telegram_id, updates={ - 'location_id': new_location.id, - 'stamina': max(0, player['stamina'] - 1) - }) - - return { - "success": True, - "location": { - "id": new_location.id, - "name": new_location.name, - "description": new_location.description, - "exits": new_location.exits - }, - "stamina": max(0, player['stamina'] - 1) - } - - -# ==================== Inventory Endpoints ==================== - -@router.get("/player/telegram/{telegram_id}/inventory") -async def get_inventory_internal( - telegram_id: int, - _: bool = Depends(verify_internal_key) -): - """Get player's inventory""" - from bot.database import get_inventory - - inventory = await get_inventory(telegram_id) - return {"items": inventory} - - -@router.post("/player/telegram/{telegram_id}/use_item") -async def use_item_internal( - telegram_id: int, - item_data: UseItem, - _: bool = Depends(verify_internal_key) -): - """Use an item from inventory""" - from bot.logic import use_item_logic - from bot.database import get_player - - player = await get_player(telegram_id=telegram_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - result = await use_item_logic(player, item_data.item_db_id) - return result - - -@router.post("/player/telegram/{telegram_id}/equip") -async def equip_item_internal( - telegram_id: int, - item_data: EquipItem, - _: bool = Depends(verify_internal_key) -): - """Equip/unequip an item""" - from bot.logic import toggle_equip - - result = await toggle_equip(telegram_id, item_data.item_db_id) - return {"success": True, "message": result} - - -# ==================== Combat Endpoints ==================== - -@router.post("/combat/start") -async def start_combat_internal( - combat_data: CombatStart, - _: bool = Depends(verify_internal_key) -): - """Start combat with an NPC""" - from bot.combat import start_combat - from bot.database import get_player - - player = await get_player(telegram_id=combat_data.telegram_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - result = await start_combat(combat_data.telegram_id, combat_data.npc_id, player['location_id']) - if not result.get("success"): - raise HTTPException(status_code=400, detail=result.get("message", "Failed to start combat")) - - return result - - -@router.get("/combat/telegram/{telegram_id}") -async def get_combat_internal( - telegram_id: int, - _: bool = Depends(verify_internal_key) -): - """Get active combat state""" - from bot.combat import get_active_combat - - combat = await get_active_combat(telegram_id) - if not combat: - raise HTTPException(status_code=404, detail="No active combat") - - return combat - - -@router.post("/combat/telegram/{telegram_id}/action") -async def combat_action_internal( - telegram_id: int, - action_data: CombatAction, - _: bool = Depends(verify_internal_key) -): - """Perform combat action""" - from bot.combat import player_attack, player_defend, player_flee - - if action_data.action == "attack": - result = await player_attack(telegram_id) - elif action_data.action == "defend": - result = await player_defend(telegram_id) - elif action_data.action == "flee": - result = await player_flee(telegram_id) - else: - raise HTTPException(status_code=400, detail="Invalid combat action") - - return result diff --git a/api/main.old.py b/api/main.old.py deleted file mode 100644 index 0beb594..0000000 --- a/api/main.old.py +++ /dev/null @@ -1,499 +0,0 @@ -from fastapi import FastAPI, Depends, HTTPException, status -from fastapi.middleware.cors import CORSMiddleware -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from pydantic import BaseModel -from typing import Optional, List -import jwt -import bcrypt -from datetime import datetime, timedelta -import os -import sys - -# Add parent directory to path to import bot modules -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from bot.database import get_player, create_player -from data.world_loader import load_world -from api.internal import router as internal_router - -app = FastAPI(title="Echoes of the Ashes API", version="1.0.0") - -# Include internal API router -app.include_router(internal_router) - -# CORS configuration -app.add_middleware( - CORSMiddleware, - allow_origins=["https://echoesoftheashgame.patacuack.net", "http://localhost:3000"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# JWT Configuration -SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production") -ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days - -security = HTTPBearer() - -# Load world data -WORLD = None -LOCATIONS = {} -try: - WORLD = load_world() - # WORLD.locations is already a dict {location_id: Location} - LOCATIONS = WORLD.locations - print(f"โœ… Loaded {len(LOCATIONS)} locations") -except Exception as e: - print(f"โš ๏ธ Warning: Could not load world data: {e}") - import traceback - traceback.print_exc() - -# Pydantic Models -class UserRegister(BaseModel): - username: str - password: str - -class UserLogin(BaseModel): - username: str - password: str - -class Token(BaseModel): - access_token: str - token_type: str = "bearer" - -class User(BaseModel): - id: int - username: str - telegram_id: Optional[str] = None - -class PlayerState(BaseModel): - location_id: str - location_name: str - health: int - max_health: int - stamina: int - max_stamina: int - inventory: List[dict] - status_effects: List[dict] - -class MoveRequest(BaseModel): - direction: str - - -# Helper Functions -def create_access_token(data: dict): - to_encode = data.copy() - expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) - return encoded_jwt - -def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)): - try: - token = credentials.credentials - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - user_id: int = payload.get("sub") - if user_id is None: - raise HTTPException(status_code=401, detail="Invalid authentication credentials") - return user_id - except jwt.ExpiredSignatureError: - raise HTTPException(status_code=401, detail="Token has expired") - except jwt.JWTError: - raise HTTPException(status_code=401, detail="Could not validate credentials") - - -# Routes -@app.get("/") -async def root(): - return {"message": "Echoes of the Ashes API", "status": "online"} - -@app.post("/api/auth/register", response_model=Token) -async def register(user_data: UserRegister): - """Register a new user account""" - try: - # Check if username already exists - existing_player = await get_player(username=user_data.username) - if existing_player: - raise HTTPException(status_code=400, detail="Username already exists") - - # Hash password - password_hash = bcrypt.hashpw(user_data.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - - # Create player with web auth - player = await create_player( - telegram_id=None, - username=user_data.username, - password_hash=password_hash - ) - - if not player or 'id' not in player: - print(f"ERROR: create_player returned: {player}") - raise HTTPException(status_code=500, detail="Failed to create player - no ID returned") - - # Create token - access_token = create_access_token(data={"sub": player['id']}) - - return {"access_token": access_token} - except HTTPException: - raise - except Exception as e: - import traceback - print(f"ERROR in register: {str(e)}") - print(traceback.format_exc()) - raise HTTPException(status_code=500, detail=str(e)) - -@app.post("/api/auth/login", response_model=Token) -async def login(user_data: UserLogin): - """Login with username and password""" - try: - # Get player - player = await get_player(username=user_data.username) - if not player or not player.get('password_hash'): - raise HTTPException(status_code=401, detail="Invalid username or password") - - # Verify password - if not bcrypt.checkpw(user_data.password.encode('utf-8'), player['password_hash'].encode('utf-8')): - raise HTTPException(status_code=401, detail="Invalid username or password") - - # Create token - access_token = create_access_token(data={"sub": player['id']}) - - return {"access_token": access_token} - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.get("/api/auth/me", response_model=User) -async def get_current_user(user_id: int = Depends(verify_token)): - """Get current authenticated user""" - player = await get_player(player_id=user_id) - if not player: - raise HTTPException(status_code=404, detail="User not found") - - return { - "id": player['id'], - "username": player.get('username'), - "telegram_id": player.get('telegram_id') - } - -@app.get("/api/game/state", response_model=PlayerState) -async def get_game_state(user_id: int = Depends(verify_token)): - """Get current player game state""" - player = await get_player(player_id=user_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - location = LOCATIONS.get(player['location_id']) - - # TODO: Get actual inventory and status effects from database - inventory = [] - status_effects = [] - - return { - "location_id": player['location_id'], - "location_name": location.name if location else "Unknown", - "health": player['hp'], - "max_health": player['max_hp'], - "stamina": player['stamina'], - "max_stamina": player['max_stamina'], - "inventory": inventory, - "status_effects": status_effects - } - -@app.post("/api/game/move") -async def move_player(move_data: MoveRequest, user_id: int = Depends(verify_token)): - """Move player in a direction""" - from bot.database import update_player - - player = await get_player(player_id=user_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - current_location = LOCATIONS.get(player['location_id']) - if not current_location: - raise HTTPException(status_code=400, detail="Invalid current location") - - # Check if player has enough stamina - if player['stamina'] < 1: - raise HTTPException(status_code=400, detail="Not enough stamina to move") - - # Find exit in the specified direction (exits is dict {direction: destination_id}) - destination_id = current_location.exits.get(move_data.direction.lower()) - - if not destination_id: - raise HTTPException(status_code=400, detail=f"Cannot move {move_data.direction} from here") - - # Move player - new_location = LOCATIONS.get(destination_id) - if not new_location: - raise HTTPException(status_code=400, detail="Invalid destination") - - # Update player location and stamina (use player_id for web users) - await update_player(player_id=player['id'], updates={ - 'location_id': new_location.id, - 'stamina': max(0, player['stamina'] - 1) - }) - - # Get updated player state - updated_player = await get_player(player_id=user_id) - - return { - "success": True, - "message": f"You travel {move_data.direction} to {new_location.name}. {new_location.description}", - "player_state": { - "location_id": updated_player['location_id'], - "location_name": new_location.name, - "health": updated_player['hp'], - "max_health": updated_player['max_hp'], - "stamina": updated_player['stamina'], - "max_stamina": updated_player['max_stamina'], - "inventory": [], - "status_effects": [] - } - } - -@app.get("/api/game/location") -async def get_current_location(user_id: int = Depends(verify_token)): - """Get detailed information about current location""" - player = await get_player(player_id=user_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - location = LOCATIONS.get(player['location_id']) - if not location: - raise HTTPException(status_code=404, detail=f"Location '{player['location_id']}' not found") - - # Get available directions from exits dict - directions = list(location.exits.keys()) - - # Get NPCs at location (TODO: implement NPC spawning) - npcs = [] - - # Get items at location (TODO: implement dropped items) - items = [] - - # Determine image extension (png or jpg) - image_url = None - if location.image_path: - # Use the path from location data - image_url = f"/{location.image_path}" - else: - # Default to png with fallback to jpg - image_url = f"/images/locations/{location.id}.png" - - return { - "id": location.id, - "name": location.name, - "description": location.description, - "directions": directions, - "npcs": npcs, - "items": items, - "image_url": image_url, - "interactables": [{"id": k, "name": v.name} for k, v in location.interactables.items()] - } - -@app.get("/api/game/inventory") -async def get_inventory(user_id: int = Depends(verify_token)): - """Get player's inventory""" - from bot.database import get_inventory - - player = await get_player(player_id=user_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - # For web users without telegram_id, inventory might be empty - # This is a limitation of the current schema - inventory = [] - - return { - "items": inventory, - "capacity": 20 # TODO: Calculate based on equipped bag - } - -@app.get("/api/game/profile") -async def get_profile(user_id: int = Depends(verify_token)): - """Get player profile and stats""" - player = await get_player(player_id=user_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - return { - "name": player['name'], - "level": player['level'], - "xp": player['xp'], - "hp": player['hp'], - "max_hp": player['max_hp'], - "stamina": player['stamina'], - "max_stamina": player['max_stamina'], - "strength": player['strength'], - "agility": player['agility'], - "endurance": player['endurance'], - "intellect": player['intellect'], - "unspent_points": player['unspent_points'], - "is_dead": player['is_dead'] - } - -@app.get("/api/game/map") -async def get_map(user_id: int = Depends(verify_token)): - """Get world map data""" - player = await get_player(player_id=user_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - # Return all locations and connections (LOCATIONS is dict {id: Location}) - locations_data = [] - for loc_id, loc in LOCATIONS.items(): - locations_data.append({ - "id": loc.id, - "name": loc.name, - "description": loc.description, - "exits": loc.exits # Dict of {direction: destination_id} - }) - - return { - "current_location": player['location_id'], - "locations": locations_data - } - -@app.post("/api/game/inspect") -async def inspect_area(user_id: int = Depends(verify_token)): - """Inspect the current area for details""" - player = await get_player(player_id=user_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - location = LOCATIONS.get(player['location_id']) - if not location: - raise HTTPException(status_code=404, detail="Location not found") - - # Get detailed information - interactables_detail = [] - for inst_id, inter in location.interactables.items(): - actions = [{"id": act.id, "label": act.label, "stamina_cost": act.stamina_cost} - for act in inter.actions.values()] - interactables_detail.append({ - "instance_id": inst_id, - "name": inter.name, - "actions": actions - }) - - return { - "location": location.name, - "description": location.description, - "interactables": interactables_detail, - "exits": location.exits - } - -class InteractRequest(BaseModel): - interactable_id: str - action_id: str - -@app.post("/api/game/interact") -async def interact_with_object(interact_data: InteractRequest, user_id: int = Depends(verify_token)): - """Interact with an object in the world""" - from bot.database import update_player, add_inventory_item - import random - - player = await get_player(player_id=user_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - location = LOCATIONS.get(player['location_id']) - if not location: - raise HTTPException(status_code=404, detail="Location not found") - - interactable = location.interactables.get(interact_data.interactable_id) - if not interactable: - raise HTTPException(status_code=404, detail="Interactable not found") - - action = interactable.actions.get(interact_data.action_id) - if not action: - raise HTTPException(status_code=404, detail="Action not found") - - # Check stamina - if player['stamina'] < action.stamina_cost: - raise HTTPException(status_code=400, detail="Not enough stamina") - - # Perform action - randomly choose outcome - outcome_key = random.choice(list(action.outcomes.keys())) - outcome = action.outcomes[outcome_key] - - # Apply outcome - stamina_change = -action.stamina_cost - hp_change = -outcome.damage_taken if outcome.damage_taken else 0 - items_found = outcome.items_reward if outcome.items_reward else {} - - # Update player - new_hp = max(1, player['hp'] + hp_change) - new_stamina = max(0, player['stamina'] + stamina_change) - - await update_player(player_id=player['id'], updates={ - 'hp': new_hp, - 'stamina': new_stamina - }) - - # Add items to inventory (if player has telegram_id for FK) - items_added = [] - if player.get('telegram_id') and items_found: - for item_id, quantity in items_found.items(): - # This will fail for web users without telegram_id - # TODO: Fix inventory schema - try: - items_added.append({"id": item_id, "quantity": quantity}) - except: - pass - - return { - "success": True, - "outcome": outcome_key, - "message": outcome.text, - "items_found": items_added, - "hp_change": hp_change, - "stamina_change": stamina_change, - "new_hp": new_hp, - "new_stamina": new_stamina - } - -class UseItemRequest(BaseModel): - item_db_id: int - -@app.post("/api/game/use_item") -async def use_item_endpoint(item_data: UseItemRequest, user_id: int = Depends(verify_token)): - """Use an item from inventory""" - from bot.logic import use_item_logic - - player = await get_player(player_id=user_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - if not player.get('telegram_id'): - raise HTTPException(status_code=400, detail="Inventory not available for web users yet") - - result = await use_item_logic(player, item_data.item_db_id) - return result - -class EquipItemRequest(BaseModel): - item_db_id: int - -@app.post("/api/game/equip_item") -async def equip_item_endpoint(item_data: EquipItemRequest, user_id: int = Depends(verify_token)): - """Equip or unequip an item""" - from bot.logic import toggle_equip - - player = await get_player(player_id=user_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - if not player.get('telegram_id'): - raise HTTPException(status_code=400, detail="Inventory not available for web users yet") - - result = await toggle_equip(player['telegram_id'], item_data.item_db_id) - return {"success": True, "message": result} - - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/api/main_original_5573_lines.py b/api/main_original_5573_lines.py deleted file mode 100644 index 9f46797..0000000 --- a/api/main_original_5573_lines.py +++ /dev/null @@ -1,5573 +0,0 @@ -""" -Standalone FastAPI application for Echoes of the Ashes. -All dependencies are self-contained in the api/ directory. -""" -from fastapi import FastAPI, HTTPException, Depends, status, WebSocket, WebSocketDisconnect -from fastapi.middleware.cors import CORSMiddleware -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel -from typing import Optional, List, Dict, Any -import jwt -import bcrypt -import asyncio -from datetime import datetime, timedelta -import os -import math -import time -from contextlib import asynccontextmanager -from pathlib import Path -import json -import logging -import traceback - -# Import our standalone modules -from . import database as db -from .world_loader import load_world, World, Location -from .items import ItemsManager -from . import game_logic -from . import background_tasks -from .redis_manager import redis_manager - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -# Helper function for distance calculation -def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float: - """ - Calculate distance between two points using Euclidean distance. - Coordinate system: 1 unit = 100 meters (so distance(0,0 to 1,1) = 141.4m) - """ - # Calculate distance in coordinate units - coord_distance = math.sqrt((x2 - x1)**2 + (y2 - y1)**2) - # Convert to meters (1 coordinate unit = 100 meters) - distance_meters = coord_distance * 100 - return distance_meters - -def calculate_stamina_cost(distance: float, weight: float, agility: int, max_weight: float = 10.0, volume: float = 0.0, max_volume: float = 10.0) -> int: - """ - Calculate stamina cost based on distance, weight, volume, capacity, and agility. - - Base cost: distance / 50 (so 50m = 1 stamina, 100m = 2 stamina) - - Weight penalty: +1 stamina per 10kg - - Agility reduction: -1 stamina per 3 agility points - - Over-capacity penalty: 50-200% extra if over weight OR volume limits - - Minimum: 1 stamina - """ - base_cost = max(1, round(distance / 50)) - weight_penalty = int(weight / 10) - agility_reduction = int(agility / 3) - - # Add over-capacity penalty (50% extra stamina cost if over limit) - over_capacity_penalty = 0 - if weight > max_weight or volume > max_volume: - weight_excess_ratio = max(0, (weight - max_weight) / max_weight) if max_weight > 0 else 0 - volume_excess_ratio = max(0, (volume - max_volume) / max_volume) if max_volume > 0 else 0 - excess_ratio = max(weight_excess_ratio, volume_excess_ratio) - # Penalty scales from 50% to 200% based on how much over capacity - over_capacity_penalty = int((base_cost + weight_penalty) * (0.5 + min(1.5, excess_ratio))) - - total_cost = max(1, base_cost + weight_penalty + over_capacity_penalty - agility_reduction) - return total_cost - - -async def calculate_player_capacity(player_id: int): - """ - Calculate player's current and max weight/volume capacity. - Returns: (current_weight, max_weight, current_volume, max_volume) - """ - inventory = await db.get_inventory(player_id) - current_weight = 0.0 - current_volume = 0.0 - max_weight = 10.0 # Base capacity - max_volume = 10.0 # Base capacity - - for inv_item in inventory: - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item_def: - current_weight += item_def.weight * inv_item['quantity'] - current_volume += item_def.volume * inv_item['quantity'] - - # Check for equipped bags/containers that increase capacity - if inv_item['is_equipped'] and item_def.stats: - max_weight += item_def.stats.get('weight_capacity', 0) - max_volume += item_def.stats.get('volume_capacity', 0) - - return current_weight, max_weight, current_volume, max_volume - -# Lifespan context manager for startup/shutdown -@asynccontextmanager -async def lifespan(app: FastAPI): - # Startup - await db.init_db() - print("โœ… Database initialized") - - # Connect to Redis - await redis_manager.connect() - print("โœ… Redis connected") - - # Inject Redis manager into ConnectionManager - manager.set_redis_manager(redis_manager) - - # Subscribe to all location channels + global broadcast - location_channels = [f"location:{loc_id}" for loc_id in LOCATIONS.keys()] - await redis_manager.subscribe_to_channels(location_channels + ['game:broadcast']) - print(f"โœ… Subscribed to {len(location_channels)} location channels") - - # Register this worker - await redis_manager.register_worker() - print(f"โœ… Worker registered: {redis_manager.worker_id}") - - # Start Redis message listener (background task) - redis_manager.start_listener(manager.handle_redis_message) - print("โœ… Redis listener started") - - # Start background tasks (distributed via Redis locks) - tasks = await background_tasks.start_background_tasks(manager, LOCATIONS) - if tasks: - print(f"โœ… Started {len(tasks)} background tasks in this worker") - else: - print("โญ๏ธ Background tasks running in another worker") - - yield - - # Shutdown - await background_tasks.stop_background_tasks(tasks) - - # Unregister worker - await redis_manager.unregister_worker() - print(f"๐Ÿ”Œ Worker unregistered: {redis_manager.worker_id}") - - # Disconnect from Redis - await redis_manager.disconnect() - print("โœ… Redis disconnected") - -app = FastAPI( - title="Echoes of the Ash API", - version="2.0.0", - description="Standalone game API with web and bot support", - lifespan=lifespan -) - -# CORS configuration -app.add_middleware( - CORSMiddleware, - allow_origins=[ - "https://echoesoftheashgame.patacuack.net", - "http://localhost:3000", - "http://localhost:5173" - ], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Mount static files for images -images_dir = Path(__file__).parent.parent / "images" -if images_dir.exists(): - app.mount("/images", StaticFiles(directory=str(images_dir)), name="images") - print(f"โœ… Mounted images directory: {images_dir}") -else: - print(f"โš ๏ธ Images directory not found: {images_dir}") - -# JWT Configuration -SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production-please") -ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days - -# Internal API key for bot communication -API_INTERNAL_KEY = os.getenv("API_INTERNAL_KEY", "change-this-internal-key") - -security = HTTPBearer() -oauth2_scheme = security # Alias for token extraction in character endpoints - -# Load game data -print("๐Ÿ”„ Loading game world...") -WORLD: World = load_world() -LOCATIONS: Dict[str, Location] = WORLD.locations -ITEMS_MANAGER = ItemsManager() -print(f"โœ… Game world ready: {len(LOCATIONS)} locations, {len(ITEMS_MANAGER.items)} items") - - -# ============================================================================ -# WebSocket Connection Manager -# ============================================================================ - -class ConnectionManager: - """ - Manages WebSocket connections for real-time game updates. - Tracks active connections and provides methods for broadcasting messages. - Now uses Redis pub/sub for cross-worker communication. - """ - def __init__(self): - # Maps player_id -> WebSocket connection (local to this worker only) - self.active_connections: Dict[int, WebSocket] = {} - # Maps player_id -> username for debugging - self.player_usernames: Dict[int, str] = {} - # Redis manager instance (injected later) - self.redis_manager = None - - def set_redis_manager(self, redis_manager): - """Inject Redis manager after initialization.""" - self.redis_manager = redis_manager - - async def connect(self, websocket: WebSocket, player_id: int, username: str): - """Accept a new WebSocket connection and track it.""" - await websocket.accept() - self.active_connections[player_id] = websocket - self.player_usernames[player_id] = username - - # Subscribe to player's personal channel - if self.redis_manager: - await self.redis_manager.subscribe_to_channels([f"player:{player_id}"]) - await self.redis_manager.mark_player_connected(player_id) - - print(f"๐Ÿ”Œ WebSocket connected: {username} (player_id={player_id}, worker={self.redis_manager.worker_id if self.redis_manager else 'N/A'})") - - async def disconnect(self, player_id: int): - """Remove a WebSocket connection.""" - if player_id in self.active_connections: - username = self.player_usernames.get(player_id, "unknown") - del self.active_connections[player_id] - if player_id in self.player_usernames: - del self.player_usernames[player_id] - - # Unsubscribe from player's personal channel - if self.redis_manager: - await self.redis_manager.unsubscribe_from_channel(f"player:{player_id}") - await self.redis_manager.mark_player_disconnected(player_id) - - print(f"๐Ÿ”Œ WebSocket disconnected: {username} (player_id={player_id})") - - async def send_personal_message(self, player_id: int, message: dict): - """Send a message to a specific player via Redis pub/sub.""" - if self.redis_manager: - # Send locally first if player is connected to this worker - if player_id in self.active_connections: - await self._send_direct(player_id, message) - else: - # Publish to Redis (player might be on another worker) - await self.redis_manager.publish_to_player(player_id, message) - else: - # Fallback to direct send (single worker mode) - await self._send_direct(player_id, message) - - async def _send_direct(self, player_id: int, message: dict): - """Directly send to local WebSocket connection.""" - if player_id in self.active_connections: - try: - print(f"๐Ÿ“จ Sending {message.get('type')} to player {player_id}") - await self.active_connections[player_id].send_json(message) - except Exception as e: - print(f"โŒ Failed to send message to player {player_id}: {e}") - await self.disconnect(player_id) - else: - print(f"โš ๏ธ Player {player_id} not in active connections, cannot send {message.get('type')}") - - async def broadcast(self, message: dict, exclude_player_id: Optional[int] = None): - """Broadcast a message to all connected players via Redis.""" - if self.redis_manager: - await self.redis_manager.publish_global_broadcast(message) - - # ALSO send to LOCAL connections immediately - for player_id in list(self.active_connections.keys()): - if player_id != exclude_player_id: - await self._send_direct(player_id, message) - else: - # Fallback: direct broadcast to local connections - disconnected = [] - for player_id, connection in self.active_connections.items(): - if player_id != exclude_player_id: - try: - await connection.send_json(message) - except Exception as e: - print(f"โŒ Failed to broadcast to player {player_id}: {e}") - disconnected.append(player_id) - - for player_id in disconnected: - await self.disconnect(player_id) - - async def send_to_location(self, location_id: str, message: dict, exclude_player_id: Optional[int] = None): - """Send a message to all players in a specific location via Redis pub/sub.""" - if self.redis_manager: - # Use Redis pub/sub for cross-worker broadcast - message_with_exclude = { - **message, - "exclude_player_id": exclude_player_id - } - await self.redis_manager.publish_to_location(location_id, message_with_exclude) - - # ALSO send to LOCAL connections immediately (don't wait for Redis roundtrip) - player_ids = await self.redis_manager.get_players_in_location(location_id) - for player_id in player_ids: - if player_id == exclude_player_id: - continue - if player_id in self.active_connections: - await self._send_direct(player_id, message) - else: - # Fallback: Query DB and send directly (single worker mode) - players_in_location = await db.get_players_in_location(location_id) - - active_players = [p for p in players_in_location if p['id'] in self.active_connections and p['id'] != exclude_player_id] - if not active_players: - return - - print(f"๐Ÿ“ Broadcasting to location {location_id}: {message.get('type')} (excluding player {exclude_player_id})") - - disconnected = [] - sent_count = 0 - for player in active_players: - player_id = player['id'] - try: - await self.active_connections[player_id].send_json(message) - sent_count += 1 - except Exception as e: - print(f"โŒ Failed to send to player {player_id}: {e}") - disconnected.append(player_id) - - print(f" ๐Ÿ“ค Sent {message.get('type')} to {sent_count} players") - - for player_id in disconnected: - await self.disconnect(player_id) - - async def handle_redis_message(self, channel: str, data: dict): - """Handle incoming Redis pub/sub messages and route to local WebSocket connections. - - This method is called by RedisManager when a message arrives on a subscribed channel. - Only sends to WebSocket connections that are local to this worker. - """ - try: - # Extract message type and data - message = { - "type": data.get("type"), - "data": data.get("data") - } - - # Determine routing based on channel type - if channel.startswith("player:"): - # Personal message to specific player - player_id = int(channel.split(":")[1]) - if player_id in self.active_connections: - await self._send_direct(player_id, message) - - elif channel.startswith("location:"): - # Broadcast to all players in location (only local connections) - location_id = channel.split(":")[1] - exclude_player_id = data.get("exclude_player_id") - - # Get players from Redis location registry - if self.redis_manager: - player_ids = await self.redis_manager.get_players_in_location(location_id) - - for player_id in player_ids: - if player_id == exclude_player_id: - continue - - # Only send if this worker has the connection - if player_id in self.active_connections: - await self._send_direct(player_id, message) - - elif channel == "game:broadcast": - # Global broadcast to all local connections - exclude_player_id = data.get("exclude_player_id") - - for player_id in list(self.active_connections.keys()): - if player_id != exclude_player_id: - await self._send_direct(player_id, message) - - except Exception as e: - print(f"โŒ Error handling Redis message on channel {channel}: {e}") - - def has_players_in_location(self, location_id: str) -> bool: - """Check if there are any players with active connections in a specific location (synchronous check).""" - return len(self.active_connections) > 0 - - def get_connected_count(self) -> int: - """Get the number of active WebSocket connections.""" - return len(self.active_connections) - -# Global connection manager instance -manager = ConnectionManager() - - -# ============================================================================ -# Pydantic Models -# ============================================================================ - -class UserRegister(BaseModel): - email: str - password: str - - -class UserLogin(BaseModel): - email: str - password: str - - -class CharacterCreate(BaseModel): - name: str - strength: int = 0 - agility: int = 0 - endurance: int = 0 - intellect: int = 0 - avatar_data: Optional[str] = None - - -class CharacterSelect(BaseModel): - character_id: int - - -class MoveRequest(BaseModel): - direction: str - - -class InteractRequest(BaseModel): - interactable_id: str - action_id: str - - -class UseItemRequest(BaseModel): - item_id: str - - -class PickupItemRequest(BaseModel): - item_id: int # This is the dropped_item database ID, not the item type string - quantity: int = 1 # How many to pick up (default: 1) - - -class InitiateCombatRequest(BaseModel): - enemy_id: int # wandering_enemies.id from database - - -class CombatActionRequest(BaseModel): - action: str # 'attack', 'defend', 'flee' - - -# ============================================================================ -# JWT Helper Functions -# ============================================================================ - -def create_access_token(data: dict) -> str: - """Create a JWT access token""" - to_encode = data.copy() - expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) - return encoded_jwt - - -async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: - """Verify JWT token and return current character (requires character selection)""" - try: - token = credentials.credentials - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - - # New system: account_id + character_id - character_id = payload.get("character_id") - account_id = payload.get("account_id") - - # Check if this is a new token format - if account_id is not None: - if character_id is None: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="No character selected. Please select a character first." - ) - - character = await db.get_character_by_id(character_id) - if character is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Character not found" - ) - - # Verify character belongs to account - if character["account_id"] != account_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Character does not belong to this account" - ) - - return character - - # Old system fallback: player_id (for backward compatibility during migration) - player_id = payload.get("player_id") - if player_id is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials" - ) - - player = await db.get_player_by_id(player_id) - if player is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Player not found" - ) - - return player - - except jwt.ExpiredSignatureError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token has expired" - ) - except (jwt.InvalidTokenError, jwt.DecodeError, Exception): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials" - ) - - -def decode_token(token: str) -> dict: - """Decode JWT token and return payload""" - try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - return payload - except jwt.ExpiredSignatureError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token has expired" - ) - except (jwt.InvalidTokenError, jwt.DecodeError, Exception): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials" - ) - - -# ============================================================================ -# Authentication Endpoints -# ============================================================================ - -@app.post("/api/auth/register") -async def register(user: UserRegister): - """Register a new account""" - # Check if email already exists - existing = await db.get_account_by_email(user.email) - if existing: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Email already registered" - ) - - # Hash password - password_hash = bcrypt.hashpw(user.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - - # Create account - account = await db.create_account( - email=user.email, - password_hash=password_hash, - account_type="web" - ) - - if not account: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create account" - ) - - # Get characters for this account (should be empty for new account) - characters = await db.get_characters_by_account_id(account["id"]) - - # Create access token with account_id (no character selected yet) - access_token = create_access_token({ - "account_id": account["id"], - "character_id": None - }) - - return { - "access_token": access_token, - "token_type": "bearer", - "account": { - "id": account["id"], - "email": account["email"], - "account_type": account["account_type"], - "is_premium": account.get("premium_expires_at") is not None, - }, - "characters": characters, - "needs_character_creation": len(characters) == 0 - } - - -@app.post("/api/auth/login") -async def login(user: UserLogin): - """Login with email and password""" - # Get account by email - account = await db.get_account_by_email(user.email) - if not account: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid email or password" - ) - - # Verify password - if not account.get('password_hash'): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid email or password" - ) - - if not bcrypt.checkpw(user.password.encode('utf-8'), account['password_hash'].encode('utf-8')): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid email or password" - ) - - # Update last login - await db.update_account_last_login(account["id"]) - - # Get characters for this account - characters = await db.get_characters_by_account_id(account["id"]) - - # Create access token with account_id (no character selected yet) - access_token = create_access_token({ - "account_id": account["id"], - "character_id": None - }) - - return { - "access_token": access_token, - "token_type": "bearer", - "account": { - "id": account["id"], - "email": account["email"], - "account_type": account["account_type"], - "is_premium": account.get("premium_expires_at") is not None, - }, - "characters": [ - { - "id": char["id"], - "name": char["name"], - "level": char["level"], - "xp": char["xp"], - "hp": char["hp"], - "max_hp": char["max_hp"], - "stamina": char["stamina"], - "max_stamina": char["max_stamina"], - "strength": char["strength"], - "agility": char["agility"], - "endurance": char["endurance"], - "intellect": char["intellect"], - "avatar_data": char.get("avatar_data"), - "last_played_at": char.get("last_played_at"), - "location_id": char["location_id"], - } - for char in characters - ], - "needs_character_creation": len(characters) == 0 - } - - -@app.get("/api/auth/me") -async def get_me(current_user: dict = Depends(get_current_user)): - """Get current user profile""" - return { - "id": current_user["id"], - "username": current_user.get("username"), - "name": current_user["name"], - "level": current_user["level"], - "xp": current_user["xp"], - "hp": current_user["hp"], - "max_hp": current_user["max_hp"], - "stamina": current_user["stamina"], - "max_stamina": current_user["max_stamina"], - "strength": current_user["strength"], - "agility": current_user["agility"], - "endurance": current_user["endurance"], - "intellect": current_user["intellect"], - "location_id": current_user["location_id"], - "is_dead": current_user["is_dead"], - "unspent_points": current_user["unspent_points"] - } - - -# ============================================================================ -# Character Management Endpoints -# ============================================================================ - -@app.get("/api/characters") -async def list_characters(credentials: HTTPAuthorizationCredentials = Depends(security)): - """List all characters for the logged-in account""" - token = credentials.credentials - payload = decode_token(token) - account_id = payload.get("account_id") - - if not account_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token" - ) - - characters = await db.get_characters_by_account_id(account_id) - - return { - "characters": [ - { - "id": char["id"], - "name": char["name"], - "level": char["level"], - "xp": char["xp"], - "hp": char["hp"], - "max_hp": char["max_hp"], - "stamina": char["stamina"], - "max_stamina": char["max_stamina"], - "avatar_data": char.get("avatar_data"), - "location_id": char["location_id"], - "created_at": char["created_at"], - "last_played_at": char.get("last_played_at"), - } - for char in characters - ] - } - - -@app.post("/api/characters") -async def create_character_endpoint( - character: CharacterCreate, - credentials: HTTPAuthorizationCredentials = Depends(security) -): - """Create a new character""" - token = credentials.credentials - payload = decode_token(token) - account_id = payload.get("account_id") - - if not account_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token" - ) - - # Check if account can create more characters - can_create, error_msg = await db.can_create_character(account_id) - if not can_create: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=error_msg - ) - - # Validate character name - if len(character.name) < 3 or len(character.name) > 20: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Character name must be between 3 and 20 characters" - ) - - # Check if name is unique - existing = await db.get_character_by_name(character.name) - if existing: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Character name already taken" - ) - - # Validate stat allocation (must total 20 points) - total_stats = character.strength + character.agility + character.endurance + character.intellect - if total_stats != 20: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Must allocate exactly 20 stat points (you allocated {total_stats})" - ) - - # Validate each stat is >= 0 - if any(stat < 0 for stat in [character.strength, character.agility, character.endurance, character.intellect]): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Stats cannot be negative" - ) - - # Create character - new_character = await db.create_character( - account_id=account_id, - name=character.name, - strength=character.strength, - agility=character.agility, - endurance=character.endurance, - intellect=character.intellect, - avatar_data=character.avatar_data - ) - - if not new_character: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create character" - ) - - return { - "message": "Character created successfully", - "character": { - "id": new_character["id"], - "name": new_character["name"], - "level": new_character["level"], - "strength": new_character["strength"], - "agility": new_character["agility"], - "endurance": new_character["endurance"], - "intellect": new_character["intellect"], - "hp": new_character["hp"], - "max_hp": new_character["max_hp"], - "stamina": new_character["stamina"], - "max_stamina": new_character["max_stamina"], - "location_id": new_character["location_id"], - "avatar_data": new_character.get("avatar_data"), - } - } - - -@app.post("/api/characters/select") -async def select_character( - selection: CharacterSelect, - credentials: HTTPAuthorizationCredentials = Depends(security) -): - """Select a character to play""" - token = credentials.credentials - payload = decode_token(token) - account_id = payload.get("account_id") - - if not account_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token" - ) - - # Verify character belongs to account - character = await db.get_character_by_id(selection.character_id) - if not character: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Character not found" - ) - - if character["account_id"] != account_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Character does not belong to this account" - ) - - # Update last played timestamp - await db.update_character_last_played(selection.character_id) - - # Create new token with character_id - access_token = create_access_token({ - "account_id": account_id, - "character_id": selection.character_id - }) - - return { - "access_token": access_token, - "token_type": "bearer", - "character": { - "id": character["id"], - "name": character["name"], - "level": character["level"], - "xp": character["xp"], - "hp": character["hp"], - "max_hp": character["max_hp"], - "stamina": character["stamina"], - "max_stamina": character["max_stamina"], - "strength": character["strength"], - "agility": character["agility"], - "endurance": character["endurance"], - "intellect": character["intellect"], - "location_id": character["location_id"], - "avatar_data": character.get("avatar_data"), - } - } - - -@app.delete("/api/characters/{character_id}") -async def delete_character_endpoint( - character_id: int, - credentials: HTTPAuthorizationCredentials = Depends(security) -): - """Delete a character""" - token = credentials.credentials - payload = decode_token(token) - account_id = payload.get("account_id") - - if not account_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token" - ) - - # Verify character belongs to account - character = await db.get_character_by_id(character_id) - if not character: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Character not found" - ) - - if character["account_id"] != account_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Character does not belong to this account" - ) - - # Delete character - await db.delete_character(character_id) - - return { - "message": f"Character '{character['name']}' deleted successfully" - } - - -# ============================================================================ -# Game Endpoints -# ============================================================================ - -@app.get("/api/game/state") -async def get_game_state(current_user: dict = Depends(get_current_user)): - """Get complete game state for the player""" - player_id = current_user['id'] - - # Get player data - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - # Get location - location = LOCATIONS.get(player['location_id']) - - # Get inventory and enrich with item data (exclude equipped items) - inventory_raw = await db.get_inventory(player_id) - inventory = [] - total_weight = 0.0 - total_volume = 0.0 - - for inv_item in inventory_raw: - item = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item: - item_weight = item.weight * inv_item['quantity'] - # Equipped items count for weight but not volume - if not inv_item['is_equipped']: - item_volume = item.volume * inv_item['quantity'] - total_volume += item_volume - total_weight += item_weight - - # Only add non-equipped items to inventory list - if not inv_item['is_equipped']: - # Get unique item data if this is a unique item - durability = None - max_durability = None - tier = None - if inv_item.get('unique_item_id'): - unique_item = await db.get_unique_item(inv_item['unique_item_id']) - if unique_item: - durability = unique_item.get('durability') - max_durability = unique_item.get('max_durability') - tier = unique_item.get('tier') - - inventory.append({ - "id": inv_item['id'], - "item_id": item.id, - "name": item.name, - "description": item.description, - "type": item.type, - "category": getattr(item, 'category', item.type), - "quantity": inv_item['quantity'], - "is_equipped": inv_item['is_equipped'], - "equippable": item.equippable, - "consumable": item.consumable, - "weight": item.weight, - "volume": item.volume, - "image_path": item.image_path, - "emoji": item.emoji, - "slot": item.slot, - "durability": durability if durability is not None else None, - "max_durability": max_durability if max_durability is not None else None, - "tier": tier if tier is not None else None, - "hp_restore": item.effects.get('hp_restore') if item.effects else None, - "stamina_restore": item.effects.get('stamina_restore') if item.effects else None, - "damage_min": item.stats.get('damage_min') if item.stats else None, - "damage_max": item.stats.get('damage_max') if item.stats else None - }) - - # Get equipped items - equipment_slots = await db.get_all_equipment(player_id) - equipment = {} - for slot, item_data in equipment_slots.items(): - if item_data and item_data['item_id']: - inv_item = await db.get_inventory_item_by_id(item_data['item_id']) - if inv_item: - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item_def: - # Get unique item data if this is a unique item - durability = None - max_durability = None - tier = None - if inv_item.get('unique_item_id'): - unique_item = await db.get_unique_item(inv_item['unique_item_id']) - if unique_item: - durability = unique_item.get('durability') - max_durability = unique_item.get('max_durability') - tier = unique_item.get('tier') - - equipment[slot] = { - "inventory_id": item_data['item_id'], - "item_id": item_def.id, - "name": item_def.name, - "description": item_def.description, - "emoji": item_def.emoji, - "image_path": item_def.image_path, - "durability": durability if durability is not None else None, - "max_durability": max_durability if max_durability is not None else None, - "tier": tier if tier is not None else None, - "stats": item_def.stats, - "encumbrance": item_def.encumbrance, - "weapon_effects": item_def.weapon_effects if hasattr(item_def, 'weapon_effects') else {} - } - if slot not in equipment: - equipment[slot] = None - - # Get combat state - combat = await db.get_active_combat(player_id) - - # Get dropped items at location and enrich with item data - dropped_items_raw = await db.get_dropped_items(player['location_id']) - dropped_items = [] - for dropped_item in dropped_items_raw: - item = ITEMS_MANAGER.get_item(dropped_item['item_id']) - if item: - # Get unique item data if this is a unique item - durability = None - max_durability = None - tier = None - if dropped_item.get('unique_item_id'): - unique_item = await db.get_unique_item(dropped_item['unique_item_id']) - if unique_item: - durability = unique_item.get('durability') - max_durability = unique_item.get('max_durability') - tier = unique_item.get('tier') - - dropped_items.append({ - "id": dropped_item['id'], - "item_id": item.id, - "name": item.name, - "description": item.description, - "type": item.type, - "quantity": dropped_item['quantity'], - "image_path": item.image_path, - "emoji": item.emoji, - "weight": item.weight, - "volume": item.volume, - "durability": durability if durability is not None else None, - "max_durability": max_durability if max_durability is not None else None, - "tier": tier if tier is not None else None, - "hp_restore": item.effects.get('hp_restore') if item.effects else None, - "stamina_restore": item.effects.get('stamina_restore') if item.effects else None, - "damage_min": item.stats.get('damage_min') if item.stats else None, - "damage_max": item.stats.get('damage_max') if item.stats else None - }) - - # Calculate max weight and volume based on equipment - # Base capacity - max_weight = 10.0 # Base carrying capacity - max_volume = 10.0 # Base volume capacity - - # Check for equipped backpack that increases capacity - if equipment.get('backpack'): - backpack_stats = equipment['backpack'].get('stats', {}) - max_weight += backpack_stats.get('weight_capacity', 0) - max_volume += backpack_stats.get('volume_capacity', 0) - - # Convert location to dict - location_dict = None - if location: - location_dict = { - "id": location.id, - "name": location.name, - "description": location.description, - "exits": location.exits, - "image_path": location.image_path, - "x": getattr(location, 'x', 0.0), - "y": getattr(location, 'y', 0.0), - "tags": getattr(location, 'tags', []) - } - - # Add weight/volume to player data - player_with_capacity = dict(player) - player_with_capacity['current_weight'] = round(total_weight, 2) - player_with_capacity['max_weight'] = round(max_weight, 2) - player_with_capacity['current_volume'] = round(total_volume, 2) - player_with_capacity['max_volume'] = round(max_volume, 2) - - # Calculate movement cooldown - import time - current_time = time.time() - last_movement = player.get('last_movement_time', 0) - time_since_movement = current_time - last_movement - movement_cooldown = max(0, min(5, 5 - time_since_movement)) - player_with_capacity['movement_cooldown'] = int(movement_cooldown) - - return { - "player": player_with_capacity, - "location": location_dict, - "inventory": inventory, - "equipment": equipment, - "combat": combat, - "dropped_items": dropped_items - } - - -@app.get("/api/game/profile") -async def get_player_profile(current_user: dict = Depends(get_current_user)): - """Get player profile information""" - player_id = current_user['id'] - - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - # Get inventory and enrich with item data - inventory_raw = await db.get_inventory(player_id) - inventory = [] - total_weight = 0.0 - total_volume = 0.0 - max_weight = 10.0 - max_volume = 10.0 - - for inv_item in inventory_raw: - item = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item: - item_weight = item.weight * inv_item['quantity'] - item_volume = item.volume * inv_item['quantity'] - total_weight += item_weight - total_volume += item_volume - - # Check for equipped bags/containers - if inv_item['is_equipped'] and item.stats: - max_weight += item.stats.get('weight_capacity', 0) - max_volume += item.stats.get('volume_capacity', 0) - - # Enrich inventory item with all necessary data - inventory.append({ - "id": inv_item['id'], - "item_id": item.id, - "name": item.name, - "description": item.description, - "type": item.type, - "category": getattr(item, 'category', item.type), - "quantity": inv_item['quantity'], - "is_equipped": inv_item['is_equipped'], - "equippable": item.equippable, - "consumable": item.consumable, - "weight": item.weight, - "volume": item.volume, - "image_path": item.image_path, - "emoji": item.emoji, - "hp_restore": item.effects.get('hp_restore') if item.effects else None, - "stamina_restore": item.effects.get('stamina_restore') if item.effects else None, - "damage_min": item.stats.get('damage_min') if item.stats else None, - "damage_max": item.stats.get('damage_max') if item.stats else None - }) - - # Add weight/volume to player data - player_with_capacity = dict(player) - player_with_capacity['current_weight'] = round(total_weight, 2) - player_with_capacity['max_weight'] = round(max_weight, 2) - player_with_capacity['current_volume'] = round(total_volume, 2) - player_with_capacity['max_volume'] = round(max_volume, 2) - - # Calculate movement cooldown - import time - current_time = time.time() - last_movement = player.get('last_movement_time', 0) - time_since_movement = current_time - last_movement - movement_cooldown = max(0, min(5, 5 - time_since_movement)) - player_with_capacity['movement_cooldown'] = round(movement_cooldown, 1) - - return { - "player": player_with_capacity, - "inventory": inventory - } - - -@app.post("/api/game/spend_point") -async def spend_stat_point( - stat: str, - current_user: dict = Depends(get_current_user) -): - """Spend a stat point on a specific attribute""" - player = current_user # current_user is already the character dict - - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - if player['unspent_points'] < 1: - raise HTTPException(status_code=400, detail="No unspent points available") - - # Valid stats - valid_stats = ['strength', 'agility', 'endurance', 'intellect'] - if stat not in valid_stats: - raise HTTPException(status_code=400, detail=f"Invalid stat. Must be one of: {', '.join(valid_stats)}") - - # Update the stat and decrease unspent points - update_data = { - stat: player[stat] + 1, - 'unspent_points': player['unspent_points'] - 1 - } - - # Endurance increases max HP - if stat == 'endurance': - update_data['max_hp'] = player['max_hp'] + 5 - update_data['hp'] = min(player['hp'] + 5, update_data['max_hp']) # Also heal by 5 - - await db.update_character(current_user['id'], **update_data) - - return { - "success": True, - "message": f"Increased {stat} by 1!", - "new_value": player[stat] + 1, - "remaining_points": player['unspent_points'] - 1 - } - - -@app.get("/api/game/location") -async def get_current_location(current_user: dict = Depends(get_current_user)): - """Get current location information""" - location_id = current_user['location_id'] - location = LOCATIONS.get(location_id) - - if not location: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Location {location_id} not found" - ) - - # Get dropped items at location - dropped_items = await db.get_dropped_items(location_id) - - # Get wandering enemies at location - wandering_enemies = await db.get_wandering_enemies_in_location(location_id) - - # Format interactables for response with cooldown info - interactables_data = [] - for interactable in location.interactables: - actions_data = [] - for action in interactable.actions: - # Check cooldown status for this specific action - cooldown_expiry = await db.get_interactable_cooldown(interactable.id, action.id) - import time - is_on_cooldown = False - remaining_cooldown = 0 - - if cooldown_expiry: - current_time = time.time() - if cooldown_expiry > current_time: - is_on_cooldown = True - remaining_cooldown = int(cooldown_expiry - current_time) - - actions_data.append({ - "id": action.id, - "name": action.label, - "stamina_cost": action.stamina_cost, - "description": f"Costs {action.stamina_cost} stamina", - "on_cooldown": is_on_cooldown, - "cooldown_remaining": remaining_cooldown - }) - - interactables_data.append({ - "instance_id": interactable.id, - "name": interactable.name, - "image_path": interactable.image_path, - "actions": actions_data - }) - - # Fix image URL - image_path already contains the full path from images/ - image_url = f"/{location.image_path}" if location.image_path else "/images/locations/default.png" - - # Calculate player's current weight for stamina cost adjustment - player = current_user # current_user is already the character dict - - if not player: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="No character selected. Please select a character first." - ) - - inventory_raw = await db.get_inventory(current_user['id']) - total_weight = 0.0 - total_volume = 0.0 - max_weight = 10.0 # Base capacity - max_volume = 10.0 # Base capacity - - for inv_item in inventory_raw: - item = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item: - total_weight += item.weight * inv_item['quantity'] - total_volume += item.volume * inv_item['quantity'] - - # Add capacity from equipped items (backpacks) - if inv_item.get('is_equipped', False) and item.stats: - max_weight += item.stats.get('weight_capacity', 0) - max_volume += item.stats.get('volume_capacity', 0) - - # Format directions with stamina costs (calculated from distance, weight, agility) - directions_with_stamina = [] - player_agility = player.get('agility', 5) - - for direction in location.exits.keys(): - destination_id = location.exits[direction] - destination_loc = LOCATIONS.get(destination_id) - - if destination_loc: - # Calculate real distance using coordinates - distance = calculate_distance( - location.x, location.y, - destination_loc.x, destination_loc.y - ) - # Calculate stamina cost based on distance, weight, volume, capacity, and agility - stamina_cost = calculate_stamina_cost(distance, total_weight, player_agility, max_weight, total_volume, max_volume) - destination_name = destination_loc.name - else: - # Fallback if destination not found - distance = 500 # Default 500m - stamina_cost = calculate_stamina_cost(distance, total_weight, player_agility) - destination_name = destination_id - - directions_with_stamina.append({ - "direction": direction, - "stamina_cost": stamina_cost, - "distance": int(distance), # Round to integer meters - "destination": destination_id, - "destination_name": destination_name - }) - - # Format NPCs (wandering enemies + static NPCs from JSON) - npcs_data = [] - - # Add wandering enemies from database - for enemy in wandering_enemies: - npcs_data.append({ - "id": enemy['id'], - "name": enemy['npc_id'].replace('_', ' ').title(), - "type": "enemy", - "level": enemy.get('level', 1), - "is_wandering": True - }) - - # Add static NPCs from location JSON (if any) - for npc in location.npcs: - if isinstance(npc, dict): - npcs_data.append({ - "id": npc.get('id', npc.get('name', 'unknown')), - "name": npc.get('name', 'Unknown NPC'), - "type": npc.get('type', 'npc'), - "level": npc.get('level'), - "is_wandering": False - }) - else: - npcs_data.append({ - "id": npc, - "name": npc, - "type": "npc", - "is_wandering": False - }) - - # Enrich dropped items with metadata - DON'T consolidate unique items! - items_dict = {} - for item in dropped_items: - item_def = ITEMS_MANAGER.get_item(item['item_id']) - if item_def: - # Get unique item data if this is a unique item - durability = None - max_durability = None - tier = None - if item.get('unique_item_id'): - unique_item = await db.get_unique_item(item['unique_item_id']) - if unique_item: - durability = unique_item.get('durability') - max_durability = unique_item.get('max_durability') - tier = unique_item.get('tier') - - # Create a unique key for unique items to prevent stacking - if item.get('unique_item_id'): - dict_key = f"{item['item_id']}_{item['unique_item_id']}" - else: - dict_key = item['item_id'] - - if dict_key not in items_dict: - items_dict[dict_key] = { - "id": item['id'], # Use first ID for pickup - "item_id": item['item_id'], - "name": item_def.name, - "description": item_def.description, - "quantity": item['quantity'], - "emoji": item_def.emoji, - "image_path": item_def.image_path, - "weight": item_def.weight, - "volume": item_def.volume, - "durability": durability, - "max_durability": max_durability, - "tier": tier, - "hp_restore": item_def.effects.get('hp_restore') if item_def.effects else None, - "stamina_restore": item_def.effects.get('stamina_restore') if item_def.effects else None, - "damage_min": item_def.stats.get('damage_min') if item_def.stats else None, - "damage_max": item_def.stats.get('damage_max') if item_def.stats else None - } - else: - # Only stack if it's not a unique item (stackable items only) - if not item.get('unique_item_id'): - items_dict[dict_key]['quantity'] += item['quantity'] - - items_data = list(items_dict.values()) - - # Get other players in the same location (characters from all accounts) - other_players = [] - try: - # Use Redis for player registry if available (includes disconnected players) - if redis_manager: - player_ids = await redis_manager.get_players_in_location(location_id) - - for pid in player_ids: - if pid == current_user['id']: - continue - - # Get player session from Redis - session = await redis_manager.get_player_session(pid) - if session: - # Check if player is connected - is_connected = session.get('websocket_connected') == 'true' - - # Check disconnect duration - disconnect_duration = None - if not is_connected: - disconnect_duration = await redis_manager.get_disconnect_duration(pid) - - # Get player data from DB for combat checks - char = await db.get_player_by_id(pid) - if not char: - continue - - # Don't show dead players - if char.get('is_dead', False): - continue - - # Check if character is in any combat (PvE or PvP) - in_pve_combat = await db.get_active_combat(pid) - in_pvp_combat = await db.get_pvp_combat_by_player(pid) - - # Don't show characters who are in combat - if in_pve_combat or in_pvp_combat: - continue - - # Check if PvP is possible with this character - level_diff = abs(player['level'] - int(session.get('level', 0))) - can_pvp = location.danger_level >= 3 and level_diff <= 3 - - other_players.append({ - "id": pid, - "name": session.get('username'), - "level": int(session.get('level', 0)), - "username": session.get('username'), - "can_pvp": can_pvp, - "level_diff": level_diff, - "is_connected": is_connected, - "vulnerable": not is_connected and location.danger_level >= 3 # Disconnected in dangerous zone - }) - else: - # Fallback: Query database directly (single worker mode) - async with db.engine.begin() as conn: - stmt = db.select(db.characters).where( - db.and_( - db.characters.c.location_id == location_id, - db.characters.c.id != current_user['id'], - db.characters.c.is_dead == False # Don't show dead players - ) - ) - result = await conn.execute(stmt) - characters_rows = result.fetchall() - - for char_row in characters_rows: - # Check if character is in any combat (PvE or PvP) - in_pve_combat = await db.get_active_combat(char_row.id) - in_pvp_combat = await db.get_pvp_combat_by_player(char_row.id) - - if in_pve_combat or in_pvp_combat: - continue - - # Check if PvP is possible with this character - level_diff = abs(player['level'] - char_row.level) - can_pvp = location.danger_level >= 3 and level_diff <= 3 - - other_players.append({ - "id": char_row.id, - "name": char_row.name, - "level": char_row.level, - "username": char_row.name, - "can_pvp": can_pvp, - "level_diff": level_diff, - "is_connected": True, # Assume connected in fallback mode - "vulnerable": False - }) - except Exception as e: - print(f"Error fetching other characters: {e}") - - # Get corpses at location - npc_corpses = await db.get_npc_corpses_in_location(location_id) - player_corpses = await db.get_player_corpses_in_location(location_id) - - # Format corpses for response - corpses_data = [] - import json - import sys - sys.path.insert(0, '/app') - from data.npcs import NPCS - - for corpse in npc_corpses: - loot = json.loads(corpse['loot_remaining']) if corpse['loot_remaining'] else [] - npc_def = NPCS.get(corpse['npc_id']) - corpses_data.append({ - "id": f"npc_{corpse['id']}", - "type": "npc", - "name": f"{npc_def.name if npc_def else corpse['npc_id']} Corpse", - "emoji": "๐Ÿ’€", - "loot_count": len(loot), - "timestamp": corpse['death_timestamp'] - }) - - for corpse in player_corpses: - items = json.loads(corpse['items']) if corpse['items'] else [] - corpses_data.append({ - "id": f"player_{corpse['id']}", - "type": "player", - "name": f"{corpse['player_name']}'s Corpse", - "emoji": "โšฐ๏ธ", - "loot_count": len(items), - "timestamp": corpse['death_timestamp'] - }) - - return { - "id": location.id, - "name": location.name, - "description": location.description, - "image_url": image_url, - "directions": list(location.exits.keys()), # Keep for backwards compatibility - "directions_detailed": directions_with_stamina, # New detailed format - "danger_level": location.danger_level, - "tags": location.tags if hasattr(location, 'tags') else [], # Include location tags - "npcs": npcs_data, - "items": items_data, - "interactables": interactables_data, - "other_players": other_players, - "corpses": corpses_data - } - - -@app.post("/api/game/move") -async def move( - move_req: MoveRequest, - current_user: dict = Depends(get_current_user) -): - """Move player in a direction""" - import time - - # Check if player is in PvP combat and hasn't acknowledged - pvp_combat = await db.get_pvp_combat_by_player(current_user['id']) - if pvp_combat: - is_attacker = pvp_combat['attacker_character_id'] == current_user['id'] - acknowledged = pvp_combat.get('attacker_acknowledged', False) if is_attacker else pvp_combat.get('defender_acknowledged', False) - - # Check if combat ended - need to get actual player HP - attacker = await db.get_player_by_id(pvp_combat['attacker_character_id']) - defender = await db.get_player_by_id(pvp_combat['defender_character_id']) - - # Only block if combat is still active (not fled, not defeated) and player hasn't acknowledged - combat_ended = pvp_combat.get('attacker_fled', False) or pvp_combat.get('defender_fled', False) or \ - attacker['hp'] <= 0 or defender['hp'] <= 0 - - if not acknowledged and not combat_ended: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot move while in PvP combat!" - ) - - # Check movement cooldown (5 seconds) - player = current_user # current_user is already the character dict - current_time = time.time() - last_movement = player.get('last_movement_time', 0) - cooldown_remaining = max(0, 5 - (current_time - last_movement)) - - if cooldown_remaining > 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"You must wait {int(cooldown_remaining)} seconds before moving again." - ) - - success, message, new_location_id, stamina_cost, distance = await game_logic.move_player( - current_user['id'], - move_req.direction, - LOCATIONS - ) - - if not success: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=message - ) - - # Update last movement time - await db.update_player(current_user['id'], last_movement_time=current_time) - - # Update Redis cache: Move player between locations - if redis_manager: - await redis_manager.move_player_between_locations( - current_user['id'], - player['location_id'], - new_location_id - ) - - # Update player session with new location - await redis_manager.update_player_session_field(current_user['id'], 'location_id', new_location_id) - await redis_manager.update_player_session_field(current_user['id'], 'stamina', player['stamina'] - stamina_cost) - - # Track movement statistics - use actual distance in meters - await db.update_player_statistics(current_user['id'], distance_walked=distance, increment=True) - - # Check for encounter upon arrival (if danger level > 1) - import random - import sys - sys.path.insert(0, '/app') - from data.npcs import get_random_npc_for_location, LOCATION_DANGER, NPCS - - new_location = LOCATIONS.get(new_location_id) - encounter_triggered = False - enemy_id = None - combat_data = None - - if new_location and new_location.danger_level > 1: - # Get encounter rate from danger config - danger_data = LOCATION_DANGER.get(new_location_id) - if danger_data: - _, encounter_rate, _ = danger_data - # Roll for encounter - if random.random() < encounter_rate: - # Get a random enemy for this location - enemy_id = get_random_npc_for_location(new_location_id) - if enemy_id: - # Check if player is already in combat - existing_combat = await db.get_active_combat(current_user['id']) - if not existing_combat: - # Get NPC definition - npc_def = NPCS.get(enemy_id) - if npc_def: - # Randomize HP - npc_hp = random.randint(npc_def.hp_min, npc_def.hp_max) - - # Create combat directly - combat = await db.create_combat( - player_id=current_user['id'], - npc_id=enemy_id, - npc_hp=npc_hp, - npc_max_hp=npc_hp, - location_id=new_location_id, - from_wandering=False # This is an encounter, not wandering - ) - - # Track combat initiation - await db.update_player_statistics(current_user['id'], combats_initiated=1, increment=True) - - encounter_triggered = True - combat_data = { - "npc_id": enemy_id, - "npc_name": npc_def.name, - "npc_hp": npc_hp, - "npc_max_hp": npc_hp, - "npc_image": f"/images/npcs/{enemy_id}.png", - "turn": "player", - "round": 1 - } - - response = { - "success": True, - "message": message, - "new_location_id": new_location_id - } - - # Add encounter info if triggered - if encounter_triggered: - response["encounter"] = { - "triggered": True, - "enemy_id": enemy_id, - "message": f"โš ๏ธ An enemy ambushes you upon arrival!", - "combat": combat_data - } - - # Broadcast movement to WebSocket clients - # Notify old location that player left - await manager.send_to_location( - player['location_id'], - { - "type": "location_update", - "data": { - "message": f"{player['name']} left the area", - "action": "player_left", - "player_id": current_user['id'], - "player_name": player['name'] - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=current_user['id'] - ) - - # Notify new location that player arrived - await manager.send_to_location( - new_location_id, - { - "type": "location_update", - "data": { - "message": f"{player['name']} arrived", - "action": "player_arrived", - "player_id": current_user['id'], - "player_name": player['name'], - "player_level": player['level'], - "can_pvp": new_location.danger_level >= 3 # Full player data for UI update - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=current_user['id'] - ) - - # Send state update to the moving player - await manager.send_personal_message(current_user['id'], { - "type": "state_update", - "data": { - "player": { - "stamina": player['stamina'] - stamina_cost, - "location_id": new_location_id - }, - "location": { - "id": new_location.id, - "name": new_location.name - } if new_location else None, - "encounter": response.get("encounter") - }, - "timestamp": datetime.utcnow().isoformat() - }) - - return response - - -@app.post("/api/game/inspect") -async def inspect(current_user: dict = Depends(get_current_user)): - """Inspect the current area""" - location_id = current_user['location_id'] - location = LOCATIONS.get(location_id) - - if not location: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Location not found" - ) - - # Get dropped items - dropped_items = await db.get_dropped_items(location_id) - - message = await game_logic.inspect_area( - current_user['id'], - location, - {} # interactables_data - not needed with new structure - ) - - return { - "success": True, - "message": message - } - - -@app.post("/api/game/interact") -async def interact( - interact_req: InteractRequest, - current_user: dict = Depends(get_current_user) -): - """Interact with an object""" - # Check if player is in combat - combat = await db.get_active_combat(current_user['id']) - if combat: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot interact with objects while in combat" - ) - - location_id = current_user['location_id'] - location = LOCATIONS.get(location_id) - - if not location: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Location not found" - ) - - result = await game_logic.interact_with_object( - current_user['id'], - interact_req.interactable_id, - interact_req.action_id, - location, - ITEMS_MANAGER - ) - - if not result['success']: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=result['message'] - ) - - # Broadcast interactable cooldown to all players in location - from datetime import datetime - - # Find the interactable name and action name - interactable = None - action_name = None - for obj in location.interactables: - if obj.id == interact_req.interactable_id: - interactable = obj - for act in obj.actions: - if act.id == interact_req.action_id: - action_name = act.label - break - break - - interactable_name = interactable.name if interactable else "Object" - action_display = action_name if action_name else interact_req.action_id - - # Get the actual cooldown expiry from database and calculate remaining time - cooldown_expiry = await db.get_interactable_cooldown( - interact_req.interactable_id, - interact_req.action_id - ) - - # Calculate remaining cooldown in seconds - import time as time_module - current_time = time_module.time() - cooldown_remaining = 0 - if cooldown_expiry and cooldown_expiry > current_time: - cooldown_remaining = int(cooldown_expiry - current_time) - - # Only broadcast if there are players in the location - if manager.has_players_in_location(location_id): - await manager.send_to_location( - location_id=location_id, - message={ - "type": "interactable_cooldown", - "data": { - "instance_id": interact_req.interactable_id, - "action_id": interact_req.action_id, - "cooldown_remaining": cooldown_remaining, - "message": f"{current_user['name']} used {action_display} on {interactable_name}" - }, - "timestamp": datetime.utcnow().isoformat() - } - ) - - return result - - -@app.post("/api/game/use_item") -async def use_item( - use_req: UseItemRequest, - current_user: dict = Depends(get_current_user) -): - """Use an item from inventory""" - import random - import sys - sys.path.insert(0, '/app') - from data.npcs import NPCS - - # Check if in combat - combat = await db.get_active_combat(current_user['id']) - in_combat = combat is not None - - result = await game_logic.use_item( - current_user['id'], - use_req.item_id, - ITEMS_MANAGER - ) - - if not result['success']: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=result['message'] - ) - - # If in combat, enemy gets a turn - if in_combat and combat['turn'] == 'player': - player = current_user # current_user is already the character dict - npc_def = NPCS.get(combat['npc_id']) - - # Enemy attacks - npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max) - if combat['npc_hp'] / combat['npc_max_hp'] < 0.3: - npc_damage = int(npc_damage * 1.5) - - new_player_hp = max(0, player['hp'] - npc_damage) - combat_message = f"\n{npc_def.name} attacks for {npc_damage} damage!" - - if new_player_hp <= 0: - combat_message += "\nYou have been defeated!" - await db.update_player(current_user['id'], hp=0, is_dead=True) - await db.end_combat(current_user['id']) - result['combat_over'] = True - result['player_won'] = False - - # Create corpse with player's inventory - import json - import time as time_module - try: - inventory = await db.get_inventory(current_user['id']) - inventory_items = [] - for inv_item in inventory: - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - inventory_items.append({ - 'item_id': inv_item['item_id'], - 'name': item_def.name if item_def else inv_item['item_id'], - 'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '๐Ÿ“ฆ', - 'quantity': inv_item['quantity'], - 'durability': inv_item.get('durability'), - 'max_durability': inv_item.get('max_durability'), - 'tier': inv_item.get('tier') - }) - - # Store minimal data in database - db_items = json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) - - logger.info(f"Creating player corpse for {player['name']} at {player['location_id']} with {len(inventory_items)} items") - - corpse_id = await db.create_player_corpse( - player_name=player['name'], - location_id=player['location_id'], - items=db_items - ) - - logger.info(f"Successfully created player corpse: ID={corpse_id}, player={player['name']}, location={player['location_id']}, items_count={len(inventory_items)}") - - # Clear player's inventory (items are now in corpse) - await db.clear_inventory(current_user['id']) - - # Build corpse data for broadcast - corpse_data = { - "id": f"player_{corpse_id}", - "type": "player", - "name": f"{player['name']}'s Corpse", - "emoji": "โšฐ๏ธ", - "player_name": player['name'], - "loot_count": len(inventory_items), - "items": inventory_items, # Full item list for UI - "timestamp": time_module.time() - } - - # Broadcast to location that player died and corpse appeared - logger.info(f"Broadcasting player_died to location {player['location_id']} for player {player['name']}") - await manager.send_to_location( - location_id=player['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} was defeated in combat", - "action": "player_died", - "player_id": player['id'], - "corpse": corpse_data # Send full corpse data - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=current_user['id'] - ) - except Exception as e: - logger.error(f"Error creating player corpse for {player['name']}: {e}", exc_info=True) - else: - await db.update_player(current_user['id'], hp=new_player_hp) - - result['message'] += combat_message - result['in_combat'] = True - result['combat_over'] = result.get('combat_over', False) - - return result - - -@app.post("/api/game/pickup") -async def pickup( - pickup_req: PickupItemRequest, - current_user: dict = Depends(get_current_user) -): - """Pick up an item from the ground""" - # Get item details for broadcast BEFORE picking it up (it will be removed from DB) - # pickup_req.item_id is the dropped_item database ID, not the item_id string - dropped_item = await db.get_dropped_item(pickup_req.item_id) - if dropped_item: - item_def = ITEMS_MANAGER.get_item(dropped_item['item_id']) - item_name = item_def.name if item_def else dropped_item['item_id'] - else: - item_name = "item" - - result = await game_logic.pickup_item( - current_user['id'], - pickup_req.item_id, - current_user['location_id'], - pickup_req.quantity, - ITEMS_MANAGER - ) - - if not result['success']: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=result['message'] - ) - - # Track pickup statistics - quantity = pickup_req.quantity if pickup_req.quantity else 1 - await db.update_player_statistics(current_user['id'], items_collected=quantity, increment=True) - - # Broadcast pickup to other players in location - player = current_user # current_user is already the character dict - await manager.send_to_location( - player['location_id'], - { - "type": "location_update", - "data": { - "message": f"{player['name']} picked up {quantity}x {item_name}", - "action": "item_picked_up" - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=current_user['id'] - ) - - # Send state update to the player - await manager.send_personal_message(current_user['id'], { - "type": "inventory_update", - "timestamp": datetime.utcnow().isoformat() - }) - - return result - - -# ============================================================================ -# EQUIPMENT SYSTEM -# ============================================================================ - -class EquipItemRequest(BaseModel): - inventory_id: int # ID of item in inventory to equip - - -class UnequipItemRequest(BaseModel): - slot: str # Equipment slot to unequip from - - -class RepairItemRequest(BaseModel): - inventory_id: int # ID of item in inventory to repair - - -@app.post("/api/game/equip") -async def equip_item( - equip_req: EquipItemRequest, - current_user: dict = Depends(get_current_user) -): - """Equip an item from inventory""" - player_id = current_user['id'] - - # Get the inventory item - inv_item = await db.get_inventory_item_by_id(equip_req.inventory_id) - if not inv_item or inv_item['character_id'] != player_id: - raise HTTPException(status_code=404, detail="Item not found in inventory") - - # Get item definition - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - if not item_def: - raise HTTPException(status_code=404, detail="Item definition not found") - - # Check if item is equippable - if not item_def.equippable or not item_def.slot: - raise HTTPException(status_code=400, detail="This item cannot be equipped") - - # Check if slot is valid - valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack'] - if item_def.slot not in valid_slots: - raise HTTPException(status_code=400, detail=f"Invalid equipment slot: {item_def.slot}") - - # Check if slot is already occupied - current_equipped = await db.get_equipped_item_in_slot(player_id, item_def.slot) - unequipped_item_name = None - - if current_equipped and current_equipped.get('item_id'): - # Get the old item's name for the message - old_inv_item = await db.get_inventory_item_by_id(current_equipped['item_id']) - if old_inv_item: - old_item_def = ITEMS_MANAGER.get_item(old_inv_item['item_id']) - unequipped_item_name = old_item_def.name if old_item_def else "previous item" - - # Unequip current item first - await db.unequip_item(player_id, item_def.slot) - # Mark as not equipped in inventory - await db.update_inventory_item(current_equipped['item_id'], is_equipped=False) - - # Equip the new item - await db.equip_item(player_id, item_def.slot, equip_req.inventory_id) - - # Mark as equipped in inventory - await db.update_inventory_item(equip_req.inventory_id, is_equipped=True) - - # Initialize unique_item if this is first time equipping an equippable with durability - if inv_item.get('unique_item_id') is None and item_def.durability: - # Create a unique_item instance for this equipment - unique_item_id = await db.create_unique_item( - item_id=item_def.id, - durability=item_def.durability, - max_durability=item_def.durability, - tier=item_def.tier if hasattr(item_def, 'tier') else 1, - unique_stats=None - ) - # Link the inventory item to this unique_item - await db.update_inventory_item( - equip_req.inventory_id, - unique_item_id=unique_item_id - ) - - # Build message - if unequipped_item_name: - message = f"Unequipped {unequipped_item_name}, equipped {item_def.name}" - else: - message = f"Equipped {item_def.name}" - - return { - "success": True, - "message": message, - "slot": item_def.slot, - "unequipped_item": unequipped_item_name - } - - -@app.post("/api/game/unequip") -async def unequip_item( - unequip_req: UnequipItemRequest, - current_user: dict = Depends(get_current_user) -): - """Unequip an item from equipment slot""" - player_id = current_user['id'] - - # Check if slot is valid - valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack'] - if unequip_req.slot not in valid_slots: - raise HTTPException(status_code=400, detail=f"Invalid equipment slot: {unequip_req.slot}") - - # Get currently equipped item - equipped = await db.get_equipped_item_in_slot(player_id, unequip_req.slot) - if not equipped: - raise HTTPException(status_code=400, detail=f"No item equipped in {unequip_req.slot} slot") - - # Get inventory item and item definition - inv_item = await db.get_inventory_item_by_id(equipped['item_id']) - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - - # Check if inventory has space (volume-wise) - inventory = await db.get_inventory(player_id) - total_volume = sum( - ITEMS_MANAGER.get_item(i['item_id']).volume * i['quantity'] - for i in inventory - if ITEMS_MANAGER.get_item(i['item_id']) and not i['is_equipped'] - ) - - # Get max volume (base 10 + backpack bonus) - max_volume = 10.0 - for inv in inventory: - if inv['is_equipped']: - item = ITEMS_MANAGER.get_item(inv['item_id']) - if item and item.stats: - max_volume += item.stats.get('volume_capacity', 0) - - # If unequipping backpack, check if items will fit - if unequip_req.slot == 'backpack' and item_def.stats: - backpack_volume = item_def.stats.get('volume_capacity', 0) - if total_volume > (max_volume - backpack_volume): - raise HTTPException( - status_code=400, - detail="Cannot unequip backpack: inventory would exceed volume capacity" - ) - - # Check if adding this item would exceed volume - if total_volume + item_def.volume > max_volume: - # Drop to ground instead - await db.unequip_item(player_id, unequip_req.slot) - await db.update_inventory_item(equipped['item_id'], is_equipped=False) - await db.drop_item(player_id, inv_item['item_id'], 1, current_user['location_id']) - await db.remove_from_inventory(player_id, inv_item['item_id'], 1) - - return { - "success": True, - "message": f"Unequipped {item_def.name} (dropped to ground - inventory full)", - "dropped": True - } - - # Unequip the item - await db.unequip_item(player_id, unequip_req.slot) - await db.update_inventory_item(equipped['item_id'], is_equipped=False) - - return { - "success": True, - "message": f"Unequipped {item_def.name}", - "dropped": False - } - - -@app.get("/api/game/equipment") -async def get_equipment(current_user: dict = Depends(get_current_user)): - """Get all equipped items""" - player_id = current_user['id'] - - equipment = await db.get_all_equipment(player_id) - - # Enrich with item data - enriched = {} - for slot, item_data in equipment.items(): - if item_data: - inv_item = await db.get_inventory_item_by_id(item_data['item_id']) - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item_def: - enriched[slot] = { - "inventory_id": item_data['item_id'], - "item_id": item_def.id, - "name": item_def.name, - "description": item_def.description, - "emoji": item_def.emoji, - "image_path": item_def.image_path, - "durability": inv_item.get('durability'), - "max_durability": inv_item.get('max_durability'), - "tier": inv_item.get('tier', 1), - "stats": item_def.stats, - "encumbrance": item_def.encumbrance - } - else: - enriched[slot] = None - - return {"equipment": enriched} - - -@app.post("/api/game/repair_item") -async def repair_item( - repair_req: RepairItemRequest, - current_user: dict = Depends(get_current_user) -): - """Repair an item using materials at a workbench location""" - player_id = current_user['id'] - - # Get player's location - player = await db.get_player_by_id(player_id) - location = LOCATIONS.get(player['location_id']) - - if not location: - raise HTTPException(status_code=404, detail="Location not found") - - # Check if location has workbench - location_tags = getattr(location, 'tags', []) - if 'workbench' not in location_tags and 'repair_station' not in location_tags: - raise HTTPException( - status_code=400, - detail="You need to be at a location with a workbench to repair items. Try the Gas Station!" - ) - - # Get inventory item - inv_item = await db.get_inventory_item(repair_req.inventory_id) - if not inv_item or inv_item['character_id'] != player_id: - raise HTTPException(status_code=404, detail="Item not found in inventory") - - # Get item definition - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - if not item_def: - raise HTTPException(status_code=404, detail="Item definition not found") - - # Check if item is repairable - if not getattr(item_def, 'repairable', False): - raise HTTPException(status_code=400, detail=f"{item_def.name} cannot be repaired") - - # Check if item has durability (unique item) - if not inv_item.get('unique_item_id'): - raise HTTPException(status_code=400, detail="This item doesn't have durability tracking") - - # Get unique item data - unique_item = await db.get_unique_item(inv_item['unique_item_id']) - if not unique_item: - raise HTTPException(status_code=500, detail="Unique item data not found") - - current_durability = unique_item.get('durability', 0) - max_durability = unique_item.get('max_durability', 100) - - # Check if item needs repair - if current_durability >= max_durability: - raise HTTPException(status_code=400, detail=f"{item_def.name} is already at full durability") - - # Get repair materials - repair_materials = getattr(item_def, 'repair_materials', []) - if not repair_materials: - raise HTTPException(status_code=500, detail="Item repair configuration missing") - - # Get repair tools - repair_tools = getattr(item_def, 'repair_tools', []) - - # Check if player has all required materials and tools - player_inventory = await db.get_inventory(player_id) - inventory_dict = {item['item_id']: item['quantity'] for item in player_inventory} - - missing_materials = [] - for material in repair_materials: - required_qty = material.get('quantity', 1) - available_qty = inventory_dict.get(material['item_id'], 0) - if available_qty < required_qty: - material_def = ITEMS_MANAGER.get_item(material['item_id']) - material_name = material_def.name if material_def else material['item_id'] - missing_materials.append(f"{material_name} ({available_qty}/{required_qty})") - - if missing_materials: - raise HTTPException( - status_code=400, - detail=f"Missing materials: {', '.join(missing_materials)}" - ) - - # Check and consume tools if required - tools_consumed = [] - if repair_tools: - success, error_msg, tools_consumed = await consume_tool_durability(player_id, repair_tools, player_inventory) - if not success: - raise HTTPException(status_code=400, detail=error_msg) - - # Consume materials - for material in repair_materials: - await db.remove_item_from_inventory(player_id, material['item_id'], material['quantity']) - - # Calculate repair amount - repair_percentage = getattr(item_def, 'repair_percentage', 25) - repair_amount = int((max_durability * repair_percentage) / 100) - new_durability = min(current_durability + repair_amount, max_durability) - - # Update unique item durability - await db.update_unique_item(inv_item['unique_item_id'], durability=new_durability) - - # Build materials consumed message - materials_used = [] - for material in repair_materials: - material_def = ITEMS_MANAGER.get_item(material['item_id']) - emoji = material_def.emoji if material_def and hasattr(material_def, 'emoji') else '๐Ÿ“ฆ' - name = material_def.name if material_def else material['item_id'] - materials_used.append(f"{emoji} {name} x{material['quantity']}") - - return { - "success": True, - "message": f"Repaired {item_def.name}! Restored {repair_amount} durability.", - "item_name": item_def.name, - "old_durability": current_durability, - "new_durability": new_durability, - "max_durability": max_durability, - "materials_consumed": materials_used, - "tools_consumed": tools_consumed, - "repair_amount": repair_amount - } - - - - -async def reduce_armor_durability(player_id: int, damage_taken: int) -> tuple: - """ - Reduce durability of equipped armor pieces when taking damage. - Formula: durability_loss = max(1, (damage_taken / armor_value) * base_reduction_rate) - Base reduction rate: 0.5 (so 10 damage with 5 armor = 1 durability loss) - Returns: (armor_damage_absorbed, broken_armor_pieces) - """ - equipment = await db.get_all_equipment(player_id) - armor_pieces = ['head', 'chest', 'legs', 'feet'] - - total_armor = 0 - equipped_armor = [] - - # Collect all equipped armor - for slot in armor_pieces: - if equipment.get(slot) and equipment[slot]: - armor_slot = equipment[slot] - inv_item = await db.get_inventory_item_by_id(armor_slot['item_id']) - if inv_item and inv_item.get('unique_item_id'): - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item_def and item_def.stats and 'armor' in item_def.stats: - armor_value = item_def.stats['armor'] - total_armor += armor_value - equipped_armor.append({ - 'slot': slot, - 'inv_item_id': armor_slot['item_id'], - 'unique_item_id': inv_item['unique_item_id'], - 'item_id': inv_item['item_id'], - 'item_def': item_def, - 'armor_value': armor_value - }) - - if not equipped_armor: - return 0, [] - - # Calculate damage absorbed by armor (total armor reduces damage) - armor_absorbed = min(damage_taken // 2, total_armor) # Armor absorbs up to half the damage - - # Calculate durability loss for each armor piece - # Balanced formula: armor should last many combats (10-20+ hits for low tier) - base_reduction_rate = 0.1 # Reduced from 0.5 to make armor more durable - broken_armor = [] - - for armor in equipped_armor: - # Each piece takes durability loss proportional to its armor value - proportion = armor['armor_value'] / total_armor if total_armor > 0 else 0 - # Formula: durability_loss = (damage_taken * proportion / armor_value) * base_rate - # This means higher armor value = less durability loss per hit - # With base_rate = 0.1, a 5 armor piece taking 10 damage loses ~0.2 durability per hit - durability_loss = max(1, int((damage_taken * proportion / max(armor['armor_value'], 1)) * base_reduction_rate * 10)) - - # Get current durability - unique_item = await db.get_unique_item(armor['unique_item_id']) - if unique_item: - current_durability = unique_item.get('durability', 0) - new_durability = max(0, current_durability - durability_loss) - - await db.update_unique_item(armor['unique_item_id'], durability=new_durability) - - # If armor broke, unequip and remove from inventory - if new_durability <= 0: - await db.unequip_item(player_id, armor['slot']) - await db.remove_inventory_row(armor['inv_item_id']) - broken_armor.append({ - 'name': armor['item_def'].name, - 'emoji': armor['item_def'].emoji, - 'slot': armor['slot'] - }) - - return armor_absorbed, broken_armor - - -async def consume_tool_durability(user_id: int, tools: list, inventory: list) -> tuple: - """ - Consume durability from required tools. - Returns: (success, error_message, consumed_tools_info) - """ - consumed_tools = [] - tools_map = {} - - # Build map of available tools with durability - for inv_item in inventory: - if inv_item.get('unique_item_id'): - unique_item = await db.get_unique_item(inv_item['unique_item_id']) - if unique_item: - item_id = inv_item['item_id'] - durability = unique_item.get('durability', 0) - if item_id not in tools_map: - tools_map[item_id] = [] - tools_map[item_id].append({ - 'inventory_id': inv_item['id'], - 'unique_item_id': inv_item['unique_item_id'], - 'durability': durability, - 'max_durability': unique_item.get('max_durability', 100) - }) - - # Check and consume tools - for tool_req in tools: - tool_id = tool_req['item_id'] - durability_cost = tool_req['durability_cost'] - - if tool_id not in tools_map or not tools_map[tool_id]: - tool_def = ITEMS_MANAGER.items.get(tool_id) - tool_name = tool_def.name if tool_def else tool_id - return False, f"Missing required tool: {tool_name}", [] - - # Find tool with enough durability - tool_found = None - for tool in tools_map[tool_id]: - if tool['durability'] >= durability_cost: - tool_found = tool - break - - if not tool_found: - tool_def = ITEMS_MANAGER.items.get(tool_id) - tool_name = tool_def.name if tool_def else tool_id - return False, f"Tool {tool_name} doesn't have enough durability (need {durability_cost})", [] - - # Consume durability - new_durability = tool_found['durability'] - durability_cost - await db.update_unique_item(tool_found['unique_item_id'], durability=new_durability) - - # If tool breaks, remove from inventory - if new_durability <= 0: - await db.remove_inventory_row(tool_found['inventory_id']) - - tool_def = ITEMS_MANAGER.items.get(tool_id) - consumed_tools.append({ - 'item_id': tool_id, - 'name': tool_def.name if tool_def else tool_id, - 'durability_cost': durability_cost, - 'broke': new_durability <= 0 - }) - - return True, "", consumed_tools - - - -@app.get("/api/game/craftable") -async def get_craftable_items(current_user: dict = Depends(get_current_user)): - """Get all craftable items with material requirements and availability""" - try: - player = current_user # current_user is already the character dict - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - # Get player's inventory with quantities - inventory = await db.get_inventory(current_user['id']) - inventory_counts = {} - for inv_item in inventory: - item_id = inv_item['item_id'] - quantity = inv_item.get('quantity', 1) - inventory_counts[item_id] = inventory_counts.get(item_id, 0) + quantity - - craftable_items = [] - for item_id, item_def in ITEMS_MANAGER.items.items(): - if not getattr(item_def, 'craftable', False): - continue - - craft_materials = getattr(item_def, 'craft_materials', []) - if not craft_materials: - continue - - # Check material availability - materials_info = [] - can_craft = True - for material in craft_materials: - mat_item_id = material['item_id'] - required = material['quantity'] - available = inventory_counts.get(mat_item_id, 0) - - mat_item_def = ITEMS_MANAGER.items.get(mat_item_id) - materials_info.append({ - 'item_id': mat_item_id, - 'name': mat_item_def.name if mat_item_def else mat_item_id, - 'emoji': mat_item_def.emoji if mat_item_def else '๐Ÿ“ฆ', - 'required': required, - 'available': available, - 'has_enough': available >= required - }) - - if available < required: - can_craft = False - - # Check tool requirements - craft_tools = getattr(item_def, 'craft_tools', []) - tools_info = [] - for tool_req in craft_tools: - tool_id = tool_req['item_id'] - durability_cost = tool_req['durability_cost'] - tool_def = ITEMS_MANAGER.items.get(tool_id) - - # Check if player has this tool - has_tool = False - tool_durability = 0 - for inv_item in inventory: - if inv_item['item_id'] == tool_id and inv_item.get('unique_item_id'): - unique = await db.get_unique_item(inv_item['unique_item_id']) - if unique and unique.get('durability', 0) >= durability_cost: - has_tool = True - tool_durability = unique.get('durability', 0) - break - - tools_info.append({ - 'item_id': tool_id, - 'name': tool_def.name if tool_def else tool_id, - 'emoji': tool_def.emoji if tool_def else '๐Ÿ”ง', - 'durability_cost': durability_cost, - 'has_tool': has_tool, - 'tool_durability': tool_durability - }) - - if not has_tool: - can_craft = False - - # Check level requirement - craft_level = getattr(item_def, 'craft_level', 1) - player_level = player.get('level', 1) - meets_level = player_level >= craft_level - - # Don't show recipes above player level - if player_level < craft_level: - continue - - if not meets_level: - can_craft = False - - craftable_items.append({ - 'item_id': item_id, - 'name': item_def.name, - 'emoji': item_def.emoji, - 'description': item_def.description, - 'tier': getattr(item_def, 'tier', 1), - 'type': item_def.type, - 'category': item_def.type, # Add category for filtering - 'slot': getattr(item_def, 'slot', None), - 'materials': materials_info, - 'tools': tools_info, - 'craft_level': craft_level, - 'meets_level': meets_level, - 'uncraftable': getattr(item_def, 'uncraftable', False), - 'can_craft': can_craft - }) - - # Sort: craftable items first, then by tier, then by name - craftable_items.sort(key=lambda x: (not x['can_craft'], x['tier'], x['name'])) - - return {'craftable_items': craftable_items} - - except Exception as e: - print(f"Error getting craftable items: {e}") - import traceback - traceback.print_exc() - raise HTTPException(status_code=500, detail=str(e)) - - -class CraftItemRequest(BaseModel): - item_id: str - - -@app.post("/api/game/craft_item") -async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get_current_user)): - """Craft an item, consuming materials and creating item with random stats for unique items""" - try: - player = current_user # current_user is already the character dict - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - location_id = player['location_id'] - location = LOCATIONS.get(location_id) - - # Check if player is at a workbench - if not location or 'workbench' not in getattr(location, 'tags', []): - raise HTTPException(status_code=400, detail="You must be at a workbench to craft items") - - # Get item definition - item_def = ITEMS_MANAGER.items.get(request.item_id) - if not item_def: - raise HTTPException(status_code=404, detail="Item not found") - - if not getattr(item_def, 'craftable', False): - raise HTTPException(status_code=400, detail="This item cannot be crafted") - - # Check level requirement - craft_level = getattr(item_def, 'craft_level', 1) - player_level = player.get('level', 1) - if player_level < craft_level: - raise HTTPException(status_code=400, detail=f"You need to be level {craft_level} to craft this item (you are level {player_level})") - - craft_materials = getattr(item_def, 'craft_materials', []) - if not craft_materials: - raise HTTPException(status_code=400, detail="No crafting recipe found") - - # Check if player has all materials - inventory = await db.get_inventory(current_user['id']) - inventory_counts = {} - inventory_items_map = {} - - for inv_item in inventory: - item_id = inv_item['item_id'] - quantity = inv_item.get('quantity', 1) - inventory_counts[item_id] = inventory_counts.get(item_id, 0) + quantity - if item_id not in inventory_items_map: - inventory_items_map[item_id] = [] - inventory_items_map[item_id].append(inv_item) - - # Check tools requirement - craft_tools = getattr(item_def, 'craft_tools', []) - if craft_tools: - success, error_msg, tools_consumed = await consume_tool_durability(current_user['id'], craft_tools, inventory) - if not success: - raise HTTPException(status_code=400, detail=error_msg) - else: - tools_consumed = [] - - # Verify all materials are available - for material in craft_materials: - required = material['quantity'] - available = inventory_counts.get(material['item_id'], 0) - if available < required: - raise HTTPException( - status_code=400, - detail=f"Not enough {material['item_id']}. Need {required}, have {available}" - ) - - # Consume materials - materials_used = [] - for material in craft_materials: - item_id = material['item_id'] - quantity_needed = material['quantity'] - - items_of_type = inventory_items_map[item_id] - for inv_item in items_of_type: - if quantity_needed <= 0: - break - - inv_quantity = inv_item.get('quantity', 1) - to_remove = min(quantity_needed, inv_quantity) - - if inv_quantity > to_remove: - # Update quantity - await db.update_inventory_item( - inv_item['id'], - quantity=inv_quantity - to_remove - ) - else: - # Remove entire stack - use item_id string, not inventory row id - await db.remove_item_from_inventory(current_user['id'], item_id, to_remove) - - quantity_needed -= to_remove - - mat_item_def = ITEMS_MANAGER.items.get(item_id) - materials_used.append({ - 'item_id': item_id, - 'name': mat_item_def.name if mat_item_def else item_id, - 'quantity': material['quantity'] - }) - - # Generate random stats for unique items - import random - created_item = None - - if hasattr(item_def, 'durability') and item_def.durability: - # This is a unique item - generate random stats - base_durability = item_def.durability - # Random durability: 90-110% of base - random_durability = int(base_durability * random.uniform(0.9, 1.1)) - - # Generate tier based on durability roll - durability_percent = (random_durability / base_durability) - if durability_percent >= 1.08: - tier = 5 # Gold - elif durability_percent >= 1.04: - tier = 4 # Purple - elif durability_percent >= 1.0: - tier = 3 # Blue - elif durability_percent >= 0.96: - tier = 2 # Green - else: - tier = 1 # White - - # Generate random stats if item has stats - random_stats = {} - if hasattr(item_def, 'stats') and item_def.stats: - for stat_key, stat_value in item_def.stats.items(): - if isinstance(stat_value, (int, float)): - # Random stat: 90-110% of base - random_stats[stat_key] = int(stat_value * random.uniform(0.9, 1.1)) - else: - random_stats[stat_key] = stat_value - - # Create unique item in database - unique_item_id = await db.create_unique_item( - item_id=request.item_id, - durability=random_durability, - max_durability=random_durability, - tier=tier, - unique_stats=random_stats - ) - - # Add to inventory - await db.add_item_to_inventory( - player_id=current_user['id'], - item_id=request.item_id, - quantity=1, - unique_item_id=unique_item_id - ) - - created_item = { - 'item_id': request.item_id, - 'name': item_def.name, - 'emoji': item_def.emoji, - 'tier': tier, - 'durability': random_durability, - 'max_durability': random_durability, - 'stats': random_stats, - 'unique': True - } - else: - # Stackable item - just add to inventory - await db.add_item_to_inventory( - player_id=current_user['id'], - item_id=request.item_id, - quantity=1 - ) - - created_item = { - 'item_id': request.item_id, - 'name': item_def.name, - 'emoji': item_def.emoji, - 'tier': getattr(item_def, 'tier', 1), - 'unique': False - } - - return { - 'success': True, - 'message': f"Successfully crafted {item_def.name}!", - 'item': created_item, - 'materials_consumed': materials_used, - 'tools_consumed': tools_consumed - } - - except Exception as e: - print(f"Error crafting item: {e}") - import traceback - traceback.print_exc() - raise HTTPException(status_code=500, detail=str(e)) - - -class UncraftItemRequest(BaseModel): - inventory_id: int - - -@app.post("/api/game/uncraft_item") -async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends(get_current_user)): - """Uncraft an item, returning materials with a chance of loss""" - try: - player = current_user # current_user is already the character dict - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - location_id = player['location_id'] - location = LOCATIONS.get(location_id) - - # Check if player is at a workbench - if not location or 'workbench' not in getattr(location, 'tags', []): - raise HTTPException(status_code=400, detail="You must be at a workbench to uncraft items") - - # Get inventory item - inventory = await db.get_inventory(current_user['id']) - inv_item = None - for item in inventory: - if item['id'] == request.inventory_id: - inv_item = item - break - - if not inv_item: - raise HTTPException(status_code=404, detail="Item not found in inventory") - - # Get item definition - item_def = ITEMS_MANAGER.items.get(inv_item['item_id']) - if not item_def: - raise HTTPException(status_code=404, detail="Item definition not found") - - if not getattr(item_def, 'uncraftable', False): - raise HTTPException(status_code=400, detail="This item cannot be uncrafted") - - uncraft_yield = getattr(item_def, 'uncraft_yield', []) - if not uncraft_yield: - raise HTTPException(status_code=400, detail="No uncraft recipe found") - - # Check tools requirement - uncraft_tools = getattr(item_def, 'uncraft_tools', []) - if uncraft_tools: - success, error_msg, tools_consumed = await consume_tool_durability(current_user['id'], uncraft_tools, inventory) - if not success: - raise HTTPException(status_code=400, detail=error_msg) - else: - tools_consumed = [] - - # Remove the item from inventory - # Use remove_inventory_row since we have the inventory ID - await db.remove_inventory_row(inv_item['id']) - - # Calculate durability ratio for yield reduction - durability_ratio = 1.0 # Default: full yield - if inv_item.get('unique_item_id'): - unique_item = await db.get_unique_item(inv_item['unique_item_id']) - if unique_item: - current_durability = unique_item.get('durability', 0) - max_durability = unique_item.get('max_durability', 1) - if max_durability > 0: - durability_ratio = current_durability / max_durability - - # Calculate materials with loss chance and durability reduction - import random - loss_chance = getattr(item_def, 'uncraft_loss_chance', 0.3) - materials_yielded = [] - materials_lost = [] - - for material in uncraft_yield: - # Apply durability reduction first - base_quantity = material['quantity'] - adjusted_quantity = int(base_quantity * durability_ratio) - - # If durability is too low (< 10%), yield nothing for this material - if durability_ratio < 0.1 or adjusted_quantity <= 0: - mat_def = ITEMS_MANAGER.items.get(material['item_id']) - materials_lost.append({ - 'item_id': material['item_id'], - 'name': mat_def.name if mat_def else material['item_id'], - 'quantity': base_quantity, - 'reason': 'durability_too_low' - }) - continue - - # Roll for each material separately with loss chance - if random.random() < loss_chance: - # Lost this material - mat_def = ITEMS_MANAGER.items.get(material['item_id']) - materials_lost.append({ - 'item_id': material['item_id'], - 'name': mat_def.name if mat_def else material['item_id'], - 'quantity': adjusted_quantity, - 'reason': 'random_loss' - }) - else: - # Yield this material - await db.add_item_to_inventory( - player_id=current_user['id'], - item_id=material['item_id'], - quantity=adjusted_quantity - ) - mat_def = ITEMS_MANAGER.items.get(material['item_id']) - materials_yielded.append({ - 'item_id': material['item_id'], - 'name': mat_def.name if mat_def else material['item_id'], - 'emoji': mat_def.emoji if mat_def else '๐Ÿ“ฆ', - 'quantity': adjusted_quantity - }) - - message = f"Uncrafted {item_def.name}!" - if durability_ratio < 1.0: - message += f" (Item condition reduced yield by {int((1 - durability_ratio) * 100)}%)" - if materials_lost: - message += f" Lost {len(materials_lost)} material type(s) in the process." - - return { - 'success': True, - 'message': message, - 'item_name': item_def.name, - 'materials_yielded': materials_yielded, - 'materials_lost': materials_lost, - 'tools_consumed': tools_consumed, - 'loss_chance': loss_chance, - 'durability_ratio': round(durability_ratio, 2) - } - - except Exception as e: - print(f"Error uncrafting item: {e}") - import traceback - traceback.print_exc() - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/api/game/repairable") -async def get_repairable_items(current_user: dict = Depends(get_current_user)): - """Get all repairable items from inventory and equipped slots""" - try: - player = current_user # current_user is already the character dict - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - location_id = player['location_id'] - location = LOCATIONS.get(location_id) - - # Check if player is at a repair station - if not location or 'repair_station' not in getattr(location, 'tags', []): - raise HTTPException(status_code=400, detail="You must be at a repair station to repair items") - - repairable_items = [] - - # Check inventory items - inventory = await db.get_inventory(current_user['id']) - inventory_counts = {} - for inv_item in inventory: - item_id = inv_item['item_id'] - quantity = inv_item.get('quantity', 1) - inventory_counts[item_id] = inventory_counts.get(item_id, 0) + quantity - - for inv_item in inventory: - if inv_item.get('unique_item_id'): - unique_item = await db.get_unique_item(inv_item['unique_item_id']) - if not unique_item: - continue - - item_def = ITEMS_MANAGER.items.get(inv_item['item_id']) - if not item_def or not getattr(item_def, 'repairable', False): - continue - - current_durability = unique_item.get('durability', 0) - max_durability = unique_item.get('max_durability', 100) - needs_repair = current_durability < max_durability - - # Check materials availability - repair_materials = getattr(item_def, 'repair_materials', []) - materials_info = [] - has_materials = True - for material in repair_materials: - mat_item_def = ITEMS_MANAGER.items.get(material['item_id']) - available = inventory_counts.get(material['item_id'], 0) - required = material['quantity'] - materials_info.append({ - 'item_id': material['item_id'], - 'name': mat_item_def.name if mat_item_def else material['item_id'], - 'emoji': mat_item_def.emoji if mat_item_def else '๐Ÿ“ฆ', - 'quantity': required, - 'available': available, - 'has_enough': available >= required - }) - if available < required: - has_materials = False - - # Check tools availability - repair_tools = getattr(item_def, 'repair_tools', []) - tools_info = [] - has_tools = True - for tool_req in repair_tools: - tool_id = tool_req['item_id'] - durability_cost = tool_req['durability_cost'] - tool_def = ITEMS_MANAGER.items.get(tool_id) - - # Check if player has this tool - tool_found = False - tool_durability = 0 - for check_item in inventory: - if check_item['item_id'] == tool_id and check_item.get('unique_item_id'): - unique = await db.get_unique_item(check_item['unique_item_id']) - if unique and unique.get('durability', 0) >= durability_cost: - tool_found = True - tool_durability = unique.get('durability', 0) - break - - tools_info.append({ - 'item_id': tool_id, - 'name': tool_def.name if tool_def else tool_id, - 'emoji': tool_def.emoji if tool_def else '๐Ÿ”ง', - 'durability_cost': durability_cost, - 'has_tool': tool_found, - 'tool_durability': tool_durability - }) - if not tool_found: - has_tools = False - - can_repair = needs_repair and has_materials and has_tools - - repairable_items.append({ - 'inventory_id': inv_item['id'], - 'unique_item_id': inv_item['unique_item_id'], - 'item_id': inv_item['item_id'], - 'name': item_def.name, - 'emoji': item_def.emoji, - 'tier': unique_item.get('tier', 1), - 'current_durability': current_durability, - 'max_durability': max_durability, - 'durability_percent': int((current_durability / max_durability) * 100), - 'repair_percentage': getattr(item_def, 'repair_percentage', 25), - 'needs_repair': needs_repair, - 'materials': materials_info, - 'tools': tools_info, - 'can_repair': can_repair, - 'location': 'inventory' - }) - - # Check equipped items - equipment_slots = ['head', 'weapon', 'torso', 'backpack', 'legs', 'feet'] - for slot in equipment_slots: - equipped_item_id = player.get(f'equipped_{slot}') - if not equipped_item_id: - continue - - unique_item = await db.get_unique_item(equipped_item_id) - if not unique_item: - continue - - item_id = unique_item['item_id'] - item_def = ITEMS_MANAGER.items.get(item_id) - if not item_def or not getattr(item_def, 'repairable', False): - continue - - current_durability = unique_item.get('durability', 0) - max_durability = unique_item.get('max_durability', 100) - needs_repair = current_durability < max_durability - - # Check materials availability - repair_materials = getattr(item_def, 'repair_materials', []) - materials_info = [] - has_materials = True - for material in repair_materials: - mat_item_def = ITEMS_MANAGER.items.get(material['item_id']) - available = inventory_counts.get(material['item_id'], 0) - required = material['quantity'] - materials_info.append({ - 'item_id': material['item_id'], - 'name': mat_item_def.name if mat_item_def else material['item_id'], - 'emoji': mat_item_def.emoji if mat_item_def else '๐Ÿ“ฆ', - 'quantity': required, - 'available': available, - 'has_enough': available >= required - }) - if available < required: - has_materials = False - - # Check tools availability - repair_tools = getattr(item_def, 'repair_tools', []) - tools_info = [] - has_tools = True - for tool_req in repair_tools: - tool_id = tool_req['item_id'] - durability_cost = tool_req['durability_cost'] - tool_def = ITEMS_MANAGER.items.get(tool_id) - - # Check if player has this tool - tool_found = False - tool_durability = 0 - for check_item in inventory: - if check_item['item_id'] == tool_id and check_item.get('unique_item_id'): - unique = await db.get_unique_item(check_item['unique_item_id']) - if unique and unique.get('durability', 0) >= durability_cost: - tool_found = True - tool_durability = unique.get('durability', 0) - break - - tools_info.append({ - 'item_id': tool_id, - 'name': tool_def.name if tool_def else tool_id, - 'emoji': tool_def.emoji if tool_def else '๐Ÿ”ง', - 'durability_cost': durability_cost, - 'has_tool': tool_found, - 'tool_durability': tool_durability - }) - if not tool_found: - has_tools = False - - can_repair = needs_repair and has_materials and has_tools - - repairable_items.append({ - 'unique_item_id': equipped_item_id, - 'item_id': item_id, - 'name': item_def.name, - 'emoji': item_def.emoji, - 'tier': unique_item.get('tier', 1), - 'current_durability': current_durability, - 'max_durability': max_durability, - 'durability_percent': int((current_durability / max_durability) * 100), - 'repair_percentage': getattr(item_def, 'repair_percentage', 25), - 'needs_repair': needs_repair, - 'materials': materials_info, - 'tools': tools_info, - 'can_repair': can_repair, - 'location': 'equipped', - 'slot': slot - }) - - # Sort: repairable items first (can_repair=True), then by durability percent (lowest first), then by name - repairable_items.sort(key=lambda x: (not x['can_repair'], -x['durability_percent'], x['name'])) - - return {'repairable_items': repairable_items} - - except Exception as e: - print(f"Error getting repairable items: {e}") - import traceback - traceback.print_exc() - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/api/game/salvageable") -async def get_salvageable_items(current_user: dict = Depends(get_current_user)): - """Get list of salvageable (uncraftable) items from inventory with their unique stats""" - try: - player = current_user # current_user is already the character dict - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - location_id = player['location_id'] - location = LOCATIONS.get(location_id) - - # Check if player is at a workbench - if not location or 'workbench' not in getattr(location, 'tags', []): - return {'salvageable_items': [], 'at_workbench': False} - - # Get inventory - inventory = await db.get_inventory(current_user['id']) - - salvageable_items = [] - for inv_item in inventory: - item_id = inv_item['item_id'] - item_def = ITEMS_MANAGER.items.get(item_id) - - if not item_def or not getattr(item_def, 'uncraftable', False): - continue - - # Get unique item details if it exists - unique_item_data = None - if inv_item.get('unique_item_id'): - unique_item = await db.get_unique_item(inv_item['unique_item_id']) - if unique_item: - current_durability = unique_item.get('durability', 0) - max_durability = unique_item.get('max_durability', 1) - durability_percent = int((current_durability / max_durability) * 100) if max_durability > 0 else 0 - - # Get item stats from definition merged with unique stats - item_stats = {} - if item_def.stats: - item_stats = dict(item_def.stats) - if unique_item.get('unique_stats'): - item_stats.update(unique_item.get('unique_stats')) - - unique_item_data = { - 'current_durability': current_durability, - 'max_durability': max_durability, - 'durability_percent': durability_percent, - 'tier': unique_item.get('tier', 1), - 'unique_stats': item_stats # Includes both base stats and unique overrides - } - - # Get uncraft yield - uncraft_yield = getattr(item_def, 'uncraft_yield', []) - yield_info = [] - for material in uncraft_yield: - mat_def = ITEMS_MANAGER.items.get(material['item_id']) - yield_info.append({ - 'item_id': material['item_id'], - 'name': mat_def.name if mat_def else material['item_id'], - 'emoji': mat_def.emoji if mat_def else '๐Ÿ“ฆ', - 'quantity': material['quantity'] - }) - - salvageable_items.append({ - 'inventory_id': inv_item['id'], - 'unique_item_id': inv_item.get('unique_item_id'), - 'item_id': item_id, - 'name': item_def.name, - 'emoji': item_def.emoji, - 'tier': getattr(item_def, 'tier', 1), - 'quantity': inv_item['quantity'], - 'unique_item_data': unique_item_data, - 'base_yield': yield_info, - 'loss_chance': getattr(item_def, 'uncraft_loss_chance', 0.3) - }) - - return { - 'salvageable_items': salvageable_items, - 'at_workbench': True - } - - except Exception as e: - print(f"Error getting salvageable items: {e}") - import traceback - traceback.print_exc() - raise HTTPException(status_code=500, detail=str(e)) - - -class LootCorpseRequest(BaseModel): - corpse_id: str - item_index: Optional[int] = None # Index of specific item to loot (None = all) - - -@app.get("/api/game/corpse/{corpse_id}") -async def get_corpse_details( - corpse_id: str, - current_user: dict = Depends(get_current_user) -): - """Get detailed information about a corpse's lootable items""" - import json - import sys - sys.path.insert(0, '/app') - from data.npcs import NPCS - - # Parse corpse ID - corpse_type, corpse_db_id = corpse_id.split('_', 1) - corpse_db_id = int(corpse_db_id) - - player = current_user # current_user is already the character dict - - # Get player's inventory to check available tools - inventory = await db.get_inventory(player['id']) - available_tools = set([item['item_id'] for item in inventory]) - - if corpse_type == 'npc': - # Get NPC corpse - corpse = await db.get_npc_corpse(corpse_db_id) - if not corpse: - raise HTTPException(status_code=404, detail="Corpse not found") - - if corpse['location_id'] != player['location_id']: - raise HTTPException(status_code=400, detail="Corpse not at this location") - - # Parse remaining loot - loot_remaining = json.loads(corpse['loot_remaining']) if corpse['loot_remaining'] else [] - - # Format loot items with tool requirements - loot_items = [] - for idx, loot_item in enumerate(loot_remaining): - required_tool = loot_item.get('required_tool') - item_def = ITEMS_MANAGER.get_item(loot_item['item_id']) - - has_tool = required_tool is None or required_tool in available_tools - tool_def = ITEMS_MANAGER.get_item(required_tool) if required_tool else None - - loot_items.append({ - 'index': idx, - 'item_id': loot_item['item_id'], - 'item_name': item_def.name if item_def else loot_item['item_id'], - 'emoji': item_def.emoji if item_def else '๐Ÿ“ฆ', - 'quantity_min': loot_item['quantity_min'], - 'quantity_max': loot_item['quantity_max'], - 'required_tool': required_tool, - 'required_tool_name': tool_def.name if tool_def else required_tool, - 'has_tool': has_tool, - 'can_loot': has_tool - }) - - npc_def = NPCS.get(corpse['npc_id']) - - return { - 'corpse_id': corpse_id, - 'type': 'npc', - 'name': f"{npc_def.name if npc_def else corpse['npc_id']} Corpse", - 'loot_items': loot_items, - 'total_items': len(loot_items) - } - - elif corpse_type == 'player': - # Get player corpse - corpse = await db.get_player_corpse(corpse_db_id) - if not corpse: - raise HTTPException(status_code=404, detail="Corpse not found") - - if corpse['location_id'] != player['location_id']: - raise HTTPException(status_code=400, detail="Corpse not at this location") - - # Parse items - items = json.loads(corpse['items']) if corpse['items'] else [] - - # Format items (player corpses don't require tools) - loot_items = [] - for idx, item in enumerate(items): - item_def = ITEMS_MANAGER.get_item(item['item_id']) - - loot_items.append({ - 'index': idx, - 'item_id': item['item_id'], - 'item_name': item_def.name if item_def else item['item_id'], - 'emoji': item_def.emoji if item_def else '๐Ÿ“ฆ', - 'quantity_min': item['quantity'], - 'quantity_max': item['quantity'], - 'required_tool': None, - 'required_tool_name': None, - 'has_tool': True, - 'can_loot': True - }) - - return { - 'corpse_id': corpse_id, - 'type': 'player', - 'name': f"{corpse['player_name']}'s Corpse", - 'loot_items': loot_items, - 'total_items': len(loot_items) - } - - else: - raise HTTPException(status_code=400, detail="Invalid corpse type") - - -@app.post("/api/game/loot_corpse") -async def loot_corpse( - req: LootCorpseRequest, - current_user: dict = Depends(get_current_user) -): - """Loot a corpse (NPC or player) - can loot specific item by index or all items""" - import json - import sys - import random - sys.path.insert(0, '/app') - from data.npcs import NPCS - - # Parse corpse ID - corpse_type, corpse_db_id = req.corpse_id.split('_', 1) - corpse_db_id = int(corpse_db_id) - - player = current_user # current_user is already the character dict - - # Get player's current capacity - current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(player['id']) - - if corpse_type == 'npc': - # Get NPC corpse - corpse = await db.get_npc_corpse(corpse_db_id) - if not corpse: - raise HTTPException(status_code=404, detail="Corpse not found") - - # Check if player is at the same location - if corpse['location_id'] != player['location_id']: - raise HTTPException(status_code=400, detail="Corpse not at this location") - - # Parse remaining loot - loot_remaining = json.loads(corpse['loot_remaining']) if corpse['loot_remaining'] else [] - - if not loot_remaining: - raise HTTPException(status_code=400, detail="Corpse has already been looted") - - # Get player's inventory to check tools - inventory = await db.get_inventory(player['id']) - available_tools = set([item['item_id'] for item in inventory]) - - looted_items = [] - remaining_loot = [] - dropped_items = [] # Items that couldn't fit in inventory - tools_consumed = [] # Track tool durability consumed - - # If specific item index provided, loot only that item - if req.item_index is not None: - if req.item_index < 0 or req.item_index >= len(loot_remaining): - raise HTTPException(status_code=400, detail="Invalid item index") - - loot_item = loot_remaining[req.item_index] - required_tool = loot_item.get('required_tool') - durability_cost = loot_item.get('tool_durability_cost', 5) # Default 5 durability per loot - - # Check if player has required tool and consume durability - if required_tool: - # Build tool requirement format for consume_tool_durability - tool_req = [{ - 'item_id': required_tool, - 'durability_cost': durability_cost - }] - - success, error_msg, tools_consumed = await consume_tool_durability(player['id'], tool_req, inventory) - if not success: - raise HTTPException(status_code=400, detail=error_msg) - - # Determine quantity - quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max']) - - if quantity > 0: - # Check if item fits in inventory - item_def = ITEMS_MANAGER.get_item(loot_item['item_id']) - if item_def: - item_weight = item_def.weight * quantity - item_volume = item_def.volume * quantity - - if current_weight + item_weight > max_weight or current_volume + item_volume > max_volume: - # Item doesn't fit - drop it on ground - await db.add_dropped_item(player['location_id'], loot_item['item_id'], quantity) - dropped_items.append({ - 'item_id': loot_item['item_id'], - 'quantity': quantity, - 'emoji': item_def.emoji - }) - else: - # Item fits - add to inventory - await db.add_item_to_inventory(player['id'], loot_item['item_id'], quantity) - current_weight += item_weight - current_volume += item_volume - looted_items.append({ - 'item_id': loot_item['item_id'], - 'quantity': quantity - }) - - # Remove this item from loot, keep others - remaining_loot = [item for i, item in enumerate(loot_remaining) if i != req.item_index] - else: - # Loot all items that don't require tools or player has tools for - for loot_item in loot_remaining: - required_tool = loot_item.get('required_tool') - durability_cost = loot_item.get('tool_durability_cost', 5) - - # If tool is required, consume durability - can_loot = True - if required_tool: - tool_req = [{ - 'item_id': required_tool, - 'durability_cost': durability_cost - }] - - # Check if player has tool with enough durability - success, error_msg, consumed_info = await consume_tool_durability(player['id'], tool_req, inventory) - if success: - # Tool consumed successfully - tools_consumed.extend(consumed_info) - # Refresh inventory after tool consumption - inventory = await db.get_inventory(player['id']) - else: - # Can't loot this item - can_loot = False - - if can_loot: - # Can loot this item - quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max']) - - if quantity > 0: - # Check if item fits in inventory - item_def = ITEMS_MANAGER.get_item(loot_item['item_id']) - if item_def: - item_weight = item_def.weight * quantity - item_volume = item_def.volume * quantity - - if current_weight + item_weight > max_weight or current_volume + item_volume > max_volume: - # Item doesn't fit - drop it on ground - await db.add_dropped_item(player['location_id'], loot_item['item_id'], quantity) - dropped_items.append({ - 'item_id': loot_item['item_id'], - 'quantity': quantity, - 'emoji': item_def.emoji - }) - else: - # Item fits - add to inventory - await db.add_item_to_inventory(player['id'], loot_item['item_id'], quantity) - current_weight += item_weight - current_volume += item_volume - looted_items.append({ - 'item_id': loot_item['item_id'], - 'quantity': quantity - }) - else: - # Keep in corpse - remaining_loot.append(loot_item) - - # Update or remove corpse - if remaining_loot: - await db.update_npc_corpse(corpse_db_id, json.dumps(remaining_loot)) - else: - await db.remove_npc_corpse(corpse_db_id) - - # Build response message - message_parts = [] - for item in looted_items: - item_def = ITEMS_MANAGER.get_item(item['item_id']) - item_name = item_def.name if item_def else item['item_id'] - message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}") - - dropped_parts = [] - for item in dropped_items: - item_def = ITEMS_MANAGER.get_item(item['item_id']) - item_name = item_def.name if item_def else item['item_id'] - dropped_parts.append(f"{item.get('emoji', '๐Ÿ“ฆ')} {item_name} x{item['quantity']}") - - message = "" - if message_parts: - message = "Looted: " + ", ".join(message_parts) - if dropped_parts: - if message: - message += "\n" - message += "โš ๏ธ Backpack full! Dropped on ground: " + ", ".join(dropped_parts) - if not message_parts and not dropped_parts: - message = "Nothing could be looted" - if remaining_loot and req.item_index is None: - message += f"\n{len(remaining_loot)} item(s) require tools to extract" - - # Broadcast to location about corpse looting - if len(remaining_loot) == 0: - # Corpse fully looted - await manager.send_to_location( - location_id=player['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} fully looted an NPC corpse", - "action": "corpse_looted" - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=player['id'] - ) - - return { - "success": True, - "message": message, - "looted_items": looted_items, - "dropped_items": dropped_items, - "tools_consumed": tools_consumed, - "corpse_empty": len(remaining_loot) == 0, - "remaining_count": len(remaining_loot) - } - - elif corpse_type == 'player': - # Get player corpse - corpse = await db.get_player_corpse(corpse_db_id) - if not corpse: - raise HTTPException(status_code=404, detail="Corpse not found") - - if corpse['location_id'] != player['location_id']: - raise HTTPException(status_code=400, detail="Corpse not at this location") - - # Parse items - items = json.loads(corpse['items']) if corpse['items'] else [] - - if not items: - raise HTTPException(status_code=400, detail="Corpse has no items") - - looted_items = [] - remaining_items = [] - dropped_items = [] # Items that couldn't fit in inventory - - # If specific item index provided, loot only that item - if req.item_index is not None: - if req.item_index < 0 or req.item_index >= len(items): - raise HTTPException(status_code=400, detail="Invalid item index") - - item = items[req.item_index] - - # Check if item fits in inventory - item_def = ITEMS_MANAGER.get_item(item['item_id']) - if item_def: - item_weight = item_def.weight * item['quantity'] - item_volume = item_def.volume * item['quantity'] - - if current_weight + item_weight > max_weight or current_volume + item_volume > max_volume: - # Item doesn't fit - drop it on ground - await db.add_dropped_item(player['location_id'], item['item_id'], item['quantity']) - dropped_items.append({ - 'item_id': item['item_id'], - 'quantity': item['quantity'], - 'emoji': item_def.emoji - }) - else: - # Item fits - add to inventory - await db.add_item_to_inventory(player['id'], item['item_id'], item['quantity']) - looted_items.append(item) - - # Remove this item, keep others - remaining_items = [it for i, it in enumerate(items) if i != req.item_index] - else: - # Loot all items - for item in items: - # Check if item fits in inventory - item_def = ITEMS_MANAGER.get_item(item['item_id']) - if item_def: - item_weight = item_def.weight * item['quantity'] - item_volume = item_def.volume * item['quantity'] - - if current_weight + item_weight > max_weight or current_volume + item_volume > max_volume: - # Item doesn't fit - drop it on ground - await db.add_dropped_item(player['location_id'], item['item_id'], item['quantity']) - dropped_items.append({ - 'item_id': item['item_id'], - 'quantity': item['quantity'], - 'emoji': item_def.emoji - }) - else: - # Item fits - add to inventory - await db.add_item_to_inventory(player['id'], item['item_id'], item['quantity']) - current_weight += item_weight - current_volume += item_volume - looted_items.append(item) - - # Update or remove corpse - if remaining_items: - await db.update_player_corpse(corpse_db_id, json.dumps(remaining_items)) - else: - await db.remove_player_corpse(corpse_db_id) - - # Build message - message_parts = [] - for item in looted_items: - item_def = ITEMS_MANAGER.get_item(item['item_id']) - item_name = item_def.name if item_def else item['item_id'] - message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}") - - dropped_parts = [] - for item in dropped_items: - item_def = ITEMS_MANAGER.get_item(item['item_id']) - item_name = item_def.name if item_def else item['item_id'] - dropped_parts.append(f"{item.get('emoji', '๐Ÿ“ฆ')} {item_name} x{item['quantity']}") - - message = "" - if message_parts: - message = "Looted: " + ", ".join(message_parts) - if dropped_parts: - if message: - message += "\n" - message += "โš ๏ธ Backpack full! Dropped on ground: " + ", ".join(dropped_parts) - if not message_parts and not dropped_parts: - message = "Nothing could be looted" - - # Broadcast to location about corpse looting - if len(remaining_items) == 0: - # Corpse fully looted - broadcast removal - await manager.send_to_location( - location_id=player['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} fully looted {corpse['player_name']}'s corpse", - "action": "player_corpse_emptied", - "corpse_id": req.corpse_id - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=player['id'] - ) - else: - # Corpse partially looted - broadcast item updates - await manager.send_to_location( - location_id=player['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} looted from {corpse['player_name']}'s corpse", - "action": "player_corpse_looted", - "corpse_id": req.corpse_id, - "remaining_items": remaining_items, - "looted_item_ids": [item['item_id'] for item in looted_items] if req.item_index is not None else None - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=player['id'] - ) - - return { - "success": True, - "message": message, - "looted_items": looted_items, - "dropped_items": dropped_items, - "corpse_empty": len(remaining_items) == 0, - "remaining_count": len(remaining_items) - } - - else: - raise HTTPException(status_code=400, detail="Invalid corpse type") - - -# ============================================================================ -# Combat Endpoints -# ============================================================================ - -@app.get("/api/game/combat") -async def get_combat_status(current_user: dict = Depends(get_current_user)): - """Get current combat status""" - combat = await db.get_active_combat(current_user['id']) - if not combat: - return {"in_combat": False} - - # Load NPC data from npcs.json - import sys - sys.path.insert(0, '/app') - from data.npcs import NPCS - npc_def = NPCS.get(combat['npc_id']) - - return { - "in_combat": True, - "combat": { - "npc_id": combat['npc_id'], - "npc_name": npc_def.name if npc_def else combat['npc_id'].replace('_', ' ').title(), - "npc_hp": combat['npc_hp'], - "npc_max_hp": combat['npc_max_hp'], - "npc_image": f"/images/npcs/{combat['npc_id']}.png" if npc_def else None, - "turn": combat['turn'], - "round": combat.get('round', 1) - } - } - - -@app.post("/api/game/combat/initiate") -async def initiate_combat( - req: InitiateCombatRequest, - current_user: dict = Depends(get_current_user) -): - """Start combat with a wandering enemy""" - import random - import sys - sys.path.insert(0, '/app') - from data.npcs import NPCS - - # Check if already in combat - existing_combat = await db.get_active_combat(current_user['id']) - if existing_combat: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Already in combat" - ) - - # Get enemy from wandering_enemies table - async with db.DatabaseSession() as session: - from sqlalchemy import select - stmt = select(db.wandering_enemies).where(db.wandering_enemies.c.id == req.enemy_id) - result = await session.execute(stmt) - enemy = result.fetchone() - - if not enemy: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Enemy not found" - ) - - # Get NPC definition - npc_def = NPCS.get(enemy.npc_id) - if not npc_def: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="NPC definition not found" - ) - - # Randomize HP - npc_hp = random.randint(npc_def.hp_min, npc_def.hp_max) - - # Create combat - combat = await db.create_combat( - player_id=current_user['id'], - npc_id=enemy.npc_id, - npc_hp=npc_hp, - npc_max_hp=npc_hp, - location_id=current_user['location_id'], - from_wandering=True - ) - - # Remove the wandering enemy from the location - async with db.DatabaseSession() as session: - from sqlalchemy import delete - stmt = delete(db.wandering_enemies).where(db.wandering_enemies.c.id == req.enemy_id) - await session.execute(stmt) - await session.commit() - - # Track combat initiation - await db.update_player_statistics(current_user['id'], combats_initiated=1, increment=True) - - # Get player info for broadcasts - player = current_user # current_user is already the character dict - - # Send WebSocket update to the player - await manager.send_personal_message(current_user['id'], { - "type": "combat_started", - "data": { - "message": f"Combat started with {npc_def.name}!", - "combat": { - "npc_id": enemy.npc_id, - "npc_name": npc_def.name, - "npc_hp": npc_hp, - "npc_max_hp": npc_hp, - "npc_image": f"/images/npcs/{enemy.npc_id}.png", - "turn": "player", - "round": 1 - } - }, - "timestamp": datetime.utcnow().isoformat() - }) - - # Broadcast to location that player entered combat - await manager.send_to_location( - location_id=current_user['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} entered combat with {npc_def.name}", - "action": "combat_started", - "player_id": player['id'] - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=current_user['id'] - ) - - return { - "success": True, - "message": f"Combat started with {npc_def.name}!", - "combat": { - "npc_id": enemy.npc_id, - "npc_name": npc_def.name, - "npc_hp": npc_hp, - "npc_max_hp": npc_hp, - "npc_image": f"/images/npcs/{enemy.npc_id}.png", - "turn": "player", - "round": 1 - } - } - - -@app.post("/api/game/combat/action") -async def combat_action( - req: CombatActionRequest, - current_user: dict = Depends(get_current_user) -): - """Perform a combat action""" - import random - import sys - sys.path.insert(0, '/app') - from data.npcs import NPCS - - # Get active combat - combat = await db.get_active_combat(current_user['id']) - if not combat: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Not in combat" - ) - - if combat['turn'] != 'player': - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Not your turn" - ) - - # Get player and NPC data - player = current_user # current_user is already the character dict - npc_def = NPCS.get(combat['npc_id']) - - result_message = "" - combat_over = False - player_won = False - - if req.action == 'attack': - # Calculate player damage - base_damage = 5 - strength_bonus = player['strength'] // 2 - level_bonus = player['level'] - weapon_damage = 0 - weapon_effects = {} - weapon_inv_id = None - - # Check for equipped weapon - equipment = await db.get_all_equipment(player['id']) - if equipment.get('weapon') and equipment['weapon']: - weapon_slot = equipment['weapon'] - inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id']) - if inv_item: - weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - if weapon_def and weapon_def.stats: - weapon_damage = random.randint( - weapon_def.stats.get('damage_min', 0), - weapon_def.stats.get('damage_max', 0) - ) - weapon_effects = weapon_def.weapon_effects if hasattr(weapon_def, 'weapon_effects') else {} - weapon_inv_id = weapon_slot['item_id'] - - # Check encumbrance penalty (higher encumbrance = chance to miss) - encumbrance = player.get('encumbrance', 0) - attack_failed = False - if encumbrance > 0: - miss_chance = min(0.3, encumbrance * 0.05) # Max 30% miss chance - if random.random() < miss_chance: - attack_failed = True - - variance = random.randint(-2, 2) - damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance) - - if attack_failed: - result_message = f"Your attack misses due to heavy encumbrance! " - new_npc_hp = combat['npc_hp'] - else: - # Apply damage to NPC - new_npc_hp = max(0, combat['npc_hp'] - damage) - result_message = f"You attack for {damage} damage! " - - # Apply weapon effects - if weapon_effects and 'bleeding' in weapon_effects: - bleeding = weapon_effects['bleeding'] - if random.random() < bleeding.get('chance', 0): - # Apply bleeding effect (would need combat effects table, for now just bonus damage) - bleed_damage = bleeding.get('damage', 0) - new_npc_hp = max(0, new_npc_hp - bleed_damage) - result_message += f"๐Ÿ’‰ Bleeding effect! +{bleed_damage} damage! " - - # Decrease weapon durability (from unique_item) - if weapon_inv_id and inv_item.get('unique_item_id'): - new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1) - if new_durability is None: - # Weapon broke (unique_item was deleted, cascades to inventory) - result_message += "\nโš ๏ธ Your weapon broke! " - await db.unequip_item(player['id'], 'weapon') - - if new_npc_hp <= 0: - # NPC defeated - result_message += f"{npc_def.name} has been defeated!" - combat_over = True - player_won = True - - # Award XP - xp_gained = npc_def.xp_reward - new_xp = player['xp'] + xp_gained - result_message += f"\n+{xp_gained} XP" - - await db.update_player(player['id'], xp=new_xp) - - # Track kill statistics - await db.update_player_statistics(player['id'], enemies_killed=1, damage_dealt=damage, increment=True) - - # Check for level up - level_up_result = await game_logic.check_and_apply_level_up(player['id']) - if level_up_result['leveled_up']: - result_message += f"\n๐ŸŽ‰ Level Up! You are now level {level_up_result['new_level']}!" - result_message += f"\n+{level_up_result['levels_gained']} stat point(s) to spend!" - - # Create corpse with loot - import json - corpse_loot = npc_def.corpse_loot if hasattr(npc_def, 'corpse_loot') else [] - # Convert CorpseLoot objects to dicts - corpse_loot_dicts = [] - for loot in corpse_loot: - if hasattr(loot, '__dict__'): - corpse_loot_dicts.append({ - 'item_id': loot.item_id, - 'quantity_min': loot.quantity_min, - 'quantity_max': loot.quantity_max, - 'required_tool': loot.required_tool - }) - else: - corpse_loot_dicts.append(loot) - await db.create_npc_corpse( - npc_id=combat['npc_id'], - location_id=player['location_id'], - loot_remaining=json.dumps(corpse_loot_dicts) - ) - - await db.end_combat(player['id']) - - # Update Redis: Delete combat state cache - if redis_manager: - await redis_manager.delete_combat_state(player['id']) - # Update player session - await redis_manager.update_player_session_field(player['id'], 'xp', new_xp) - if level_up_result['leveled_up']: - await redis_manager.update_player_session_field(player['id'], 'level', level_up_result['new_level']) - - # Broadcast to location that combat ended and corpse appeared - await manager.send_to_location( - location_id=player['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} defeated {npc_def.name}", - "action": "combat_ended", - "player_id": player['id'], - "corpse_created": True - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=player['id'] - ) - - else: - # NPC's turn - use shared logic - npc_attack_message, player_defeated = await game_logic.npc_attack( - player['id'], - {'npc_hp': new_npc_hp, 'npc_max_hp': combat['npc_max_hp']}, - npc_def, - reduce_armor_durability - ) - result_message += f"\n{npc_attack_message}" - - if player_defeated: - combat_over = True - else: - # Update NPC HP (combat turn already updated by npc_attack) - await db.update_combat(player['id'], { - 'npc_hp': new_npc_hp - }) - - elif req.action == 'flee': - # 50% chance to flee - if random.random() < 0.5: - result_message = "You successfully fled from combat!" - combat_over = True - player_won = False # Fled, not won - - # Track successful flee - await db.update_player_statistics(player['id'], successful_flees=1, increment=True) - - # Respawn the enemy back to the location if it came from wandering - if combat.get('from_wandering_enemy'): - # Respawn enemy with current HP at the combat location - import time - despawn_time = time.time() + 300 # 5 minutes - async with db.DatabaseSession() as session: - from sqlalchemy import insert - stmt = insert(db.wandering_enemies).values( - npc_id=combat['npc_id'], - location_id=combat['location_id'], - spawn_timestamp=time.time(), - despawn_timestamp=despawn_time - ) - await session.execute(stmt) - await session.commit() - - await db.end_combat(player['id']) - - # Broadcast to location that player fled from combat - await manager.send_to_location( - location_id=combat['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} fled from combat", - "action": "combat_fled", - "player_id": player['id'] - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=player['id'] - ) - else: - # Failed to flee, NPC attacks - npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max) - new_player_hp = max(0, player['hp'] - npc_damage) - result_message = f"Failed to flee! {npc_def.name} attacks for {npc_damage} damage!" - - if new_player_hp <= 0: - result_message += "\nYou have been defeated!" - combat_over = True - await db.update_player(player['id'], hp=0, is_dead=True) - await db.update_player_statistics(player['id'], deaths=1, failed_flees=1, damage_taken=npc_damage, increment=True) - - # Create corpse with player's inventory - import json - import time as time_module - inventory = await db.get_inventory(player['id']) - inventory_items = [] - for inv_item in inventory: - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - inventory_items.append({ - 'item_id': inv_item['item_id'], - 'name': item_def.name if item_def else inv_item['item_id'], - 'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '๐Ÿ“ฆ', - 'quantity': inv_item['quantity'], - 'durability': inv_item.get('durability'), - 'max_durability': inv_item.get('max_durability'), - 'tier': inv_item.get('tier') - }) - - logger.info(f"Creating player corpse (failed flee) for {player['name']} at {combat['location_id']} with {len(inventory_items)} items") - - corpse_id = await db.create_player_corpse( - player_name=player['name'], - location_id=combat['location_id'], - items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) - ) - - logger.info(f"Successfully created player corpse (failed flee): ID={corpse_id}, player={player['name']}, location={combat['location_id']}, items_count={len(inventory_items)}") - - # Clear player's inventory (items are now in corpse) - await db.clear_inventory(player['id']) - - # Build corpse data for broadcast - corpse_data = { - "id": f"player_{corpse_id}", - "type": "player", - "name": f"{player['name']}'s Corpse", - "emoji": "โšฐ๏ธ", - "player_name": player['name'], - "loot_count": len(inventory_items), - "items": inventory_items, - "timestamp": time_module.time() - } - - # Respawn enemy if from wandering - if combat.get('from_wandering_enemy'): - import time - despawn_time = time.time() + 300 - async with db.DatabaseSession() as session: - from sqlalchemy import insert - stmt = insert(db.wandering_enemies).values( - npc_id=combat['npc_id'], - location_id=combat['location_id'], - spawn_timestamp=time.time(), - despawn_timestamp=despawn_time - ) - await session.execute(stmt) - await session.commit() - - await db.end_combat(player['id']) - - # Broadcast to location that player died and corpse appeared - logger.info(f"Broadcasting player_died (failed flee) to location {combat['location_id']} for player {player['name']}") - await manager.send_to_location( - location_id=combat['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} was defeated in combat", - "action": "player_died", - "player_id": player['id'], - "corpse": corpse_data - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=player['id'] - ) - else: - # Player survived, update HP and turn back to player - await db.update_player(player['id'], hp=new_player_hp) - await db.update_player_statistics(player['id'], failed_flees=1, damage_taken=npc_damage, increment=True) - await db.update_combat(player['id'], {'turn': 'player'}) - - # Get updated combat state if not over - updated_combat = None - if not combat_over: - raw_combat = await db.get_active_combat(current_user['id']) - if raw_combat: - updated_combat = { - "npc_id": raw_combat['npc_id'], - "npc_name": npc_def.name, - "npc_hp": raw_combat['npc_hp'], - "npc_max_hp": raw_combat['npc_max_hp'], - "npc_image": f"/images/npcs/{raw_combat['npc_id']}.png", - "turn": raw_combat['turn'] - } - - # Get fresh player data with updated HP after NPC attack - updated_player = await db.get_player_by_id(current_user['id']) - if not updated_player: - updated_player = current_user # Fallback to current_user if something went wrong - - # Broadcast combat update via WebSocket - await manager.send_personal_message(current_user['id'], { - "type": "combat_update", - "data": { - "message": result_message, - "log_entry": result_message, # This should be APPENDED to combat log, not replace it - "combat_over": combat_over, - "player_won": player_won if combat_over else None, - "combat": updated_combat, - "player": { - "hp": updated_player['hp'], - "xp": updated_player['xp'], - "level": updated_player['level'] - } - }, - "timestamp": datetime.utcnow().isoformat() - }) - - return { - "success": True, - "message": result_message, - "combat_over": combat_over, - "player_won": player_won if combat_over else None, - "combat": updated_combat if updated_combat else None - } - - -# ============================================================================ -# PvP Combat Endpoints -# ============================================================================ - -class PvPCombatInitiateRequest(BaseModel): - target_player_id: int - - -@app.post("/api/game/pvp/initiate") -async def initiate_pvp_combat( - req: PvPCombatInitiateRequest, - current_user: dict = Depends(get_current_user) -): - """Initiate PvP combat with another player""" - # Get attacker (current user) - attacker = await db.get_player_by_id(current_user['id']) - if not attacker: - raise HTTPException(status_code=404, detail="Player not found") - - # Check if attacker is already in combat - existing_combat = await db.get_active_combat(attacker['id']) - if existing_combat: - raise HTTPException(status_code=400, detail="You are already in PvE combat") - - existing_pvp = await db.get_pvp_combat_by_player(attacker['id']) - if existing_pvp: - raise HTTPException(status_code=400, detail="You are already in PvP combat") - - # Get defender (target player) - defender = await db.get_player_by_id(req.target_player_id) - if not defender: - raise HTTPException(status_code=404, detail="Target player not found") - - # Check if defender is in combat - defender_pve = await db.get_active_combat(defender['id']) - if defender_pve: - raise HTTPException(status_code=400, detail="Target player is in PvE combat") - - defender_pvp = await db.get_pvp_combat_by_player(defender['id']) - if defender_pvp: - raise HTTPException(status_code=400, detail="Target player is in PvP combat") - - # Check same location - if attacker['location_id'] != defender['location_id']: - raise HTTPException(status_code=400, detail="Target player is not in your location") - - # Check danger level (>= 3 required for PvP) - location = LOCATIONS.get(attacker['location_id']) - if not location or location.danger_level < 3: - raise HTTPException(status_code=400, detail="PvP combat is only allowed in dangerous zones (danger level >= 3)") - - # Check level difference (+/- 3 levels) - level_diff = abs(attacker['level'] - defender['level']) - if level_diff > 3: - raise HTTPException( - status_code=400, - detail=f"Level difference too large! You can only fight players within 3 levels (target is level {defender['level']})" - ) - - # Create PvP combat - pvp_combat = await db.create_pvp_combat( - attacker_id=attacker['id'], - defender_id=defender['id'], - location_id=attacker['location_id'], - turn_timeout=300 # 5 minutes - ) - - # Track PvP combat initiation - await db.update_player_statistics(attacker['id'], pvp_combats_initiated=1, increment=True) - - # Send WebSocket notifications to both players - await manager.send_personal_message(attacker['id'], { - "type": "combat_started", - "data": { - "message": f"You have initiated combat with {defender['name']}! They get the first turn.", - "pvp_combat": pvp_combat - }, - "timestamp": datetime.utcnow().isoformat() - }) - - await manager.send_personal_message(defender['id'], { - "type": "combat_started", - "data": { - "message": f"{attacker['name']} has challenged you to PvP combat! It's your turn.", - "pvp_combat": pvp_combat - }, - "timestamp": datetime.utcnow().isoformat() - }) - - return { - "success": True, - "message": f"You have initiated combat with {defender['name']}! They get the first turn.", - "pvp_combat": pvp_combat - } - - -@app.get("/api/game/pvp/status") -async def get_pvp_combat_status(current_user: dict = Depends(get_current_user)): - """Get current PvP combat status""" - pvp_combat = await db.get_pvp_combat_by_player(current_user['id']) - if not pvp_combat: - return {"in_pvp_combat": False, "pvp_combat": None} - - # Check if current player has already acknowledged - if so, don't show combat anymore - is_attacker = pvp_combat['attacker_character_id'] == current_user['id'] - if (is_attacker and pvp_combat.get('attacker_acknowledged', False)) or \ - (not is_attacker and pvp_combat.get('defender_acknowledged', False)): - return {"in_pvp_combat": False, "pvp_combat": None} - - # Get both players' data - attacker = await db.get_player_by_id(pvp_combat['attacker_character_id']) - defender = await db.get_player_by_id(pvp_combat['defender_character_id']) - - # Determine if current user is attacker or defender - is_attacker = pvp_combat['attacker_character_id'] == current_user['id'] - your_turn = (is_attacker and pvp_combat['turn'] == 'attacker') or \ - (not is_attacker and pvp_combat['turn'] == 'defender') - - # Calculate time remaining for turn - import time - time_elapsed = time.time() - pvp_combat['turn_started_at'] - time_remaining = max(0, pvp_combat['turn_timeout_seconds'] - time_elapsed) - - # Auto-advance if time expired - if time_remaining == 0 and your_turn: - # Skip turn - new_turn = 'defender' if is_attacker else 'attacker' - await db.update_pvp_combat(pvp_combat['id'], { - 'turn': new_turn, - 'turn_started_at': time.time() - }) - pvp_combat = await db.get_pvp_combat_by_id(pvp_combat['id']) - your_turn = False - time_remaining = pvp_combat['turn_timeout_seconds'] - - return { - "in_pvp_combat": True, - "pvp_combat": { - "id": pvp_combat['id'], - "attacker": { - "id": attacker['id'], - "username": attacker['name'], - "level": attacker['level'], - "hp": attacker['hp'], # Use actual player HP - "max_hp": attacker['max_hp'] - }, - "defender": { - "id": defender['id'], - "username": defender['name'], - "level": defender['level'], - "hp": defender['hp'], # Use actual player HP - "max_hp": defender['max_hp'] - }, - "is_attacker": is_attacker, - "your_turn": your_turn, - "current_turn": pvp_combat['turn'], - "time_remaining": int(time_remaining), - "location_id": pvp_combat['location_id'], - "last_action": pvp_combat.get('last_action'), - "combat_over": pvp_combat.get('attacker_fled', False) or pvp_combat.get('defender_fled', False) or \ - attacker['hp'] <= 0 or defender['hp'] <= 0, - "attacker_fled": pvp_combat.get('attacker_fled', False), - "defender_fled": pvp_combat.get('defender_fled', False) - } - } - - -class PvPAcknowledgeRequest(BaseModel): - combat_id: int - - -@app.post("/api/game/pvp/acknowledge") -async def acknowledge_pvp_combat( - req: PvPAcknowledgeRequest, - current_user: dict = Depends(get_current_user) -): - """Acknowledge PvP combat end""" - await db.acknowledge_pvp_combat(req.combat_id, current_user['id']) - - # Broadcast to location that player has returned - player = current_user # current_user is already the character dict - if player: - await manager.send_to_location( - location_id=player['location_id'], - message={ - "type": "player_arrived", - "data": { - "player_id": player['id'], - "username": player['name'], - "message": f"{player['name']} has returned from PvP combat." - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=player['id'] - ) - - return {"success": True} - - -class PvPCombatActionRequest(BaseModel): - action: str # 'attack', 'flee', 'use_item' - item_id: Optional[str] = None # For use_item action - - -@app.post("/api/game/pvp/action") -async def pvp_combat_action( - req: PvPCombatActionRequest, - current_user: dict = Depends(get_current_user) -): - """Perform a PvP combat action""" - import random - import time - - # Get PvP combat - pvp_combat = await db.get_pvp_combat_by_player(current_user['id']) - if not pvp_combat: - raise HTTPException(status_code=400, detail="Not in PvP combat") - - # Determine roles - is_attacker = pvp_combat['attacker_character_id'] == current_user['id'] - your_turn = (is_attacker and pvp_combat['turn'] == 'attacker') or \ - (not is_attacker and pvp_combat['turn'] == 'defender') - - if not your_turn: - raise HTTPException(status_code=400, detail="It's not your turn") - - # Get both players - attacker = await db.get_player_by_id(pvp_combat['attacker_character_id']) - defender = await db.get_player_by_id(pvp_combat['defender_character_id']) - current_player = attacker if is_attacker else defender - opponent = defender if is_attacker else attacker - - result_message = "" - combat_over = False - winner_id = None - - if req.action == 'attack': - # Calculate damage (similar to PvE) - base_damage = 5 - strength_bonus = current_player['strength'] * 2 - level_bonus = current_player['level'] - - # Check for equipped weapon - weapon_damage = 0 - equipment = await db.get_all_equipment(current_player['id']) - if equipment.get('weapon') and equipment['weapon']: - weapon_slot = equipment['weapon'] - inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id']) - if inv_item: - weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - if weapon_def and weapon_def.stats: - weapon_damage = random.randint( - weapon_def.stats.get('damage_min', 0), - weapon_def.stats.get('damage_max', 0) - ) - # Decrease weapon durability - if inv_item.get('unique_item_id'): - new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1) - if new_durability is None: - result_message += "โš ๏ธ Your weapon broke! " - await db.unequip_item(current_player['id'], 'weapon') - - variance = random.randint(-2, 2) - damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance) - - # Apply armor reduction and durability loss to opponent - armor_absorbed, broken_armor = await reduce_armor_durability(opponent['id'], damage) - actual_damage = max(1, damage - armor_absorbed) - - # Update opponent HP (use actual player HP, not pvp_combat fields) - new_opponent_hp = max(0, opponent['hp'] - actual_damage) - - # Update opponent's HP in database - await db.update_player(opponent['id'], hp=new_opponent_hp) - - # Store message with attacker's username so both players can see it correctly - stored_message = f"{current_player['name']} attacks {opponent['name']} for {damage} damage!" - if armor_absorbed > 0: - stored_message += f" (Armor absorbed {armor_absorbed})" - - for broken in broken_armor: - stored_message += f"\n๐Ÿ’” {opponent['name']}'s {broken['emoji']} {broken['name']} broke!" - - # Check if opponent defeated - if new_opponent_hp <= 0: - stored_message += f"\n๐Ÿ† {current_player['name']} has defeated {opponent['name']}!" - result_message = "Combat victory!" # Simple message, details in stored_message - combat_over = True - winner_id = current_player['id'] - - # Update opponent to dead state - await db.update_player(opponent['id'], hp=0, is_dead=True) - - # Create corpse with opponent's inventory - import json - import time as time_module - inventory = await db.get_inventory(opponent['id']) - inventory_items = [] - for inv_item in inventory: - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - inventory_items.append({ - 'item_id': inv_item['item_id'], - 'name': item_def.name if item_def else inv_item['item_id'], - 'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '๐Ÿ“ฆ', - 'quantity': inv_item['quantity'], - 'durability': inv_item.get('durability'), - 'max_durability': inv_item.get('max_durability'), - 'tier': inv_item.get('tier') - }) - - logger.info(f"Creating player corpse (PvP death) for {opponent['name']} at {opponent['location_id']} with {len(inventory_items)} items") - - corpse_id = await db.create_player_corpse( - player_name=opponent['name'], - location_id=opponent['location_id'], - items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) - ) - - logger.info(f"Successfully created player corpse (PvP death): ID={corpse_id}, player={opponent['name']}, location={opponent['location_id']}, items_count={len(inventory_items)}") - - # Clear opponent's inventory (items are now in corpse) - await db.clear_inventory(opponent['id']) - - # Build corpse data for broadcast - corpse_data = { - "id": f"player_{corpse_id}", - "type": "player", - "name": f"{opponent['name']}'s Corpse", - "emoji": "โšฐ๏ธ", - "player_name": opponent['name'], - "loot_count": len(inventory_items), - "items": inventory_items, - "timestamp": time_module.time() - } - - # Update PvP statistics for both players - await db.update_player_statistics(opponent['id'], - pvp_deaths=1, - pvp_combats_lost=1, - pvp_damage_taken=actual_damage, - pvp_attacks_received=1, - increment=True - ) - await db.update_player_statistics(current_player['id'], - players_killed=1, - pvp_combats_won=1, - pvp_damage_dealt=damage, - pvp_attacks_landed=1, - increment=True - ) - - # Broadcast to location that player died and corpse appeared - logger.info(f"Broadcasting player_died (PvP death) to location {opponent['location_id']} for player {opponent['name']}") - await manager.send_to_location( - location_id=opponent['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{opponent['name']} was defeated by {current_player['name']} in PvP combat", - "action": "player_died", - "player_id": opponent['id'], - "corpse": corpse_data - }, - "timestamp": datetime.utcnow().isoformat() - } - ) - - # End PvP combat - await db.end_pvp_combat(pvp_combat['id']) - else: - # Combat continues - don't return detailed message, it's in stored_message - result_message = "" # Empty message, frontend will show stored_message from polling - - # Update PvP statistics for attack - await db.update_player_statistics(current_player['id'], - pvp_damage_dealt=damage, - pvp_attacks_landed=1, - increment=True - ) - await db.update_player_statistics(opponent['id'], - pvp_damage_taken=actual_damage, - pvp_attacks_received=1, - increment=True - ) - - # Update combat state and switch turns - # Add timestamp to make each action unique for duplicate detection - updates = { - 'turn': 'defender' if is_attacker else 'attacker', - 'turn_started_at': time.time(), - 'last_action': f"{stored_message}|{time.time()}" # Add timestamp for uniqueness - } - # No need to update HP in pvp_combat - we use player HP directly - - await db.update_pvp_combat(pvp_combat['id'], updates) - await db.update_player_statistics(current_player['id'], damage_dealt=damage, increment=True) - - elif req.action == 'flee': - # 50% chance to flee from PvP - if random.random() < 0.5: - result_message = f"You successfully fled from {opponent['name']}!" - combat_over = True - - # Mark as fled, store last action with timestamp, and end combat - flee_field = 'attacker_fled' if is_attacker else 'defender_fled' - await db.update_pvp_combat(pvp_combat['id'], { - flee_field: True, - 'last_action': f"{current_player['name']} fled from combat!|{time.time()}" - }) - await db.end_pvp_combat(pvp_combat['id']) - await db.update_player_statistics(current_player['id'], - pvp_successful_flees=1, - increment=True - ) - else: - # Failed to flee, skip turn - result_message = f"Failed to flee from {opponent['name']}!" - await db.update_pvp_combat(pvp_combat['id'], { - 'turn': 'defender' if is_attacker else 'attacker', - 'turn_started_at': time.time(), - 'last_action': f"{current_player['name']} tried to flee but failed!|{time.time()}" - }) - await db.update_player_statistics(current_player['id'], - pvp_failed_flees=1, - increment=True - ) - - # Send WebSocket combat updates to both players - # Get fresh PvP combat data - updated_pvp = await db.get_pvp_combat_by_id(pvp_combat['id']) - - # Get fresh player data for HP updates - fresh_attacker = await db.get_player_by_id(pvp_combat['attacker_character_id']) - fresh_defender = await db.get_player_by_id(pvp_combat['defender_character_id']) - - # Send to both players with enriched data (like the API endpoint does) - for player_id in [pvp_combat['attacker_character_id'], pvp_combat['defender_character_id']]: - is_attacker = player_id == pvp_combat['attacker_character_id'] - your_turn = (is_attacker and updated_pvp['turn'] == 'attacker') or \ - (not is_attacker and updated_pvp['turn'] == 'defender') - - # Calculate time remaining - import time - time_elapsed = time.time() - updated_pvp['turn_started_at'] - time_remaining = max(0, updated_pvp['turn_timeout_seconds'] - time_elapsed) - - # Build enriched pvp_combat object like the API does - enriched_pvp = { - "id": updated_pvp['id'], - "attacker": { - "id": fresh_attacker['id'], - "username": fresh_attacker['name'], - "level": fresh_attacker['level'], - "hp": fresh_attacker['hp'], - "max_hp": fresh_attacker['max_hp'] - }, - "defender": { - "id": fresh_defender['id'], - "username": fresh_defender['name'], - "level": fresh_defender['level'], - "hp": fresh_defender['hp'], - "max_hp": fresh_defender['max_hp'] - }, - "is_attacker": is_attacker, - "your_turn": your_turn, - "current_turn": updated_pvp['turn'], - "time_remaining": int(time_remaining), - "location_id": updated_pvp['location_id'], - "last_action": updated_pvp.get('last_action'), - "combat_over": updated_pvp.get('attacker_fled', False) or updated_pvp.get('defender_fled', False) or \ - fresh_attacker['hp'] <= 0 or fresh_defender['hp'] <= 0, - "attacker_fled": updated_pvp.get('attacker_fled', False), - "defender_fled": updated_pvp.get('defender_fled', False) - } - - await manager.send_personal_message(player_id, { - "type": "combat_update", - "data": { - "message": result_message if player_id == current_user['id'] else "", - "log_entry": result_message if player_id == current_user['id'] else "", # Append to combat log - "pvp_combat": enriched_pvp, - "combat_over": combat_over, - "winner_id": winner_id, - "attacker_hp": fresh_attacker['hp'], - "defender_hp": fresh_defender['hp'] - }, - "timestamp": datetime.utcnow().isoformat() - }) - - return { - "success": True, - "message": result_message, - "combat_over": combat_over, - "winner_id": winner_id - } - - -@app.get("/api/game/inventory") -async def get_inventory(current_user: dict = Depends(get_current_user)): - """Get player inventory""" - inventory = await db.get_inventory(current_user['id']) - - # Enrich with item data - inventory_items = [] - for inv_item in inventory: - item = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item: - item_data = { - "id": inv_item['id'], - "item_id": item.id, - "name": item.name, - "description": item.description, - "type": item.type, - "quantity": inv_item['quantity'], - "is_equipped": inv_item['is_equipped'], - "equippable": item.equippable, - "consumable": item.consumable, - "image_path": item.image_path, - "emoji": item.emoji if hasattr(item, 'emoji') else None, - "weight": item.weight if hasattr(item, 'weight') else 0, - "volume": item.volume if hasattr(item, 'volume') else 0, - "uncraftable": getattr(item, 'uncraftable', False), - "inventory_id": inv_item['id'], - "unique_item_id": inv_item.get('unique_item_id') - } - # Add combat/consumable stats if they exist - if hasattr(item, 'hp_restore'): - item_data["hp_restore"] = item.hp_restore - if hasattr(item, 'stamina_restore'): - item_data["stamina_restore"] = item.stamina_restore - if hasattr(item, 'damage_min'): - item_data["damage_min"] = item.damage_min - if hasattr(item, 'damage_max'): - item_data["damage_max"] = item.damage_max - - # Add tier if unique item - if inv_item.get('unique_item_id'): - unique_item = await db.get_unique_item(inv_item['unique_item_id']) - if unique_item: - item_data["tier"] = unique_item.get('tier', 1) - item_data["durability"] = unique_item.get('durability', 0) - item_data["max_durability"] = unique_item.get('max_durability', 100) - - # Add uncraft data if uncraftable - if getattr(item, 'uncraftable', False): - uncraft_yield = getattr(item, 'uncraft_yield', []) - uncraft_tools = getattr(item, 'uncraft_tools', []) - - # Format materials - yield_materials = [] - for mat in uncraft_yield: - mat_def = ITEMS_MANAGER.get_item(mat['item_id']) - yield_materials.append({ - 'item_id': mat['item_id'], - 'name': mat_def.name if mat_def else mat['item_id'], - 'emoji': mat_def.emoji if mat_def else '๐Ÿ“ฆ', - 'quantity': mat['quantity'] - }) - - # Check tools availability - tools_info = [] - can_uncraft = True - for tool_req in uncraft_tools: - tool_id = tool_req['item_id'] - durability_cost = tool_req['durability_cost'] - tool_def = ITEMS_MANAGER.get_item(tool_id) - - # Check if player has this tool - tool_found = False - tool_durability = 0 - for check_item in inventory: - if check_item['item_id'] == tool_id and check_item.get('unique_item_id'): - unique = await db.get_unique_item(check_item['unique_item_id']) - if unique and unique.get('durability', 0) >= durability_cost: - tool_found = True - tool_durability = unique.get('durability', 0) - break - - tools_info.append({ - 'item_id': tool_id, - 'name': tool_def.name if tool_def else tool_id, - 'emoji': tool_def.emoji if tool_def else '๐Ÿ”ง', - 'durability_cost': durability_cost, - 'has_tool': tool_found, - 'tool_durability': tool_durability - }) - if not tool_found: - can_uncraft = False - - item_data["uncraft_yield"] = yield_materials - item_data["uncraft_loss_chance"] = getattr(item, 'uncraft_loss_chance', 0.3) - item_data["uncraft_tools"] = tools_info - item_data["can_uncraft"] = can_uncraft - - inventory_items.append(item_data) - - return {"items": inventory_items} - - -@app.post("/api/game/item/drop") -async def drop_item( - drop_req: dict, - current_user: dict = Depends(get_current_user) -): - """Drop an item from inventory""" - player_id = current_user['id'] - item_id = drop_req.get('item_id') # This is the item_id string like "energy_bar" - quantity = drop_req.get('quantity', 1) - - # Get player to know their location - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - # Get inventory item by item_id (string), not database id - inventory = await db.get_inventory(player_id) - inv_item = None - for item in inventory: - if item['item_id'] == item_id: - inv_item = item - break - - if not inv_item: - raise HTTPException(status_code=404, detail="Item not found in inventory") - - if inv_item['quantity'] < quantity: - raise HTTPException(status_code=400, detail="Not enough items to drop") - - # For unique items, we need to handle each one individually - if inv_item.get('unique_item_id'): - # This is a unique item - drop it and remove from inventory by row ID - await db.add_dropped_item( - player['location_id'], - inv_item['item_id'], - 1, - unique_item_id=inv_item['unique_item_id'] - ) - # Remove this specific inventory row (not by item_id, by row id) - await db.remove_inventory_row(inv_item['id']) - else: - # Stackable item - drop the quantity requested - await db.add_dropped_item( - player['location_id'], - inv_item['item_id'], - quantity, - unique_item_id=None - ) - # Remove from inventory (handles quantity reduction automatically) - await db.remove_item_from_inventory(player_id, inv_item['item_id'], quantity) - - # Track drop statistics - await db.update_player_statistics(player_id, items_dropped=quantity, increment=True) - - # Invalidate inventory cache - if redis_manager: - await redis_manager.invalidate_inventory(player_id) - - # Get item details for broadcast - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - - # Broadcast to location that item was dropped - await manager.send_to_location( - location_id=player['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} dropped {item_def.emoji} {item_def.name} x{quantity}", - "action": "item_dropped" - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=player_id - ) - - return { - "success": True, - "message": f"Dropped {item_def.emoji} {item_def.name} x{quantity}" - } - - -# ============================================================================ -# Internal API Endpoints (for bot communication) -# ============================================================================ - -async def verify_internal_key(authorization: str = Depends(security)): - """Verify internal API key""" - if authorization.credentials != API_INTERNAL_KEY: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid internal API key" - ) - return True - - -@app.get("/api/internal/player/by_id/{player_id}", dependencies=[Depends(verify_internal_key)]) -async def get_player_by_id(player_id: int): - """Get player by unique database ID (for bot)""" - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Player not found" - ) - return player - - -@app.get("/api/internal/player/{player_id}/combat", dependencies=[Depends(verify_internal_key)]) -async def get_player_combat(player_id: int): - """Get active combat for player (for bot)""" - combat = await db.get_active_combat(player_id) - return combat if combat else None - - -@app.post("/api/internal/combat/create", dependencies=[Depends(verify_internal_key)]) -async def create_combat(player_id: int, npc_id: str, npc_hp: int, npc_max_hp: int, location_id: str, from_wandering: bool = False): - """Create new combat (for bot)""" - combat = await db.create_combat(player_id, npc_id, npc_hp, npc_max_hp, location_id, from_wandering) - return combat - - -@app.patch("/api/internal/combat/{player_id}", dependencies=[Depends(verify_internal_key)]) -async def update_combat(player_id: int, updates: dict): - """Update combat state (for bot)""" - success = await db.update_combat(player_id, updates) - return {"success": success} - - -@app.delete("/api/internal/combat/{player_id}", dependencies=[Depends(verify_internal_key)]) -async def end_combat(player_id: int): - """End combat (for bot)""" - success = await db.end_combat(player_id) - return {"success": success} - - -@app.patch("/api/internal/player/{player_id}", dependencies=[Depends(verify_internal_key)]) -async def update_player(player_id: int, updates: dict): - """Update player fields (for bot)""" - success = await db.update_player(player_id, updates) - if not success: - raise HTTPException(status_code=404, detail="Player not found") - - # Return updated player - player = await db.get_player_by_id(player_id) - return player - - -@app.post("/api/internal/player/{player_id}/move", dependencies=[Depends(verify_internal_key)]) -async def bot_move_player(player_id: int, direction: str): - """Move player (for bot)""" - success, message, new_location_id, stamina_cost, distance = await game_logic.move_player( - player_id, - direction, - LOCATIONS - ) - - # Track distance for bot players too - if success: - await db.update_player_statistics(player_id, distance_walked=distance, increment=True) - - return { - "success": success, - "message": message, - "new_location_id": new_location_id - } - - -@app.get("/api/internal/player/{player_id}/inspect", dependencies=[Depends(verify_internal_key)]) -async def bot_inspect_area(player_id: int): - """Inspect area (for bot)""" - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - location = LOCATIONS.get(player['location_id']) - if not location: - raise HTTPException(status_code=404, detail="Location not found") - - message = await game_logic.inspect_area(player_id, location, {}) - return {"success": True, "message": message} - - -@app.post("/api/internal/player/{player_id}/interact", dependencies=[Depends(verify_internal_key)]) -async def bot_interact(player_id: int, interactable_id: str, action_id: str): - """Interact with object (for bot)""" - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - location = LOCATIONS.get(player['location_id']) - if not location: - raise HTTPException(status_code=404, detail="Location not found") - - result = await game_logic.interact_with_object( - player_id, - interactable_id, - action_id, - location, - ITEMS_MANAGER - ) - return result - - -@app.get("/api/internal/player/{player_id}/inventory", dependencies=[Depends(verify_internal_key)]) -async def bot_get_inventory(player_id: int): - """Get inventory (for bot)""" - inventory = await db.get_inventory(player_id) - - # Enrich with item data (include all properties for bot compatibility) - inventory_items = [] - for inv_item in inventory: - item = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item: - inventory_items.append({ - "id": inv_item['id'], - "item_id": item.id, - "name": item.name, - "description": item.description, - "type": item.type, - "quantity": inv_item['quantity'], - "is_equipped": inv_item['is_equipped'], - "equippable": item.equippable, - "consumable": item.consumable, - "weight": getattr(item, 'weight', 0), - "volume": getattr(item, 'volume', 0), - "emoji": getattr(item, 'emoji', 'โ”'), - "damage_min": getattr(item, 'damage_min', 0), - "damage_max": getattr(item, 'damage_max', 0), - "hp_restore": getattr(item, 'hp_restore', 0), - "stamina_restore": getattr(item, 'stamina_restore', 0), - "treats": getattr(item, 'treats', None) - }) - - return {"success": True, "inventory": inventory_items} - - -@app.post("/api/internal/player/{player_id}/use_item", dependencies=[Depends(verify_internal_key)]) -async def bot_use_item(player_id: int, item_id: str): - """Use item (for bot)""" - result = await game_logic.use_item(player_id, item_id, ITEMS_MANAGER) - return result - - -@app.post("/api/internal/player/{player_id}/pickup", dependencies=[Depends(verify_internal_key)]) -async def bot_pickup_item(player_id: int, item_id: str): - """Pick up item (for bot)""" - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - result = await game_logic.pickup_item(player_id, item_id, player['location_id']) - return result - - -@app.post("/api/internal/player/{player_id}/drop_item", dependencies=[Depends(verify_internal_key)]) -async def bot_drop_item(player_id: int, item_id: str, quantity: int = 1): - """Drop item (for bot)""" - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - # Get the item from inventory - inventory = await db.get_inventory(player_id) - inv_item = next((i for i in inventory if i['item_id'] == item_id), None) - - if not inv_item or inv_item['quantity'] < quantity: - return {"success": False, "message": "You don't have that item"} - - # Remove from inventory - await db.remove_item_from_inventory(player_id, item_id, quantity) - - # Add to dropped items - await db.add_dropped_item(player['location_id'], item_id, quantity) - - item = ITEMS_MANAGER.get_item(item_id) - item_name = item.name if item else item_id - - return { - "success": True, - "message": f"You dropped {quantity}x {item_name}" - } - - -@app.post("/api/internal/player/{player_id}/equip", dependencies=[Depends(verify_internal_key)]) -async def bot_equip_item(player_id: int, item_id: str): - """Equip item (for bot)""" - # Get item info - item = ITEMS_MANAGER.get_item(item_id) - if not item or not item.equippable: - return {"success": False, "message": "This item cannot be equipped"} - - # Check inventory - inventory = await db.get_inventory(player_id) - inv_item = next((i for i in inventory if i['item_id'] == item_id), None) - - if not inv_item: - return {"success": False, "message": "You don't have this item"} - - if inv_item['is_equipped']: - return {"success": False, "message": "This item is already equipped"} - - # Unequip any item of the same type - for inv in inventory: - if inv['is_equipped']: - existing_item = ITEMS_MANAGER.get_item(inv['item_id']) - if existing_item and existing_item.type == item.type: - await db.update_item_equipped_status(player_id, inv['item_id'], False) - - # Equip the new item - await db.update_item_equipped_status(player_id, item_id, True) - - return {"success": True, "message": f"You equipped {item.name}"} - - -@app.post("/api/internal/player/{player_id}/unequip", dependencies=[Depends(verify_internal_key)]) -async def bot_unequip_item(player_id: int, item_id: str): - """Unequip item (for bot)""" - # Check inventory - inventory = await db.get_inventory(player_id) - inv_item = next((i for i in inventory if i['item_id'] == item_id), None) - - if not inv_item: - return {"success": False, "message": "You don't have this item"} - - if not inv_item['is_equipped']: - return {"success": False, "message": "This item is not equipped"} - - # Unequip the item - await db.update_item_equipped_status(player_id, item_id, False) - - item = ITEMS_MANAGER.get_item(item_id) - item_name = item.name if item else item_id - - return {"success": True, "message": f"You unequipped {item_name}"} - - -# ============================================================================ -# Dropped Items (Internal Bot API) -# ============================================================================ - -@app.post("/api/internal/dropped-items", dependencies=[Depends(verify_internal_key)]) -async def drop_item(item_id: str, quantity: int, location_id: str): - """Drop an item to the world (for bot)""" - success = await db.drop_item_to_world(item_id, quantity, location_id) - return {"success": success} - - -@app.get("/api/internal/dropped-items/{dropped_item_id}", dependencies=[Depends(verify_internal_key)]) -async def get_dropped_item(dropped_item_id: int): - """Get a specific dropped item (for bot)""" - item = await db.get_dropped_item(dropped_item_id) - if not item: - raise HTTPException(status_code=404, detail="Dropped item not found") - return item - - -@app.get("/api/internal/location/{location_id}/dropped-items", dependencies=[Depends(verify_internal_key)]) -async def get_dropped_items_in_location(location_id: str): - """Get all dropped items in a location (for bot)""" - items = await db.get_dropped_items_in_location(location_id) - return items - - -@app.patch("/api/internal/dropped-items/{dropped_item_id}", dependencies=[Depends(verify_internal_key)]) -async def update_dropped_item(dropped_item_id: int, quantity: int): - """Update dropped item quantity (for bot)""" - success = await db.update_dropped_item(dropped_item_id, quantity) - if not success: - raise HTTPException(status_code=404, detail="Dropped item not found") - return {"success": success} - - -@app.delete("/api/internal/dropped-items/{dropped_item_id}", dependencies=[Depends(verify_internal_key)]) -async def remove_dropped_item(dropped_item_id: int): - """Remove a dropped item (for bot)""" - success = await db.remove_dropped_item(dropped_item_id) - return {"success": success} - - -# ============================================================================ -# Corpses (Internal Bot API) -# ============================================================================ - -@app.post("/api/internal/corpses/player", dependencies=[Depends(verify_internal_key)]) -async def create_player_corpse(player_name: str, location_id: str, items: str): - """Create a player corpse (for bot)""" - corpse_id = await db.create_player_corpse(player_name, location_id, items) - return {"corpse_id": corpse_id} - - -@app.get("/api/internal/corpses/player/{corpse_id}", dependencies=[Depends(verify_internal_key)]) -async def get_player_corpse(corpse_id: int): - """Get a player corpse (for bot)""" - corpse = await db.get_player_corpse(corpse_id) - if not corpse: - raise HTTPException(status_code=404, detail="Player corpse not found") - return corpse - - -@app.patch("/api/internal/corpses/player/{corpse_id}", dependencies=[Depends(verify_internal_key)]) -async def update_player_corpse(corpse_id: int, items: str): - """Update player corpse items (for bot)""" - success = await db.update_player_corpse(corpse_id, items) - if not success: - raise HTTPException(status_code=404, detail="Player corpse not found") - return {"success": success} - - -@app.delete("/api/internal/corpses/player/{corpse_id}", dependencies=[Depends(verify_internal_key)]) -async def remove_player_corpse(corpse_id: int): - """Remove a player corpse (for bot)""" - success = await db.remove_player_corpse(corpse_id) - return {"success": success} - - -@app.post("/api/internal/corpses/npc", dependencies=[Depends(verify_internal_key)]) -async def create_npc_corpse(npc_id: str, location_id: str, loot_remaining: str): - """Create an NPC corpse (for bot)""" - corpse_id = await db.create_npc_corpse(npc_id, location_id, loot_remaining) - return {"corpse_id": corpse_id} - - -@app.get("/api/internal/corpses/npc/{corpse_id}", dependencies=[Depends(verify_internal_key)]) -async def get_npc_corpse(corpse_id: int): - """Get an NPC corpse (for bot)""" - corpse = await db.get_npc_corpse(corpse_id) - if not corpse: - raise HTTPException(status_code=404, detail="NPC corpse not found") - return corpse - - -@app.patch("/api/internal/corpses/npc/{corpse_id}", dependencies=[Depends(verify_internal_key)]) -async def update_npc_corpse(corpse_id: int, loot_remaining: str): - """Update NPC corpse loot (for bot)""" - success = await db.update_npc_corpse(corpse_id, loot_remaining) - if not success: - raise HTTPException(status_code=404, detail="NPC corpse not found") - return {"success": success} - - -@app.delete("/api/internal/corpses/npc/{corpse_id}", dependencies=[Depends(verify_internal_key)]) -async def remove_npc_corpse(corpse_id: int): - """Remove an NPC corpse (for bot)""" - success = await db.remove_npc_corpse(corpse_id) - return {"success": success} - - -# ============================================================================ -# Wandering Enemies (Internal Bot API) -# ============================================================================ - -@app.post("/api/internal/wandering-enemies", dependencies=[Depends(verify_internal_key)]) -async def spawn_wandering_enemy(npc_id: str, location_id: str, current_hp: int, max_hp: int): - """Spawn a wandering enemy (for bot)""" - enemy_id = await db.spawn_wandering_enemy(npc_id, location_id, current_hp, max_hp) - return {"enemy_id": enemy_id} - - -@app.get("/api/internal/location/{location_id}/wandering-enemies", dependencies=[Depends(verify_internal_key)]) -async def get_wandering_enemies_in_location(location_id: str): - """Get all wandering enemies in a location (for bot)""" - enemies = await db.get_wandering_enemies_in_location(location_id) - return enemies - - -@app.delete("/api/internal/wandering-enemies/{enemy_id}", dependencies=[Depends(verify_internal_key)]) -async def remove_wandering_enemy(enemy_id: int): - """Remove a wandering enemy (for bot)""" - success = await db.remove_wandering_enemy(enemy_id) - return {"success": success} - - -@app.get("/api/internal/inventory/item/{item_db_id}", dependencies=[Depends(verify_internal_key)]) -async def get_inventory_item(item_db_id: int): - """Get a specific inventory item by database ID (for bot)""" - item = await db.get_inventory_item(item_db_id) - if not item: - raise HTTPException(status_code=404, detail="Inventory item not found") - return item - - -# ============================================================================ -# Cooldowns (Internal Bot API) -# ============================================================================ - -@app.get("/api/internal/cooldown/{cooldown_key}", dependencies=[Depends(verify_internal_key)]) -async def get_cooldown(cooldown_key: str): - """Get remaining cooldown time in seconds (for bot)""" - remaining = await db.get_cooldown(cooldown_key) - return {"remaining_seconds": remaining} - - -@app.post("/api/internal/cooldown/{cooldown_key}", dependencies=[Depends(verify_internal_key)]) -async def set_cooldown(cooldown_key: str, duration_seconds: int = 600): - """Set a cooldown (for bot)""" - success = await db.set_cooldown(cooldown_key, duration_seconds) - return {"success": success} - - -# ============================================================================ -# Corpse Lists (Internal Bot API) -# ============================================================================ - -@app.get("/api/internal/location/{location_id}/corpses/player", dependencies=[Depends(verify_internal_key)]) -async def get_player_corpses_in_location(location_id: str): - """Get all player corpses in a location (for bot)""" - corpses = await db.get_player_corpses_in_location(location_id) - return corpses - - -@app.get("/api/internal/location/{location_id}/corpses/npc", dependencies=[Depends(verify_internal_key)]) -async def get_npc_corpses_in_location(location_id: str): - """Get all NPC corpses in a location (for bot)""" - corpses = await db.get_npc_corpses_in_location(location_id) - return corpses - - -# ============================================================================ -# Image Cache (Internal Bot API) -# ============================================================================ - -@app.get("/api/internal/image-cache/{image_path:path}", dependencies=[Depends(verify_internal_key)]) -async def get_cached_image(image_path: str): - """Get cached telegram file ID for an image (for bot)""" - file_id = await db.get_cached_image(image_path) - if not file_id: - raise HTTPException(status_code=404, detail="Image not cached") - return {"telegram_file_id": file_id} - - -@app.post("/api/internal/image-cache", dependencies=[Depends(verify_internal_key)]) -async def cache_image(image_path: str, telegram_file_id: str): - """Cache a telegram file ID for an image (for bot)""" - success = await db.cache_image(image_path, telegram_file_id) - return {"success": success} - - -# ============================================================================ -# Status Effects (Internal Bot API) -# ============================================================================ - -@app.get("/api/internal/player/{player_id}/status-effects", dependencies=[Depends(verify_internal_key)]) -async def get_player_status_effects(player_id: int): - """Get player status effects (for bot)""" - effects = await db.get_player_status_effects(player_id) - return effects - - -# ============================================================================ -# Statistics & Leaderboard Endpoints -# ============================================================================ - -@app.get("/api/statistics/{player_id}") -async def get_player_stats(player_id: int): - """Get character statistics by character ID (public)""" - stats = await db.get_player_statistics(player_id) - if not stats: - raise HTTPException(status_code=404, detail="Character statistics not found") - - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException(status_code=404, detail="Character not found") - - return { - "player": { - "id": player['id'], - "name": player['name'], - "level": player['level'] - }, - "statistics": stats - } - - -@app.get("/api/statistics/me") -async def get_my_stats(current_user: dict = Depends(get_current_user)): - """Get current user's statistics""" - stats = await db.get_player_statistics(current_user['id']) - return {"statistics": stats} - - -@app.get("/api/leaderboard/{stat_name}") -async def get_leaderboard_by_stat(stat_name: str, limit: int = 100): - """ - Get leaderboard for a specific statistic. - Available stats: distance_walked, enemies_killed, damage_dealt, damage_taken, - hp_restored, stamina_used, items_collected, deaths, etc. - """ - valid_stats = [ - "distance_walked", "enemies_killed", "damage_dealt", "damage_taken", - "hp_restored", "stamina_used", "stamina_restored", "items_collected", - "items_dropped", "items_used", "deaths", "successful_flees", "failed_flees", - "combats_initiated", "total_playtime" - ] - - if stat_name not in valid_stats: - raise HTTPException( - status_code=400, - detail=f"Invalid stat name. Valid stats: {', '.join(valid_stats)}" - ) - - leaderboard = await db.get_leaderboard(stat_name, limit) - return { - "stat_name": stat_name, - "leaderboard": leaderboard - } - - -# ============================================================================ -# WebSocket Endpoint -# ============================================================================ - -@app.websocket("/ws/game/{token}") -async def websocket_endpoint(websocket: WebSocket, token: str): - """ - WebSocket endpoint for real-time game updates. - Clients connect with their JWT token and receive live updates. - """ - character_id = None - - try: - # Authenticate the token - try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - # Support both character_id and old player_id - character_id = payload.get("character_id") or payload.get("player_id") - if character_id is None: - await websocket.close(code=4001, reason="Invalid token") - return - - player = await db.get_player_by_id(character_id) - if not player: - await websocket.close(code=4001, reason="Character not found") - return - - username = player.get('name') or player.get('name', 'Unknown') - except jwt.InvalidTokenError: - await websocket.close(code=4001, reason="Invalid token") - return - - # Connect the WebSocket - await manager.connect(websocket, character_id, username) - - # Initialize player session in Redis - if redis_manager: - player = await db.get_player_by_id(character_id) - await redis_manager.set_player_session(character_id, { - "username": username, - "location_id": player['location_id'], - "hp": player['hp'], - "max_hp": player['max_hp'], - "stamina": player['stamina'], - "max_stamina": player['max_stamina'], - "level": player['level'], - "xp": player['xp'], - "websocket_connected": "true" - }) - - # Add player to location registry - await redis_manager.add_player_to_location(character_id, player['location_id']) - - # Send initial connection success message - await manager.send_personal_message(character_id, { - "type": "connected", - "timestamp": datetime.utcnow().isoformat(), - "message": "WebSocket connected successfully" - }) - - # Send initial game state - player = await db.get_player_by_id(character_id) - location = LOCATIONS.get(player['location_id']) - - await manager.send_personal_message(character_id, { - "type": "state_update", - "data": { - "player": { - "hp": player['hp'], - "max_hp": player['max_hp'], - "stamina": player['stamina'], - "max_stamina": player['max_stamina'], - "location_id": player['location_id'], - "level": player['level'], - "xp": player['xp'] - }, - "location": { - "id": location.id, - "name": location.name - } if location else None - }, - "timestamp": datetime.utcnow().isoformat() - }) - - # Message loop - handle incoming messages - while True: - try: - data = await websocket.receive_json() - message_type = data.get("type") - - # Handle heartbeat - if message_type == "heartbeat": - await manager.send_personal_message(character_id, { - "type": "heartbeat_ack", - "timestamp": datetime.utcnow().isoformat() - }) - - # Handle ping - elif message_type == "ping": - await manager.send_personal_message(character_id, { - "type": "pong", - "timestamp": datetime.utcnow().isoformat() - }) - - # Future: Handle other message types (chat, emotes, etc.) - - except json.JSONDecodeError: - await manager.send_personal_message(character_id, { - "type": "error", - "message": "Invalid JSON", - "timestamp": datetime.utcnow().isoformat() - }) - - except WebSocketDisconnect: - if character_id: - await manager.disconnect(character_id) - except Exception as e: - print(f"โŒ WebSocket error for character {character_id}: {e}") - if character_id: - await manager.disconnect(character_id) - - -# ============================================================================ -# Health Check -# ============================================================================ - -@app.get("/health") -async def health_check(): - """Health check endpoint""" - return { - "status": "healthy", - "version": "2.0.0", - "locations_loaded": len(LOCATIONS), - "items_loaded": len(ITEMS_MANAGER.items) - } - - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/api/main_pre_migration_backup.py b/api/main_pre_migration_backup.py deleted file mode 100644 index 9f46797..0000000 --- a/api/main_pre_migration_backup.py +++ /dev/null @@ -1,5573 +0,0 @@ -""" -Standalone FastAPI application for Echoes of the Ashes. -All dependencies are self-contained in the api/ directory. -""" -from fastapi import FastAPI, HTTPException, Depends, status, WebSocket, WebSocketDisconnect -from fastapi.middleware.cors import CORSMiddleware -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel -from typing import Optional, List, Dict, Any -import jwt -import bcrypt -import asyncio -from datetime import datetime, timedelta -import os -import math -import time -from contextlib import asynccontextmanager -from pathlib import Path -import json -import logging -import traceback - -# Import our standalone modules -from . import database as db -from .world_loader import load_world, World, Location -from .items import ItemsManager -from . import game_logic -from . import background_tasks -from .redis_manager import redis_manager - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -# Helper function for distance calculation -def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float: - """ - Calculate distance between two points using Euclidean distance. - Coordinate system: 1 unit = 100 meters (so distance(0,0 to 1,1) = 141.4m) - """ - # Calculate distance in coordinate units - coord_distance = math.sqrt((x2 - x1)**2 + (y2 - y1)**2) - # Convert to meters (1 coordinate unit = 100 meters) - distance_meters = coord_distance * 100 - return distance_meters - -def calculate_stamina_cost(distance: float, weight: float, agility: int, max_weight: float = 10.0, volume: float = 0.0, max_volume: float = 10.0) -> int: - """ - Calculate stamina cost based on distance, weight, volume, capacity, and agility. - - Base cost: distance / 50 (so 50m = 1 stamina, 100m = 2 stamina) - - Weight penalty: +1 stamina per 10kg - - Agility reduction: -1 stamina per 3 agility points - - Over-capacity penalty: 50-200% extra if over weight OR volume limits - - Minimum: 1 stamina - """ - base_cost = max(1, round(distance / 50)) - weight_penalty = int(weight / 10) - agility_reduction = int(agility / 3) - - # Add over-capacity penalty (50% extra stamina cost if over limit) - over_capacity_penalty = 0 - if weight > max_weight or volume > max_volume: - weight_excess_ratio = max(0, (weight - max_weight) / max_weight) if max_weight > 0 else 0 - volume_excess_ratio = max(0, (volume - max_volume) / max_volume) if max_volume > 0 else 0 - excess_ratio = max(weight_excess_ratio, volume_excess_ratio) - # Penalty scales from 50% to 200% based on how much over capacity - over_capacity_penalty = int((base_cost + weight_penalty) * (0.5 + min(1.5, excess_ratio))) - - total_cost = max(1, base_cost + weight_penalty + over_capacity_penalty - agility_reduction) - return total_cost - - -async def calculate_player_capacity(player_id: int): - """ - Calculate player's current and max weight/volume capacity. - Returns: (current_weight, max_weight, current_volume, max_volume) - """ - inventory = await db.get_inventory(player_id) - current_weight = 0.0 - current_volume = 0.0 - max_weight = 10.0 # Base capacity - max_volume = 10.0 # Base capacity - - for inv_item in inventory: - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item_def: - current_weight += item_def.weight * inv_item['quantity'] - current_volume += item_def.volume * inv_item['quantity'] - - # Check for equipped bags/containers that increase capacity - if inv_item['is_equipped'] and item_def.stats: - max_weight += item_def.stats.get('weight_capacity', 0) - max_volume += item_def.stats.get('volume_capacity', 0) - - return current_weight, max_weight, current_volume, max_volume - -# Lifespan context manager for startup/shutdown -@asynccontextmanager -async def lifespan(app: FastAPI): - # Startup - await db.init_db() - print("โœ… Database initialized") - - # Connect to Redis - await redis_manager.connect() - print("โœ… Redis connected") - - # Inject Redis manager into ConnectionManager - manager.set_redis_manager(redis_manager) - - # Subscribe to all location channels + global broadcast - location_channels = [f"location:{loc_id}" for loc_id in LOCATIONS.keys()] - await redis_manager.subscribe_to_channels(location_channels + ['game:broadcast']) - print(f"โœ… Subscribed to {len(location_channels)} location channels") - - # Register this worker - await redis_manager.register_worker() - print(f"โœ… Worker registered: {redis_manager.worker_id}") - - # Start Redis message listener (background task) - redis_manager.start_listener(manager.handle_redis_message) - print("โœ… Redis listener started") - - # Start background tasks (distributed via Redis locks) - tasks = await background_tasks.start_background_tasks(manager, LOCATIONS) - if tasks: - print(f"โœ… Started {len(tasks)} background tasks in this worker") - else: - print("โญ๏ธ Background tasks running in another worker") - - yield - - # Shutdown - await background_tasks.stop_background_tasks(tasks) - - # Unregister worker - await redis_manager.unregister_worker() - print(f"๐Ÿ”Œ Worker unregistered: {redis_manager.worker_id}") - - # Disconnect from Redis - await redis_manager.disconnect() - print("โœ… Redis disconnected") - -app = FastAPI( - title="Echoes of the Ash API", - version="2.0.0", - description="Standalone game API with web and bot support", - lifespan=lifespan -) - -# CORS configuration -app.add_middleware( - CORSMiddleware, - allow_origins=[ - "https://echoesoftheashgame.patacuack.net", - "http://localhost:3000", - "http://localhost:5173" - ], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Mount static files for images -images_dir = Path(__file__).parent.parent / "images" -if images_dir.exists(): - app.mount("/images", StaticFiles(directory=str(images_dir)), name="images") - print(f"โœ… Mounted images directory: {images_dir}") -else: - print(f"โš ๏ธ Images directory not found: {images_dir}") - -# JWT Configuration -SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production-please") -ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days - -# Internal API key for bot communication -API_INTERNAL_KEY = os.getenv("API_INTERNAL_KEY", "change-this-internal-key") - -security = HTTPBearer() -oauth2_scheme = security # Alias for token extraction in character endpoints - -# Load game data -print("๐Ÿ”„ Loading game world...") -WORLD: World = load_world() -LOCATIONS: Dict[str, Location] = WORLD.locations -ITEMS_MANAGER = ItemsManager() -print(f"โœ… Game world ready: {len(LOCATIONS)} locations, {len(ITEMS_MANAGER.items)} items") - - -# ============================================================================ -# WebSocket Connection Manager -# ============================================================================ - -class ConnectionManager: - """ - Manages WebSocket connections for real-time game updates. - Tracks active connections and provides methods for broadcasting messages. - Now uses Redis pub/sub for cross-worker communication. - """ - def __init__(self): - # Maps player_id -> WebSocket connection (local to this worker only) - self.active_connections: Dict[int, WebSocket] = {} - # Maps player_id -> username for debugging - self.player_usernames: Dict[int, str] = {} - # Redis manager instance (injected later) - self.redis_manager = None - - def set_redis_manager(self, redis_manager): - """Inject Redis manager after initialization.""" - self.redis_manager = redis_manager - - async def connect(self, websocket: WebSocket, player_id: int, username: str): - """Accept a new WebSocket connection and track it.""" - await websocket.accept() - self.active_connections[player_id] = websocket - self.player_usernames[player_id] = username - - # Subscribe to player's personal channel - if self.redis_manager: - await self.redis_manager.subscribe_to_channels([f"player:{player_id}"]) - await self.redis_manager.mark_player_connected(player_id) - - print(f"๐Ÿ”Œ WebSocket connected: {username} (player_id={player_id}, worker={self.redis_manager.worker_id if self.redis_manager else 'N/A'})") - - async def disconnect(self, player_id: int): - """Remove a WebSocket connection.""" - if player_id in self.active_connections: - username = self.player_usernames.get(player_id, "unknown") - del self.active_connections[player_id] - if player_id in self.player_usernames: - del self.player_usernames[player_id] - - # Unsubscribe from player's personal channel - if self.redis_manager: - await self.redis_manager.unsubscribe_from_channel(f"player:{player_id}") - await self.redis_manager.mark_player_disconnected(player_id) - - print(f"๐Ÿ”Œ WebSocket disconnected: {username} (player_id={player_id})") - - async def send_personal_message(self, player_id: int, message: dict): - """Send a message to a specific player via Redis pub/sub.""" - if self.redis_manager: - # Send locally first if player is connected to this worker - if player_id in self.active_connections: - await self._send_direct(player_id, message) - else: - # Publish to Redis (player might be on another worker) - await self.redis_manager.publish_to_player(player_id, message) - else: - # Fallback to direct send (single worker mode) - await self._send_direct(player_id, message) - - async def _send_direct(self, player_id: int, message: dict): - """Directly send to local WebSocket connection.""" - if player_id in self.active_connections: - try: - print(f"๐Ÿ“จ Sending {message.get('type')} to player {player_id}") - await self.active_connections[player_id].send_json(message) - except Exception as e: - print(f"โŒ Failed to send message to player {player_id}: {e}") - await self.disconnect(player_id) - else: - print(f"โš ๏ธ Player {player_id} not in active connections, cannot send {message.get('type')}") - - async def broadcast(self, message: dict, exclude_player_id: Optional[int] = None): - """Broadcast a message to all connected players via Redis.""" - if self.redis_manager: - await self.redis_manager.publish_global_broadcast(message) - - # ALSO send to LOCAL connections immediately - for player_id in list(self.active_connections.keys()): - if player_id != exclude_player_id: - await self._send_direct(player_id, message) - else: - # Fallback: direct broadcast to local connections - disconnected = [] - for player_id, connection in self.active_connections.items(): - if player_id != exclude_player_id: - try: - await connection.send_json(message) - except Exception as e: - print(f"โŒ Failed to broadcast to player {player_id}: {e}") - disconnected.append(player_id) - - for player_id in disconnected: - await self.disconnect(player_id) - - async def send_to_location(self, location_id: str, message: dict, exclude_player_id: Optional[int] = None): - """Send a message to all players in a specific location via Redis pub/sub.""" - if self.redis_manager: - # Use Redis pub/sub for cross-worker broadcast - message_with_exclude = { - **message, - "exclude_player_id": exclude_player_id - } - await self.redis_manager.publish_to_location(location_id, message_with_exclude) - - # ALSO send to LOCAL connections immediately (don't wait for Redis roundtrip) - player_ids = await self.redis_manager.get_players_in_location(location_id) - for player_id in player_ids: - if player_id == exclude_player_id: - continue - if player_id in self.active_connections: - await self._send_direct(player_id, message) - else: - # Fallback: Query DB and send directly (single worker mode) - players_in_location = await db.get_players_in_location(location_id) - - active_players = [p for p in players_in_location if p['id'] in self.active_connections and p['id'] != exclude_player_id] - if not active_players: - return - - print(f"๐Ÿ“ Broadcasting to location {location_id}: {message.get('type')} (excluding player {exclude_player_id})") - - disconnected = [] - sent_count = 0 - for player in active_players: - player_id = player['id'] - try: - await self.active_connections[player_id].send_json(message) - sent_count += 1 - except Exception as e: - print(f"โŒ Failed to send to player {player_id}: {e}") - disconnected.append(player_id) - - print(f" ๐Ÿ“ค Sent {message.get('type')} to {sent_count} players") - - for player_id in disconnected: - await self.disconnect(player_id) - - async def handle_redis_message(self, channel: str, data: dict): - """Handle incoming Redis pub/sub messages and route to local WebSocket connections. - - This method is called by RedisManager when a message arrives on a subscribed channel. - Only sends to WebSocket connections that are local to this worker. - """ - try: - # Extract message type and data - message = { - "type": data.get("type"), - "data": data.get("data") - } - - # Determine routing based on channel type - if channel.startswith("player:"): - # Personal message to specific player - player_id = int(channel.split(":")[1]) - if player_id in self.active_connections: - await self._send_direct(player_id, message) - - elif channel.startswith("location:"): - # Broadcast to all players in location (only local connections) - location_id = channel.split(":")[1] - exclude_player_id = data.get("exclude_player_id") - - # Get players from Redis location registry - if self.redis_manager: - player_ids = await self.redis_manager.get_players_in_location(location_id) - - for player_id in player_ids: - if player_id == exclude_player_id: - continue - - # Only send if this worker has the connection - if player_id in self.active_connections: - await self._send_direct(player_id, message) - - elif channel == "game:broadcast": - # Global broadcast to all local connections - exclude_player_id = data.get("exclude_player_id") - - for player_id in list(self.active_connections.keys()): - if player_id != exclude_player_id: - await self._send_direct(player_id, message) - - except Exception as e: - print(f"โŒ Error handling Redis message on channel {channel}: {e}") - - def has_players_in_location(self, location_id: str) -> bool: - """Check if there are any players with active connections in a specific location (synchronous check).""" - return len(self.active_connections) > 0 - - def get_connected_count(self) -> int: - """Get the number of active WebSocket connections.""" - return len(self.active_connections) - -# Global connection manager instance -manager = ConnectionManager() - - -# ============================================================================ -# Pydantic Models -# ============================================================================ - -class UserRegister(BaseModel): - email: str - password: str - - -class UserLogin(BaseModel): - email: str - password: str - - -class CharacterCreate(BaseModel): - name: str - strength: int = 0 - agility: int = 0 - endurance: int = 0 - intellect: int = 0 - avatar_data: Optional[str] = None - - -class CharacterSelect(BaseModel): - character_id: int - - -class MoveRequest(BaseModel): - direction: str - - -class InteractRequest(BaseModel): - interactable_id: str - action_id: str - - -class UseItemRequest(BaseModel): - item_id: str - - -class PickupItemRequest(BaseModel): - item_id: int # This is the dropped_item database ID, not the item type string - quantity: int = 1 # How many to pick up (default: 1) - - -class InitiateCombatRequest(BaseModel): - enemy_id: int # wandering_enemies.id from database - - -class CombatActionRequest(BaseModel): - action: str # 'attack', 'defend', 'flee' - - -# ============================================================================ -# JWT Helper Functions -# ============================================================================ - -def create_access_token(data: dict) -> str: - """Create a JWT access token""" - to_encode = data.copy() - expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) - return encoded_jwt - - -async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: - """Verify JWT token and return current character (requires character selection)""" - try: - token = credentials.credentials - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - - # New system: account_id + character_id - character_id = payload.get("character_id") - account_id = payload.get("account_id") - - # Check if this is a new token format - if account_id is not None: - if character_id is None: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="No character selected. Please select a character first." - ) - - character = await db.get_character_by_id(character_id) - if character is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Character not found" - ) - - # Verify character belongs to account - if character["account_id"] != account_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Character does not belong to this account" - ) - - return character - - # Old system fallback: player_id (for backward compatibility during migration) - player_id = payload.get("player_id") - if player_id is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials" - ) - - player = await db.get_player_by_id(player_id) - if player is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Player not found" - ) - - return player - - except jwt.ExpiredSignatureError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token has expired" - ) - except (jwt.InvalidTokenError, jwt.DecodeError, Exception): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials" - ) - - -def decode_token(token: str) -> dict: - """Decode JWT token and return payload""" - try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - return payload - except jwt.ExpiredSignatureError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token has expired" - ) - except (jwt.InvalidTokenError, jwt.DecodeError, Exception): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials" - ) - - -# ============================================================================ -# Authentication Endpoints -# ============================================================================ - -@app.post("/api/auth/register") -async def register(user: UserRegister): - """Register a new account""" - # Check if email already exists - existing = await db.get_account_by_email(user.email) - if existing: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Email already registered" - ) - - # Hash password - password_hash = bcrypt.hashpw(user.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - - # Create account - account = await db.create_account( - email=user.email, - password_hash=password_hash, - account_type="web" - ) - - if not account: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create account" - ) - - # Get characters for this account (should be empty for new account) - characters = await db.get_characters_by_account_id(account["id"]) - - # Create access token with account_id (no character selected yet) - access_token = create_access_token({ - "account_id": account["id"], - "character_id": None - }) - - return { - "access_token": access_token, - "token_type": "bearer", - "account": { - "id": account["id"], - "email": account["email"], - "account_type": account["account_type"], - "is_premium": account.get("premium_expires_at") is not None, - }, - "characters": characters, - "needs_character_creation": len(characters) == 0 - } - - -@app.post("/api/auth/login") -async def login(user: UserLogin): - """Login with email and password""" - # Get account by email - account = await db.get_account_by_email(user.email) - if not account: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid email or password" - ) - - # Verify password - if not account.get('password_hash'): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid email or password" - ) - - if not bcrypt.checkpw(user.password.encode('utf-8'), account['password_hash'].encode('utf-8')): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid email or password" - ) - - # Update last login - await db.update_account_last_login(account["id"]) - - # Get characters for this account - characters = await db.get_characters_by_account_id(account["id"]) - - # Create access token with account_id (no character selected yet) - access_token = create_access_token({ - "account_id": account["id"], - "character_id": None - }) - - return { - "access_token": access_token, - "token_type": "bearer", - "account": { - "id": account["id"], - "email": account["email"], - "account_type": account["account_type"], - "is_premium": account.get("premium_expires_at") is not None, - }, - "characters": [ - { - "id": char["id"], - "name": char["name"], - "level": char["level"], - "xp": char["xp"], - "hp": char["hp"], - "max_hp": char["max_hp"], - "stamina": char["stamina"], - "max_stamina": char["max_stamina"], - "strength": char["strength"], - "agility": char["agility"], - "endurance": char["endurance"], - "intellect": char["intellect"], - "avatar_data": char.get("avatar_data"), - "last_played_at": char.get("last_played_at"), - "location_id": char["location_id"], - } - for char in characters - ], - "needs_character_creation": len(characters) == 0 - } - - -@app.get("/api/auth/me") -async def get_me(current_user: dict = Depends(get_current_user)): - """Get current user profile""" - return { - "id": current_user["id"], - "username": current_user.get("username"), - "name": current_user["name"], - "level": current_user["level"], - "xp": current_user["xp"], - "hp": current_user["hp"], - "max_hp": current_user["max_hp"], - "stamina": current_user["stamina"], - "max_stamina": current_user["max_stamina"], - "strength": current_user["strength"], - "agility": current_user["agility"], - "endurance": current_user["endurance"], - "intellect": current_user["intellect"], - "location_id": current_user["location_id"], - "is_dead": current_user["is_dead"], - "unspent_points": current_user["unspent_points"] - } - - -# ============================================================================ -# Character Management Endpoints -# ============================================================================ - -@app.get("/api/characters") -async def list_characters(credentials: HTTPAuthorizationCredentials = Depends(security)): - """List all characters for the logged-in account""" - token = credentials.credentials - payload = decode_token(token) - account_id = payload.get("account_id") - - if not account_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token" - ) - - characters = await db.get_characters_by_account_id(account_id) - - return { - "characters": [ - { - "id": char["id"], - "name": char["name"], - "level": char["level"], - "xp": char["xp"], - "hp": char["hp"], - "max_hp": char["max_hp"], - "stamina": char["stamina"], - "max_stamina": char["max_stamina"], - "avatar_data": char.get("avatar_data"), - "location_id": char["location_id"], - "created_at": char["created_at"], - "last_played_at": char.get("last_played_at"), - } - for char in characters - ] - } - - -@app.post("/api/characters") -async def create_character_endpoint( - character: CharacterCreate, - credentials: HTTPAuthorizationCredentials = Depends(security) -): - """Create a new character""" - token = credentials.credentials - payload = decode_token(token) - account_id = payload.get("account_id") - - if not account_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token" - ) - - # Check if account can create more characters - can_create, error_msg = await db.can_create_character(account_id) - if not can_create: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=error_msg - ) - - # Validate character name - if len(character.name) < 3 or len(character.name) > 20: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Character name must be between 3 and 20 characters" - ) - - # Check if name is unique - existing = await db.get_character_by_name(character.name) - if existing: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Character name already taken" - ) - - # Validate stat allocation (must total 20 points) - total_stats = character.strength + character.agility + character.endurance + character.intellect - if total_stats != 20: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Must allocate exactly 20 stat points (you allocated {total_stats})" - ) - - # Validate each stat is >= 0 - if any(stat < 0 for stat in [character.strength, character.agility, character.endurance, character.intellect]): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Stats cannot be negative" - ) - - # Create character - new_character = await db.create_character( - account_id=account_id, - name=character.name, - strength=character.strength, - agility=character.agility, - endurance=character.endurance, - intellect=character.intellect, - avatar_data=character.avatar_data - ) - - if not new_character: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create character" - ) - - return { - "message": "Character created successfully", - "character": { - "id": new_character["id"], - "name": new_character["name"], - "level": new_character["level"], - "strength": new_character["strength"], - "agility": new_character["agility"], - "endurance": new_character["endurance"], - "intellect": new_character["intellect"], - "hp": new_character["hp"], - "max_hp": new_character["max_hp"], - "stamina": new_character["stamina"], - "max_stamina": new_character["max_stamina"], - "location_id": new_character["location_id"], - "avatar_data": new_character.get("avatar_data"), - } - } - - -@app.post("/api/characters/select") -async def select_character( - selection: CharacterSelect, - credentials: HTTPAuthorizationCredentials = Depends(security) -): - """Select a character to play""" - token = credentials.credentials - payload = decode_token(token) - account_id = payload.get("account_id") - - if not account_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token" - ) - - # Verify character belongs to account - character = await db.get_character_by_id(selection.character_id) - if not character: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Character not found" - ) - - if character["account_id"] != account_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Character does not belong to this account" - ) - - # Update last played timestamp - await db.update_character_last_played(selection.character_id) - - # Create new token with character_id - access_token = create_access_token({ - "account_id": account_id, - "character_id": selection.character_id - }) - - return { - "access_token": access_token, - "token_type": "bearer", - "character": { - "id": character["id"], - "name": character["name"], - "level": character["level"], - "xp": character["xp"], - "hp": character["hp"], - "max_hp": character["max_hp"], - "stamina": character["stamina"], - "max_stamina": character["max_stamina"], - "strength": character["strength"], - "agility": character["agility"], - "endurance": character["endurance"], - "intellect": character["intellect"], - "location_id": character["location_id"], - "avatar_data": character.get("avatar_data"), - } - } - - -@app.delete("/api/characters/{character_id}") -async def delete_character_endpoint( - character_id: int, - credentials: HTTPAuthorizationCredentials = Depends(security) -): - """Delete a character""" - token = credentials.credentials - payload = decode_token(token) - account_id = payload.get("account_id") - - if not account_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token" - ) - - # Verify character belongs to account - character = await db.get_character_by_id(character_id) - if not character: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Character not found" - ) - - if character["account_id"] != account_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Character does not belong to this account" - ) - - # Delete character - await db.delete_character(character_id) - - return { - "message": f"Character '{character['name']}' deleted successfully" - } - - -# ============================================================================ -# Game Endpoints -# ============================================================================ - -@app.get("/api/game/state") -async def get_game_state(current_user: dict = Depends(get_current_user)): - """Get complete game state for the player""" - player_id = current_user['id'] - - # Get player data - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - # Get location - location = LOCATIONS.get(player['location_id']) - - # Get inventory and enrich with item data (exclude equipped items) - inventory_raw = await db.get_inventory(player_id) - inventory = [] - total_weight = 0.0 - total_volume = 0.0 - - for inv_item in inventory_raw: - item = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item: - item_weight = item.weight * inv_item['quantity'] - # Equipped items count for weight but not volume - if not inv_item['is_equipped']: - item_volume = item.volume * inv_item['quantity'] - total_volume += item_volume - total_weight += item_weight - - # Only add non-equipped items to inventory list - if not inv_item['is_equipped']: - # Get unique item data if this is a unique item - durability = None - max_durability = None - tier = None - if inv_item.get('unique_item_id'): - unique_item = await db.get_unique_item(inv_item['unique_item_id']) - if unique_item: - durability = unique_item.get('durability') - max_durability = unique_item.get('max_durability') - tier = unique_item.get('tier') - - inventory.append({ - "id": inv_item['id'], - "item_id": item.id, - "name": item.name, - "description": item.description, - "type": item.type, - "category": getattr(item, 'category', item.type), - "quantity": inv_item['quantity'], - "is_equipped": inv_item['is_equipped'], - "equippable": item.equippable, - "consumable": item.consumable, - "weight": item.weight, - "volume": item.volume, - "image_path": item.image_path, - "emoji": item.emoji, - "slot": item.slot, - "durability": durability if durability is not None else None, - "max_durability": max_durability if max_durability is not None else None, - "tier": tier if tier is not None else None, - "hp_restore": item.effects.get('hp_restore') if item.effects else None, - "stamina_restore": item.effects.get('stamina_restore') if item.effects else None, - "damage_min": item.stats.get('damage_min') if item.stats else None, - "damage_max": item.stats.get('damage_max') if item.stats else None - }) - - # Get equipped items - equipment_slots = await db.get_all_equipment(player_id) - equipment = {} - for slot, item_data in equipment_slots.items(): - if item_data and item_data['item_id']: - inv_item = await db.get_inventory_item_by_id(item_data['item_id']) - if inv_item: - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item_def: - # Get unique item data if this is a unique item - durability = None - max_durability = None - tier = None - if inv_item.get('unique_item_id'): - unique_item = await db.get_unique_item(inv_item['unique_item_id']) - if unique_item: - durability = unique_item.get('durability') - max_durability = unique_item.get('max_durability') - tier = unique_item.get('tier') - - equipment[slot] = { - "inventory_id": item_data['item_id'], - "item_id": item_def.id, - "name": item_def.name, - "description": item_def.description, - "emoji": item_def.emoji, - "image_path": item_def.image_path, - "durability": durability if durability is not None else None, - "max_durability": max_durability if max_durability is not None else None, - "tier": tier if tier is not None else None, - "stats": item_def.stats, - "encumbrance": item_def.encumbrance, - "weapon_effects": item_def.weapon_effects if hasattr(item_def, 'weapon_effects') else {} - } - if slot not in equipment: - equipment[slot] = None - - # Get combat state - combat = await db.get_active_combat(player_id) - - # Get dropped items at location and enrich with item data - dropped_items_raw = await db.get_dropped_items(player['location_id']) - dropped_items = [] - for dropped_item in dropped_items_raw: - item = ITEMS_MANAGER.get_item(dropped_item['item_id']) - if item: - # Get unique item data if this is a unique item - durability = None - max_durability = None - tier = None - if dropped_item.get('unique_item_id'): - unique_item = await db.get_unique_item(dropped_item['unique_item_id']) - if unique_item: - durability = unique_item.get('durability') - max_durability = unique_item.get('max_durability') - tier = unique_item.get('tier') - - dropped_items.append({ - "id": dropped_item['id'], - "item_id": item.id, - "name": item.name, - "description": item.description, - "type": item.type, - "quantity": dropped_item['quantity'], - "image_path": item.image_path, - "emoji": item.emoji, - "weight": item.weight, - "volume": item.volume, - "durability": durability if durability is not None else None, - "max_durability": max_durability if max_durability is not None else None, - "tier": tier if tier is not None else None, - "hp_restore": item.effects.get('hp_restore') if item.effects else None, - "stamina_restore": item.effects.get('stamina_restore') if item.effects else None, - "damage_min": item.stats.get('damage_min') if item.stats else None, - "damage_max": item.stats.get('damage_max') if item.stats else None - }) - - # Calculate max weight and volume based on equipment - # Base capacity - max_weight = 10.0 # Base carrying capacity - max_volume = 10.0 # Base volume capacity - - # Check for equipped backpack that increases capacity - if equipment.get('backpack'): - backpack_stats = equipment['backpack'].get('stats', {}) - max_weight += backpack_stats.get('weight_capacity', 0) - max_volume += backpack_stats.get('volume_capacity', 0) - - # Convert location to dict - location_dict = None - if location: - location_dict = { - "id": location.id, - "name": location.name, - "description": location.description, - "exits": location.exits, - "image_path": location.image_path, - "x": getattr(location, 'x', 0.0), - "y": getattr(location, 'y', 0.0), - "tags": getattr(location, 'tags', []) - } - - # Add weight/volume to player data - player_with_capacity = dict(player) - player_with_capacity['current_weight'] = round(total_weight, 2) - player_with_capacity['max_weight'] = round(max_weight, 2) - player_with_capacity['current_volume'] = round(total_volume, 2) - player_with_capacity['max_volume'] = round(max_volume, 2) - - # Calculate movement cooldown - import time - current_time = time.time() - last_movement = player.get('last_movement_time', 0) - time_since_movement = current_time - last_movement - movement_cooldown = max(0, min(5, 5 - time_since_movement)) - player_with_capacity['movement_cooldown'] = int(movement_cooldown) - - return { - "player": player_with_capacity, - "location": location_dict, - "inventory": inventory, - "equipment": equipment, - "combat": combat, - "dropped_items": dropped_items - } - - -@app.get("/api/game/profile") -async def get_player_profile(current_user: dict = Depends(get_current_user)): - """Get player profile information""" - player_id = current_user['id'] - - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - # Get inventory and enrich with item data - inventory_raw = await db.get_inventory(player_id) - inventory = [] - total_weight = 0.0 - total_volume = 0.0 - max_weight = 10.0 - max_volume = 10.0 - - for inv_item in inventory_raw: - item = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item: - item_weight = item.weight * inv_item['quantity'] - item_volume = item.volume * inv_item['quantity'] - total_weight += item_weight - total_volume += item_volume - - # Check for equipped bags/containers - if inv_item['is_equipped'] and item.stats: - max_weight += item.stats.get('weight_capacity', 0) - max_volume += item.stats.get('volume_capacity', 0) - - # Enrich inventory item with all necessary data - inventory.append({ - "id": inv_item['id'], - "item_id": item.id, - "name": item.name, - "description": item.description, - "type": item.type, - "category": getattr(item, 'category', item.type), - "quantity": inv_item['quantity'], - "is_equipped": inv_item['is_equipped'], - "equippable": item.equippable, - "consumable": item.consumable, - "weight": item.weight, - "volume": item.volume, - "image_path": item.image_path, - "emoji": item.emoji, - "hp_restore": item.effects.get('hp_restore') if item.effects else None, - "stamina_restore": item.effects.get('stamina_restore') if item.effects else None, - "damage_min": item.stats.get('damage_min') if item.stats else None, - "damage_max": item.stats.get('damage_max') if item.stats else None - }) - - # Add weight/volume to player data - player_with_capacity = dict(player) - player_with_capacity['current_weight'] = round(total_weight, 2) - player_with_capacity['max_weight'] = round(max_weight, 2) - player_with_capacity['current_volume'] = round(total_volume, 2) - player_with_capacity['max_volume'] = round(max_volume, 2) - - # Calculate movement cooldown - import time - current_time = time.time() - last_movement = player.get('last_movement_time', 0) - time_since_movement = current_time - last_movement - movement_cooldown = max(0, min(5, 5 - time_since_movement)) - player_with_capacity['movement_cooldown'] = round(movement_cooldown, 1) - - return { - "player": player_with_capacity, - "inventory": inventory - } - - -@app.post("/api/game/spend_point") -async def spend_stat_point( - stat: str, - current_user: dict = Depends(get_current_user) -): - """Spend a stat point on a specific attribute""" - player = current_user # current_user is already the character dict - - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - if player['unspent_points'] < 1: - raise HTTPException(status_code=400, detail="No unspent points available") - - # Valid stats - valid_stats = ['strength', 'agility', 'endurance', 'intellect'] - if stat not in valid_stats: - raise HTTPException(status_code=400, detail=f"Invalid stat. Must be one of: {', '.join(valid_stats)}") - - # Update the stat and decrease unspent points - update_data = { - stat: player[stat] + 1, - 'unspent_points': player['unspent_points'] - 1 - } - - # Endurance increases max HP - if stat == 'endurance': - update_data['max_hp'] = player['max_hp'] + 5 - update_data['hp'] = min(player['hp'] + 5, update_data['max_hp']) # Also heal by 5 - - await db.update_character(current_user['id'], **update_data) - - return { - "success": True, - "message": f"Increased {stat} by 1!", - "new_value": player[stat] + 1, - "remaining_points": player['unspent_points'] - 1 - } - - -@app.get("/api/game/location") -async def get_current_location(current_user: dict = Depends(get_current_user)): - """Get current location information""" - location_id = current_user['location_id'] - location = LOCATIONS.get(location_id) - - if not location: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Location {location_id} not found" - ) - - # Get dropped items at location - dropped_items = await db.get_dropped_items(location_id) - - # Get wandering enemies at location - wandering_enemies = await db.get_wandering_enemies_in_location(location_id) - - # Format interactables for response with cooldown info - interactables_data = [] - for interactable in location.interactables: - actions_data = [] - for action in interactable.actions: - # Check cooldown status for this specific action - cooldown_expiry = await db.get_interactable_cooldown(interactable.id, action.id) - import time - is_on_cooldown = False - remaining_cooldown = 0 - - if cooldown_expiry: - current_time = time.time() - if cooldown_expiry > current_time: - is_on_cooldown = True - remaining_cooldown = int(cooldown_expiry - current_time) - - actions_data.append({ - "id": action.id, - "name": action.label, - "stamina_cost": action.stamina_cost, - "description": f"Costs {action.stamina_cost} stamina", - "on_cooldown": is_on_cooldown, - "cooldown_remaining": remaining_cooldown - }) - - interactables_data.append({ - "instance_id": interactable.id, - "name": interactable.name, - "image_path": interactable.image_path, - "actions": actions_data - }) - - # Fix image URL - image_path already contains the full path from images/ - image_url = f"/{location.image_path}" if location.image_path else "/images/locations/default.png" - - # Calculate player's current weight for stamina cost adjustment - player = current_user # current_user is already the character dict - - if not player: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="No character selected. Please select a character first." - ) - - inventory_raw = await db.get_inventory(current_user['id']) - total_weight = 0.0 - total_volume = 0.0 - max_weight = 10.0 # Base capacity - max_volume = 10.0 # Base capacity - - for inv_item in inventory_raw: - item = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item: - total_weight += item.weight * inv_item['quantity'] - total_volume += item.volume * inv_item['quantity'] - - # Add capacity from equipped items (backpacks) - if inv_item.get('is_equipped', False) and item.stats: - max_weight += item.stats.get('weight_capacity', 0) - max_volume += item.stats.get('volume_capacity', 0) - - # Format directions with stamina costs (calculated from distance, weight, agility) - directions_with_stamina = [] - player_agility = player.get('agility', 5) - - for direction in location.exits.keys(): - destination_id = location.exits[direction] - destination_loc = LOCATIONS.get(destination_id) - - if destination_loc: - # Calculate real distance using coordinates - distance = calculate_distance( - location.x, location.y, - destination_loc.x, destination_loc.y - ) - # Calculate stamina cost based on distance, weight, volume, capacity, and agility - stamina_cost = calculate_stamina_cost(distance, total_weight, player_agility, max_weight, total_volume, max_volume) - destination_name = destination_loc.name - else: - # Fallback if destination not found - distance = 500 # Default 500m - stamina_cost = calculate_stamina_cost(distance, total_weight, player_agility) - destination_name = destination_id - - directions_with_stamina.append({ - "direction": direction, - "stamina_cost": stamina_cost, - "distance": int(distance), # Round to integer meters - "destination": destination_id, - "destination_name": destination_name - }) - - # Format NPCs (wandering enemies + static NPCs from JSON) - npcs_data = [] - - # Add wandering enemies from database - for enemy in wandering_enemies: - npcs_data.append({ - "id": enemy['id'], - "name": enemy['npc_id'].replace('_', ' ').title(), - "type": "enemy", - "level": enemy.get('level', 1), - "is_wandering": True - }) - - # Add static NPCs from location JSON (if any) - for npc in location.npcs: - if isinstance(npc, dict): - npcs_data.append({ - "id": npc.get('id', npc.get('name', 'unknown')), - "name": npc.get('name', 'Unknown NPC'), - "type": npc.get('type', 'npc'), - "level": npc.get('level'), - "is_wandering": False - }) - else: - npcs_data.append({ - "id": npc, - "name": npc, - "type": "npc", - "is_wandering": False - }) - - # Enrich dropped items with metadata - DON'T consolidate unique items! - items_dict = {} - for item in dropped_items: - item_def = ITEMS_MANAGER.get_item(item['item_id']) - if item_def: - # Get unique item data if this is a unique item - durability = None - max_durability = None - tier = None - if item.get('unique_item_id'): - unique_item = await db.get_unique_item(item['unique_item_id']) - if unique_item: - durability = unique_item.get('durability') - max_durability = unique_item.get('max_durability') - tier = unique_item.get('tier') - - # Create a unique key for unique items to prevent stacking - if item.get('unique_item_id'): - dict_key = f"{item['item_id']}_{item['unique_item_id']}" - else: - dict_key = item['item_id'] - - if dict_key not in items_dict: - items_dict[dict_key] = { - "id": item['id'], # Use first ID for pickup - "item_id": item['item_id'], - "name": item_def.name, - "description": item_def.description, - "quantity": item['quantity'], - "emoji": item_def.emoji, - "image_path": item_def.image_path, - "weight": item_def.weight, - "volume": item_def.volume, - "durability": durability, - "max_durability": max_durability, - "tier": tier, - "hp_restore": item_def.effects.get('hp_restore') if item_def.effects else None, - "stamina_restore": item_def.effects.get('stamina_restore') if item_def.effects else None, - "damage_min": item_def.stats.get('damage_min') if item_def.stats else None, - "damage_max": item_def.stats.get('damage_max') if item_def.stats else None - } - else: - # Only stack if it's not a unique item (stackable items only) - if not item.get('unique_item_id'): - items_dict[dict_key]['quantity'] += item['quantity'] - - items_data = list(items_dict.values()) - - # Get other players in the same location (characters from all accounts) - other_players = [] - try: - # Use Redis for player registry if available (includes disconnected players) - if redis_manager: - player_ids = await redis_manager.get_players_in_location(location_id) - - for pid in player_ids: - if pid == current_user['id']: - continue - - # Get player session from Redis - session = await redis_manager.get_player_session(pid) - if session: - # Check if player is connected - is_connected = session.get('websocket_connected') == 'true' - - # Check disconnect duration - disconnect_duration = None - if not is_connected: - disconnect_duration = await redis_manager.get_disconnect_duration(pid) - - # Get player data from DB for combat checks - char = await db.get_player_by_id(pid) - if not char: - continue - - # Don't show dead players - if char.get('is_dead', False): - continue - - # Check if character is in any combat (PvE or PvP) - in_pve_combat = await db.get_active_combat(pid) - in_pvp_combat = await db.get_pvp_combat_by_player(pid) - - # Don't show characters who are in combat - if in_pve_combat or in_pvp_combat: - continue - - # Check if PvP is possible with this character - level_diff = abs(player['level'] - int(session.get('level', 0))) - can_pvp = location.danger_level >= 3 and level_diff <= 3 - - other_players.append({ - "id": pid, - "name": session.get('username'), - "level": int(session.get('level', 0)), - "username": session.get('username'), - "can_pvp": can_pvp, - "level_diff": level_diff, - "is_connected": is_connected, - "vulnerable": not is_connected and location.danger_level >= 3 # Disconnected in dangerous zone - }) - else: - # Fallback: Query database directly (single worker mode) - async with db.engine.begin() as conn: - stmt = db.select(db.characters).where( - db.and_( - db.characters.c.location_id == location_id, - db.characters.c.id != current_user['id'], - db.characters.c.is_dead == False # Don't show dead players - ) - ) - result = await conn.execute(stmt) - characters_rows = result.fetchall() - - for char_row in characters_rows: - # Check if character is in any combat (PvE or PvP) - in_pve_combat = await db.get_active_combat(char_row.id) - in_pvp_combat = await db.get_pvp_combat_by_player(char_row.id) - - if in_pve_combat or in_pvp_combat: - continue - - # Check if PvP is possible with this character - level_diff = abs(player['level'] - char_row.level) - can_pvp = location.danger_level >= 3 and level_diff <= 3 - - other_players.append({ - "id": char_row.id, - "name": char_row.name, - "level": char_row.level, - "username": char_row.name, - "can_pvp": can_pvp, - "level_diff": level_diff, - "is_connected": True, # Assume connected in fallback mode - "vulnerable": False - }) - except Exception as e: - print(f"Error fetching other characters: {e}") - - # Get corpses at location - npc_corpses = await db.get_npc_corpses_in_location(location_id) - player_corpses = await db.get_player_corpses_in_location(location_id) - - # Format corpses for response - corpses_data = [] - import json - import sys - sys.path.insert(0, '/app') - from data.npcs import NPCS - - for corpse in npc_corpses: - loot = json.loads(corpse['loot_remaining']) if corpse['loot_remaining'] else [] - npc_def = NPCS.get(corpse['npc_id']) - corpses_data.append({ - "id": f"npc_{corpse['id']}", - "type": "npc", - "name": f"{npc_def.name if npc_def else corpse['npc_id']} Corpse", - "emoji": "๐Ÿ’€", - "loot_count": len(loot), - "timestamp": corpse['death_timestamp'] - }) - - for corpse in player_corpses: - items = json.loads(corpse['items']) if corpse['items'] else [] - corpses_data.append({ - "id": f"player_{corpse['id']}", - "type": "player", - "name": f"{corpse['player_name']}'s Corpse", - "emoji": "โšฐ๏ธ", - "loot_count": len(items), - "timestamp": corpse['death_timestamp'] - }) - - return { - "id": location.id, - "name": location.name, - "description": location.description, - "image_url": image_url, - "directions": list(location.exits.keys()), # Keep for backwards compatibility - "directions_detailed": directions_with_stamina, # New detailed format - "danger_level": location.danger_level, - "tags": location.tags if hasattr(location, 'tags') else [], # Include location tags - "npcs": npcs_data, - "items": items_data, - "interactables": interactables_data, - "other_players": other_players, - "corpses": corpses_data - } - - -@app.post("/api/game/move") -async def move( - move_req: MoveRequest, - current_user: dict = Depends(get_current_user) -): - """Move player in a direction""" - import time - - # Check if player is in PvP combat and hasn't acknowledged - pvp_combat = await db.get_pvp_combat_by_player(current_user['id']) - if pvp_combat: - is_attacker = pvp_combat['attacker_character_id'] == current_user['id'] - acknowledged = pvp_combat.get('attacker_acknowledged', False) if is_attacker else pvp_combat.get('defender_acknowledged', False) - - # Check if combat ended - need to get actual player HP - attacker = await db.get_player_by_id(pvp_combat['attacker_character_id']) - defender = await db.get_player_by_id(pvp_combat['defender_character_id']) - - # Only block if combat is still active (not fled, not defeated) and player hasn't acknowledged - combat_ended = pvp_combat.get('attacker_fled', False) or pvp_combat.get('defender_fled', False) or \ - attacker['hp'] <= 0 or defender['hp'] <= 0 - - if not acknowledged and not combat_ended: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot move while in PvP combat!" - ) - - # Check movement cooldown (5 seconds) - player = current_user # current_user is already the character dict - current_time = time.time() - last_movement = player.get('last_movement_time', 0) - cooldown_remaining = max(0, 5 - (current_time - last_movement)) - - if cooldown_remaining > 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"You must wait {int(cooldown_remaining)} seconds before moving again." - ) - - success, message, new_location_id, stamina_cost, distance = await game_logic.move_player( - current_user['id'], - move_req.direction, - LOCATIONS - ) - - if not success: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=message - ) - - # Update last movement time - await db.update_player(current_user['id'], last_movement_time=current_time) - - # Update Redis cache: Move player between locations - if redis_manager: - await redis_manager.move_player_between_locations( - current_user['id'], - player['location_id'], - new_location_id - ) - - # Update player session with new location - await redis_manager.update_player_session_field(current_user['id'], 'location_id', new_location_id) - await redis_manager.update_player_session_field(current_user['id'], 'stamina', player['stamina'] - stamina_cost) - - # Track movement statistics - use actual distance in meters - await db.update_player_statistics(current_user['id'], distance_walked=distance, increment=True) - - # Check for encounter upon arrival (if danger level > 1) - import random - import sys - sys.path.insert(0, '/app') - from data.npcs import get_random_npc_for_location, LOCATION_DANGER, NPCS - - new_location = LOCATIONS.get(new_location_id) - encounter_triggered = False - enemy_id = None - combat_data = None - - if new_location and new_location.danger_level > 1: - # Get encounter rate from danger config - danger_data = LOCATION_DANGER.get(new_location_id) - if danger_data: - _, encounter_rate, _ = danger_data - # Roll for encounter - if random.random() < encounter_rate: - # Get a random enemy for this location - enemy_id = get_random_npc_for_location(new_location_id) - if enemy_id: - # Check if player is already in combat - existing_combat = await db.get_active_combat(current_user['id']) - if not existing_combat: - # Get NPC definition - npc_def = NPCS.get(enemy_id) - if npc_def: - # Randomize HP - npc_hp = random.randint(npc_def.hp_min, npc_def.hp_max) - - # Create combat directly - combat = await db.create_combat( - player_id=current_user['id'], - npc_id=enemy_id, - npc_hp=npc_hp, - npc_max_hp=npc_hp, - location_id=new_location_id, - from_wandering=False # This is an encounter, not wandering - ) - - # Track combat initiation - await db.update_player_statistics(current_user['id'], combats_initiated=1, increment=True) - - encounter_triggered = True - combat_data = { - "npc_id": enemy_id, - "npc_name": npc_def.name, - "npc_hp": npc_hp, - "npc_max_hp": npc_hp, - "npc_image": f"/images/npcs/{enemy_id}.png", - "turn": "player", - "round": 1 - } - - response = { - "success": True, - "message": message, - "new_location_id": new_location_id - } - - # Add encounter info if triggered - if encounter_triggered: - response["encounter"] = { - "triggered": True, - "enemy_id": enemy_id, - "message": f"โš ๏ธ An enemy ambushes you upon arrival!", - "combat": combat_data - } - - # Broadcast movement to WebSocket clients - # Notify old location that player left - await manager.send_to_location( - player['location_id'], - { - "type": "location_update", - "data": { - "message": f"{player['name']} left the area", - "action": "player_left", - "player_id": current_user['id'], - "player_name": player['name'] - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=current_user['id'] - ) - - # Notify new location that player arrived - await manager.send_to_location( - new_location_id, - { - "type": "location_update", - "data": { - "message": f"{player['name']} arrived", - "action": "player_arrived", - "player_id": current_user['id'], - "player_name": player['name'], - "player_level": player['level'], - "can_pvp": new_location.danger_level >= 3 # Full player data for UI update - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=current_user['id'] - ) - - # Send state update to the moving player - await manager.send_personal_message(current_user['id'], { - "type": "state_update", - "data": { - "player": { - "stamina": player['stamina'] - stamina_cost, - "location_id": new_location_id - }, - "location": { - "id": new_location.id, - "name": new_location.name - } if new_location else None, - "encounter": response.get("encounter") - }, - "timestamp": datetime.utcnow().isoformat() - }) - - return response - - -@app.post("/api/game/inspect") -async def inspect(current_user: dict = Depends(get_current_user)): - """Inspect the current area""" - location_id = current_user['location_id'] - location = LOCATIONS.get(location_id) - - if not location: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Location not found" - ) - - # Get dropped items - dropped_items = await db.get_dropped_items(location_id) - - message = await game_logic.inspect_area( - current_user['id'], - location, - {} # interactables_data - not needed with new structure - ) - - return { - "success": True, - "message": message - } - - -@app.post("/api/game/interact") -async def interact( - interact_req: InteractRequest, - current_user: dict = Depends(get_current_user) -): - """Interact with an object""" - # Check if player is in combat - combat = await db.get_active_combat(current_user['id']) - if combat: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot interact with objects while in combat" - ) - - location_id = current_user['location_id'] - location = LOCATIONS.get(location_id) - - if not location: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Location not found" - ) - - result = await game_logic.interact_with_object( - current_user['id'], - interact_req.interactable_id, - interact_req.action_id, - location, - ITEMS_MANAGER - ) - - if not result['success']: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=result['message'] - ) - - # Broadcast interactable cooldown to all players in location - from datetime import datetime - - # Find the interactable name and action name - interactable = None - action_name = None - for obj in location.interactables: - if obj.id == interact_req.interactable_id: - interactable = obj - for act in obj.actions: - if act.id == interact_req.action_id: - action_name = act.label - break - break - - interactable_name = interactable.name if interactable else "Object" - action_display = action_name if action_name else interact_req.action_id - - # Get the actual cooldown expiry from database and calculate remaining time - cooldown_expiry = await db.get_interactable_cooldown( - interact_req.interactable_id, - interact_req.action_id - ) - - # Calculate remaining cooldown in seconds - import time as time_module - current_time = time_module.time() - cooldown_remaining = 0 - if cooldown_expiry and cooldown_expiry > current_time: - cooldown_remaining = int(cooldown_expiry - current_time) - - # Only broadcast if there are players in the location - if manager.has_players_in_location(location_id): - await manager.send_to_location( - location_id=location_id, - message={ - "type": "interactable_cooldown", - "data": { - "instance_id": interact_req.interactable_id, - "action_id": interact_req.action_id, - "cooldown_remaining": cooldown_remaining, - "message": f"{current_user['name']} used {action_display} on {interactable_name}" - }, - "timestamp": datetime.utcnow().isoformat() - } - ) - - return result - - -@app.post("/api/game/use_item") -async def use_item( - use_req: UseItemRequest, - current_user: dict = Depends(get_current_user) -): - """Use an item from inventory""" - import random - import sys - sys.path.insert(0, '/app') - from data.npcs import NPCS - - # Check if in combat - combat = await db.get_active_combat(current_user['id']) - in_combat = combat is not None - - result = await game_logic.use_item( - current_user['id'], - use_req.item_id, - ITEMS_MANAGER - ) - - if not result['success']: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=result['message'] - ) - - # If in combat, enemy gets a turn - if in_combat and combat['turn'] == 'player': - player = current_user # current_user is already the character dict - npc_def = NPCS.get(combat['npc_id']) - - # Enemy attacks - npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max) - if combat['npc_hp'] / combat['npc_max_hp'] < 0.3: - npc_damage = int(npc_damage * 1.5) - - new_player_hp = max(0, player['hp'] - npc_damage) - combat_message = f"\n{npc_def.name} attacks for {npc_damage} damage!" - - if new_player_hp <= 0: - combat_message += "\nYou have been defeated!" - await db.update_player(current_user['id'], hp=0, is_dead=True) - await db.end_combat(current_user['id']) - result['combat_over'] = True - result['player_won'] = False - - # Create corpse with player's inventory - import json - import time as time_module - try: - inventory = await db.get_inventory(current_user['id']) - inventory_items = [] - for inv_item in inventory: - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - inventory_items.append({ - 'item_id': inv_item['item_id'], - 'name': item_def.name if item_def else inv_item['item_id'], - 'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '๐Ÿ“ฆ', - 'quantity': inv_item['quantity'], - 'durability': inv_item.get('durability'), - 'max_durability': inv_item.get('max_durability'), - 'tier': inv_item.get('tier') - }) - - # Store minimal data in database - db_items = json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) - - logger.info(f"Creating player corpse for {player['name']} at {player['location_id']} with {len(inventory_items)} items") - - corpse_id = await db.create_player_corpse( - player_name=player['name'], - location_id=player['location_id'], - items=db_items - ) - - logger.info(f"Successfully created player corpse: ID={corpse_id}, player={player['name']}, location={player['location_id']}, items_count={len(inventory_items)}") - - # Clear player's inventory (items are now in corpse) - await db.clear_inventory(current_user['id']) - - # Build corpse data for broadcast - corpse_data = { - "id": f"player_{corpse_id}", - "type": "player", - "name": f"{player['name']}'s Corpse", - "emoji": "โšฐ๏ธ", - "player_name": player['name'], - "loot_count": len(inventory_items), - "items": inventory_items, # Full item list for UI - "timestamp": time_module.time() - } - - # Broadcast to location that player died and corpse appeared - logger.info(f"Broadcasting player_died to location {player['location_id']} for player {player['name']}") - await manager.send_to_location( - location_id=player['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} was defeated in combat", - "action": "player_died", - "player_id": player['id'], - "corpse": corpse_data # Send full corpse data - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=current_user['id'] - ) - except Exception as e: - logger.error(f"Error creating player corpse for {player['name']}: {e}", exc_info=True) - else: - await db.update_player(current_user['id'], hp=new_player_hp) - - result['message'] += combat_message - result['in_combat'] = True - result['combat_over'] = result.get('combat_over', False) - - return result - - -@app.post("/api/game/pickup") -async def pickup( - pickup_req: PickupItemRequest, - current_user: dict = Depends(get_current_user) -): - """Pick up an item from the ground""" - # Get item details for broadcast BEFORE picking it up (it will be removed from DB) - # pickup_req.item_id is the dropped_item database ID, not the item_id string - dropped_item = await db.get_dropped_item(pickup_req.item_id) - if dropped_item: - item_def = ITEMS_MANAGER.get_item(dropped_item['item_id']) - item_name = item_def.name if item_def else dropped_item['item_id'] - else: - item_name = "item" - - result = await game_logic.pickup_item( - current_user['id'], - pickup_req.item_id, - current_user['location_id'], - pickup_req.quantity, - ITEMS_MANAGER - ) - - if not result['success']: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=result['message'] - ) - - # Track pickup statistics - quantity = pickup_req.quantity if pickup_req.quantity else 1 - await db.update_player_statistics(current_user['id'], items_collected=quantity, increment=True) - - # Broadcast pickup to other players in location - player = current_user # current_user is already the character dict - await manager.send_to_location( - player['location_id'], - { - "type": "location_update", - "data": { - "message": f"{player['name']} picked up {quantity}x {item_name}", - "action": "item_picked_up" - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=current_user['id'] - ) - - # Send state update to the player - await manager.send_personal_message(current_user['id'], { - "type": "inventory_update", - "timestamp": datetime.utcnow().isoformat() - }) - - return result - - -# ============================================================================ -# EQUIPMENT SYSTEM -# ============================================================================ - -class EquipItemRequest(BaseModel): - inventory_id: int # ID of item in inventory to equip - - -class UnequipItemRequest(BaseModel): - slot: str # Equipment slot to unequip from - - -class RepairItemRequest(BaseModel): - inventory_id: int # ID of item in inventory to repair - - -@app.post("/api/game/equip") -async def equip_item( - equip_req: EquipItemRequest, - current_user: dict = Depends(get_current_user) -): - """Equip an item from inventory""" - player_id = current_user['id'] - - # Get the inventory item - inv_item = await db.get_inventory_item_by_id(equip_req.inventory_id) - if not inv_item or inv_item['character_id'] != player_id: - raise HTTPException(status_code=404, detail="Item not found in inventory") - - # Get item definition - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - if not item_def: - raise HTTPException(status_code=404, detail="Item definition not found") - - # Check if item is equippable - if not item_def.equippable or not item_def.slot: - raise HTTPException(status_code=400, detail="This item cannot be equipped") - - # Check if slot is valid - valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack'] - if item_def.slot not in valid_slots: - raise HTTPException(status_code=400, detail=f"Invalid equipment slot: {item_def.slot}") - - # Check if slot is already occupied - current_equipped = await db.get_equipped_item_in_slot(player_id, item_def.slot) - unequipped_item_name = None - - if current_equipped and current_equipped.get('item_id'): - # Get the old item's name for the message - old_inv_item = await db.get_inventory_item_by_id(current_equipped['item_id']) - if old_inv_item: - old_item_def = ITEMS_MANAGER.get_item(old_inv_item['item_id']) - unequipped_item_name = old_item_def.name if old_item_def else "previous item" - - # Unequip current item first - await db.unequip_item(player_id, item_def.slot) - # Mark as not equipped in inventory - await db.update_inventory_item(current_equipped['item_id'], is_equipped=False) - - # Equip the new item - await db.equip_item(player_id, item_def.slot, equip_req.inventory_id) - - # Mark as equipped in inventory - await db.update_inventory_item(equip_req.inventory_id, is_equipped=True) - - # Initialize unique_item if this is first time equipping an equippable with durability - if inv_item.get('unique_item_id') is None and item_def.durability: - # Create a unique_item instance for this equipment - unique_item_id = await db.create_unique_item( - item_id=item_def.id, - durability=item_def.durability, - max_durability=item_def.durability, - tier=item_def.tier if hasattr(item_def, 'tier') else 1, - unique_stats=None - ) - # Link the inventory item to this unique_item - await db.update_inventory_item( - equip_req.inventory_id, - unique_item_id=unique_item_id - ) - - # Build message - if unequipped_item_name: - message = f"Unequipped {unequipped_item_name}, equipped {item_def.name}" - else: - message = f"Equipped {item_def.name}" - - return { - "success": True, - "message": message, - "slot": item_def.slot, - "unequipped_item": unequipped_item_name - } - - -@app.post("/api/game/unequip") -async def unequip_item( - unequip_req: UnequipItemRequest, - current_user: dict = Depends(get_current_user) -): - """Unequip an item from equipment slot""" - player_id = current_user['id'] - - # Check if slot is valid - valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack'] - if unequip_req.slot not in valid_slots: - raise HTTPException(status_code=400, detail=f"Invalid equipment slot: {unequip_req.slot}") - - # Get currently equipped item - equipped = await db.get_equipped_item_in_slot(player_id, unequip_req.slot) - if not equipped: - raise HTTPException(status_code=400, detail=f"No item equipped in {unequip_req.slot} slot") - - # Get inventory item and item definition - inv_item = await db.get_inventory_item_by_id(equipped['item_id']) - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - - # Check if inventory has space (volume-wise) - inventory = await db.get_inventory(player_id) - total_volume = sum( - ITEMS_MANAGER.get_item(i['item_id']).volume * i['quantity'] - for i in inventory - if ITEMS_MANAGER.get_item(i['item_id']) and not i['is_equipped'] - ) - - # Get max volume (base 10 + backpack bonus) - max_volume = 10.0 - for inv in inventory: - if inv['is_equipped']: - item = ITEMS_MANAGER.get_item(inv['item_id']) - if item and item.stats: - max_volume += item.stats.get('volume_capacity', 0) - - # If unequipping backpack, check if items will fit - if unequip_req.slot == 'backpack' and item_def.stats: - backpack_volume = item_def.stats.get('volume_capacity', 0) - if total_volume > (max_volume - backpack_volume): - raise HTTPException( - status_code=400, - detail="Cannot unequip backpack: inventory would exceed volume capacity" - ) - - # Check if adding this item would exceed volume - if total_volume + item_def.volume > max_volume: - # Drop to ground instead - await db.unequip_item(player_id, unequip_req.slot) - await db.update_inventory_item(equipped['item_id'], is_equipped=False) - await db.drop_item(player_id, inv_item['item_id'], 1, current_user['location_id']) - await db.remove_from_inventory(player_id, inv_item['item_id'], 1) - - return { - "success": True, - "message": f"Unequipped {item_def.name} (dropped to ground - inventory full)", - "dropped": True - } - - # Unequip the item - await db.unequip_item(player_id, unequip_req.slot) - await db.update_inventory_item(equipped['item_id'], is_equipped=False) - - return { - "success": True, - "message": f"Unequipped {item_def.name}", - "dropped": False - } - - -@app.get("/api/game/equipment") -async def get_equipment(current_user: dict = Depends(get_current_user)): - """Get all equipped items""" - player_id = current_user['id'] - - equipment = await db.get_all_equipment(player_id) - - # Enrich with item data - enriched = {} - for slot, item_data in equipment.items(): - if item_data: - inv_item = await db.get_inventory_item_by_id(item_data['item_id']) - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item_def: - enriched[slot] = { - "inventory_id": item_data['item_id'], - "item_id": item_def.id, - "name": item_def.name, - "description": item_def.description, - "emoji": item_def.emoji, - "image_path": item_def.image_path, - "durability": inv_item.get('durability'), - "max_durability": inv_item.get('max_durability'), - "tier": inv_item.get('tier', 1), - "stats": item_def.stats, - "encumbrance": item_def.encumbrance - } - else: - enriched[slot] = None - - return {"equipment": enriched} - - -@app.post("/api/game/repair_item") -async def repair_item( - repair_req: RepairItemRequest, - current_user: dict = Depends(get_current_user) -): - """Repair an item using materials at a workbench location""" - player_id = current_user['id'] - - # Get player's location - player = await db.get_player_by_id(player_id) - location = LOCATIONS.get(player['location_id']) - - if not location: - raise HTTPException(status_code=404, detail="Location not found") - - # Check if location has workbench - location_tags = getattr(location, 'tags', []) - if 'workbench' not in location_tags and 'repair_station' not in location_tags: - raise HTTPException( - status_code=400, - detail="You need to be at a location with a workbench to repair items. Try the Gas Station!" - ) - - # Get inventory item - inv_item = await db.get_inventory_item(repair_req.inventory_id) - if not inv_item or inv_item['character_id'] != player_id: - raise HTTPException(status_code=404, detail="Item not found in inventory") - - # Get item definition - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - if not item_def: - raise HTTPException(status_code=404, detail="Item definition not found") - - # Check if item is repairable - if not getattr(item_def, 'repairable', False): - raise HTTPException(status_code=400, detail=f"{item_def.name} cannot be repaired") - - # Check if item has durability (unique item) - if not inv_item.get('unique_item_id'): - raise HTTPException(status_code=400, detail="This item doesn't have durability tracking") - - # Get unique item data - unique_item = await db.get_unique_item(inv_item['unique_item_id']) - if not unique_item: - raise HTTPException(status_code=500, detail="Unique item data not found") - - current_durability = unique_item.get('durability', 0) - max_durability = unique_item.get('max_durability', 100) - - # Check if item needs repair - if current_durability >= max_durability: - raise HTTPException(status_code=400, detail=f"{item_def.name} is already at full durability") - - # Get repair materials - repair_materials = getattr(item_def, 'repair_materials', []) - if not repair_materials: - raise HTTPException(status_code=500, detail="Item repair configuration missing") - - # Get repair tools - repair_tools = getattr(item_def, 'repair_tools', []) - - # Check if player has all required materials and tools - player_inventory = await db.get_inventory(player_id) - inventory_dict = {item['item_id']: item['quantity'] for item in player_inventory} - - missing_materials = [] - for material in repair_materials: - required_qty = material.get('quantity', 1) - available_qty = inventory_dict.get(material['item_id'], 0) - if available_qty < required_qty: - material_def = ITEMS_MANAGER.get_item(material['item_id']) - material_name = material_def.name if material_def else material['item_id'] - missing_materials.append(f"{material_name} ({available_qty}/{required_qty})") - - if missing_materials: - raise HTTPException( - status_code=400, - detail=f"Missing materials: {', '.join(missing_materials)}" - ) - - # Check and consume tools if required - tools_consumed = [] - if repair_tools: - success, error_msg, tools_consumed = await consume_tool_durability(player_id, repair_tools, player_inventory) - if not success: - raise HTTPException(status_code=400, detail=error_msg) - - # Consume materials - for material in repair_materials: - await db.remove_item_from_inventory(player_id, material['item_id'], material['quantity']) - - # Calculate repair amount - repair_percentage = getattr(item_def, 'repair_percentage', 25) - repair_amount = int((max_durability * repair_percentage) / 100) - new_durability = min(current_durability + repair_amount, max_durability) - - # Update unique item durability - await db.update_unique_item(inv_item['unique_item_id'], durability=new_durability) - - # Build materials consumed message - materials_used = [] - for material in repair_materials: - material_def = ITEMS_MANAGER.get_item(material['item_id']) - emoji = material_def.emoji if material_def and hasattr(material_def, 'emoji') else '๐Ÿ“ฆ' - name = material_def.name if material_def else material['item_id'] - materials_used.append(f"{emoji} {name} x{material['quantity']}") - - return { - "success": True, - "message": f"Repaired {item_def.name}! Restored {repair_amount} durability.", - "item_name": item_def.name, - "old_durability": current_durability, - "new_durability": new_durability, - "max_durability": max_durability, - "materials_consumed": materials_used, - "tools_consumed": tools_consumed, - "repair_amount": repair_amount - } - - - - -async def reduce_armor_durability(player_id: int, damage_taken: int) -> tuple: - """ - Reduce durability of equipped armor pieces when taking damage. - Formula: durability_loss = max(1, (damage_taken / armor_value) * base_reduction_rate) - Base reduction rate: 0.5 (so 10 damage with 5 armor = 1 durability loss) - Returns: (armor_damage_absorbed, broken_armor_pieces) - """ - equipment = await db.get_all_equipment(player_id) - armor_pieces = ['head', 'chest', 'legs', 'feet'] - - total_armor = 0 - equipped_armor = [] - - # Collect all equipped armor - for slot in armor_pieces: - if equipment.get(slot) and equipment[slot]: - armor_slot = equipment[slot] - inv_item = await db.get_inventory_item_by_id(armor_slot['item_id']) - if inv_item and inv_item.get('unique_item_id'): - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item_def and item_def.stats and 'armor' in item_def.stats: - armor_value = item_def.stats['armor'] - total_armor += armor_value - equipped_armor.append({ - 'slot': slot, - 'inv_item_id': armor_slot['item_id'], - 'unique_item_id': inv_item['unique_item_id'], - 'item_id': inv_item['item_id'], - 'item_def': item_def, - 'armor_value': armor_value - }) - - if not equipped_armor: - return 0, [] - - # Calculate damage absorbed by armor (total armor reduces damage) - armor_absorbed = min(damage_taken // 2, total_armor) # Armor absorbs up to half the damage - - # Calculate durability loss for each armor piece - # Balanced formula: armor should last many combats (10-20+ hits for low tier) - base_reduction_rate = 0.1 # Reduced from 0.5 to make armor more durable - broken_armor = [] - - for armor in equipped_armor: - # Each piece takes durability loss proportional to its armor value - proportion = armor['armor_value'] / total_armor if total_armor > 0 else 0 - # Formula: durability_loss = (damage_taken * proportion / armor_value) * base_rate - # This means higher armor value = less durability loss per hit - # With base_rate = 0.1, a 5 armor piece taking 10 damage loses ~0.2 durability per hit - durability_loss = max(1, int((damage_taken * proportion / max(armor['armor_value'], 1)) * base_reduction_rate * 10)) - - # Get current durability - unique_item = await db.get_unique_item(armor['unique_item_id']) - if unique_item: - current_durability = unique_item.get('durability', 0) - new_durability = max(0, current_durability - durability_loss) - - await db.update_unique_item(armor['unique_item_id'], durability=new_durability) - - # If armor broke, unequip and remove from inventory - if new_durability <= 0: - await db.unequip_item(player_id, armor['slot']) - await db.remove_inventory_row(armor['inv_item_id']) - broken_armor.append({ - 'name': armor['item_def'].name, - 'emoji': armor['item_def'].emoji, - 'slot': armor['slot'] - }) - - return armor_absorbed, broken_armor - - -async def consume_tool_durability(user_id: int, tools: list, inventory: list) -> tuple: - """ - Consume durability from required tools. - Returns: (success, error_message, consumed_tools_info) - """ - consumed_tools = [] - tools_map = {} - - # Build map of available tools with durability - for inv_item in inventory: - if inv_item.get('unique_item_id'): - unique_item = await db.get_unique_item(inv_item['unique_item_id']) - if unique_item: - item_id = inv_item['item_id'] - durability = unique_item.get('durability', 0) - if item_id not in tools_map: - tools_map[item_id] = [] - tools_map[item_id].append({ - 'inventory_id': inv_item['id'], - 'unique_item_id': inv_item['unique_item_id'], - 'durability': durability, - 'max_durability': unique_item.get('max_durability', 100) - }) - - # Check and consume tools - for tool_req in tools: - tool_id = tool_req['item_id'] - durability_cost = tool_req['durability_cost'] - - if tool_id not in tools_map or not tools_map[tool_id]: - tool_def = ITEMS_MANAGER.items.get(tool_id) - tool_name = tool_def.name if tool_def else tool_id - return False, f"Missing required tool: {tool_name}", [] - - # Find tool with enough durability - tool_found = None - for tool in tools_map[tool_id]: - if tool['durability'] >= durability_cost: - tool_found = tool - break - - if not tool_found: - tool_def = ITEMS_MANAGER.items.get(tool_id) - tool_name = tool_def.name if tool_def else tool_id - return False, f"Tool {tool_name} doesn't have enough durability (need {durability_cost})", [] - - # Consume durability - new_durability = tool_found['durability'] - durability_cost - await db.update_unique_item(tool_found['unique_item_id'], durability=new_durability) - - # If tool breaks, remove from inventory - if new_durability <= 0: - await db.remove_inventory_row(tool_found['inventory_id']) - - tool_def = ITEMS_MANAGER.items.get(tool_id) - consumed_tools.append({ - 'item_id': tool_id, - 'name': tool_def.name if tool_def else tool_id, - 'durability_cost': durability_cost, - 'broke': new_durability <= 0 - }) - - return True, "", consumed_tools - - - -@app.get("/api/game/craftable") -async def get_craftable_items(current_user: dict = Depends(get_current_user)): - """Get all craftable items with material requirements and availability""" - try: - player = current_user # current_user is already the character dict - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - # Get player's inventory with quantities - inventory = await db.get_inventory(current_user['id']) - inventory_counts = {} - for inv_item in inventory: - item_id = inv_item['item_id'] - quantity = inv_item.get('quantity', 1) - inventory_counts[item_id] = inventory_counts.get(item_id, 0) + quantity - - craftable_items = [] - for item_id, item_def in ITEMS_MANAGER.items.items(): - if not getattr(item_def, 'craftable', False): - continue - - craft_materials = getattr(item_def, 'craft_materials', []) - if not craft_materials: - continue - - # Check material availability - materials_info = [] - can_craft = True - for material in craft_materials: - mat_item_id = material['item_id'] - required = material['quantity'] - available = inventory_counts.get(mat_item_id, 0) - - mat_item_def = ITEMS_MANAGER.items.get(mat_item_id) - materials_info.append({ - 'item_id': mat_item_id, - 'name': mat_item_def.name if mat_item_def else mat_item_id, - 'emoji': mat_item_def.emoji if mat_item_def else '๐Ÿ“ฆ', - 'required': required, - 'available': available, - 'has_enough': available >= required - }) - - if available < required: - can_craft = False - - # Check tool requirements - craft_tools = getattr(item_def, 'craft_tools', []) - tools_info = [] - for tool_req in craft_tools: - tool_id = tool_req['item_id'] - durability_cost = tool_req['durability_cost'] - tool_def = ITEMS_MANAGER.items.get(tool_id) - - # Check if player has this tool - has_tool = False - tool_durability = 0 - for inv_item in inventory: - if inv_item['item_id'] == tool_id and inv_item.get('unique_item_id'): - unique = await db.get_unique_item(inv_item['unique_item_id']) - if unique and unique.get('durability', 0) >= durability_cost: - has_tool = True - tool_durability = unique.get('durability', 0) - break - - tools_info.append({ - 'item_id': tool_id, - 'name': tool_def.name if tool_def else tool_id, - 'emoji': tool_def.emoji if tool_def else '๐Ÿ”ง', - 'durability_cost': durability_cost, - 'has_tool': has_tool, - 'tool_durability': tool_durability - }) - - if not has_tool: - can_craft = False - - # Check level requirement - craft_level = getattr(item_def, 'craft_level', 1) - player_level = player.get('level', 1) - meets_level = player_level >= craft_level - - # Don't show recipes above player level - if player_level < craft_level: - continue - - if not meets_level: - can_craft = False - - craftable_items.append({ - 'item_id': item_id, - 'name': item_def.name, - 'emoji': item_def.emoji, - 'description': item_def.description, - 'tier': getattr(item_def, 'tier', 1), - 'type': item_def.type, - 'category': item_def.type, # Add category for filtering - 'slot': getattr(item_def, 'slot', None), - 'materials': materials_info, - 'tools': tools_info, - 'craft_level': craft_level, - 'meets_level': meets_level, - 'uncraftable': getattr(item_def, 'uncraftable', False), - 'can_craft': can_craft - }) - - # Sort: craftable items first, then by tier, then by name - craftable_items.sort(key=lambda x: (not x['can_craft'], x['tier'], x['name'])) - - return {'craftable_items': craftable_items} - - except Exception as e: - print(f"Error getting craftable items: {e}") - import traceback - traceback.print_exc() - raise HTTPException(status_code=500, detail=str(e)) - - -class CraftItemRequest(BaseModel): - item_id: str - - -@app.post("/api/game/craft_item") -async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get_current_user)): - """Craft an item, consuming materials and creating item with random stats for unique items""" - try: - player = current_user # current_user is already the character dict - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - location_id = player['location_id'] - location = LOCATIONS.get(location_id) - - # Check if player is at a workbench - if not location or 'workbench' not in getattr(location, 'tags', []): - raise HTTPException(status_code=400, detail="You must be at a workbench to craft items") - - # Get item definition - item_def = ITEMS_MANAGER.items.get(request.item_id) - if not item_def: - raise HTTPException(status_code=404, detail="Item not found") - - if not getattr(item_def, 'craftable', False): - raise HTTPException(status_code=400, detail="This item cannot be crafted") - - # Check level requirement - craft_level = getattr(item_def, 'craft_level', 1) - player_level = player.get('level', 1) - if player_level < craft_level: - raise HTTPException(status_code=400, detail=f"You need to be level {craft_level} to craft this item (you are level {player_level})") - - craft_materials = getattr(item_def, 'craft_materials', []) - if not craft_materials: - raise HTTPException(status_code=400, detail="No crafting recipe found") - - # Check if player has all materials - inventory = await db.get_inventory(current_user['id']) - inventory_counts = {} - inventory_items_map = {} - - for inv_item in inventory: - item_id = inv_item['item_id'] - quantity = inv_item.get('quantity', 1) - inventory_counts[item_id] = inventory_counts.get(item_id, 0) + quantity - if item_id not in inventory_items_map: - inventory_items_map[item_id] = [] - inventory_items_map[item_id].append(inv_item) - - # Check tools requirement - craft_tools = getattr(item_def, 'craft_tools', []) - if craft_tools: - success, error_msg, tools_consumed = await consume_tool_durability(current_user['id'], craft_tools, inventory) - if not success: - raise HTTPException(status_code=400, detail=error_msg) - else: - tools_consumed = [] - - # Verify all materials are available - for material in craft_materials: - required = material['quantity'] - available = inventory_counts.get(material['item_id'], 0) - if available < required: - raise HTTPException( - status_code=400, - detail=f"Not enough {material['item_id']}. Need {required}, have {available}" - ) - - # Consume materials - materials_used = [] - for material in craft_materials: - item_id = material['item_id'] - quantity_needed = material['quantity'] - - items_of_type = inventory_items_map[item_id] - for inv_item in items_of_type: - if quantity_needed <= 0: - break - - inv_quantity = inv_item.get('quantity', 1) - to_remove = min(quantity_needed, inv_quantity) - - if inv_quantity > to_remove: - # Update quantity - await db.update_inventory_item( - inv_item['id'], - quantity=inv_quantity - to_remove - ) - else: - # Remove entire stack - use item_id string, not inventory row id - await db.remove_item_from_inventory(current_user['id'], item_id, to_remove) - - quantity_needed -= to_remove - - mat_item_def = ITEMS_MANAGER.items.get(item_id) - materials_used.append({ - 'item_id': item_id, - 'name': mat_item_def.name if mat_item_def else item_id, - 'quantity': material['quantity'] - }) - - # Generate random stats for unique items - import random - created_item = None - - if hasattr(item_def, 'durability') and item_def.durability: - # This is a unique item - generate random stats - base_durability = item_def.durability - # Random durability: 90-110% of base - random_durability = int(base_durability * random.uniform(0.9, 1.1)) - - # Generate tier based on durability roll - durability_percent = (random_durability / base_durability) - if durability_percent >= 1.08: - tier = 5 # Gold - elif durability_percent >= 1.04: - tier = 4 # Purple - elif durability_percent >= 1.0: - tier = 3 # Blue - elif durability_percent >= 0.96: - tier = 2 # Green - else: - tier = 1 # White - - # Generate random stats if item has stats - random_stats = {} - if hasattr(item_def, 'stats') and item_def.stats: - for stat_key, stat_value in item_def.stats.items(): - if isinstance(stat_value, (int, float)): - # Random stat: 90-110% of base - random_stats[stat_key] = int(stat_value * random.uniform(0.9, 1.1)) - else: - random_stats[stat_key] = stat_value - - # Create unique item in database - unique_item_id = await db.create_unique_item( - item_id=request.item_id, - durability=random_durability, - max_durability=random_durability, - tier=tier, - unique_stats=random_stats - ) - - # Add to inventory - await db.add_item_to_inventory( - player_id=current_user['id'], - item_id=request.item_id, - quantity=1, - unique_item_id=unique_item_id - ) - - created_item = { - 'item_id': request.item_id, - 'name': item_def.name, - 'emoji': item_def.emoji, - 'tier': tier, - 'durability': random_durability, - 'max_durability': random_durability, - 'stats': random_stats, - 'unique': True - } - else: - # Stackable item - just add to inventory - await db.add_item_to_inventory( - player_id=current_user['id'], - item_id=request.item_id, - quantity=1 - ) - - created_item = { - 'item_id': request.item_id, - 'name': item_def.name, - 'emoji': item_def.emoji, - 'tier': getattr(item_def, 'tier', 1), - 'unique': False - } - - return { - 'success': True, - 'message': f"Successfully crafted {item_def.name}!", - 'item': created_item, - 'materials_consumed': materials_used, - 'tools_consumed': tools_consumed - } - - except Exception as e: - print(f"Error crafting item: {e}") - import traceback - traceback.print_exc() - raise HTTPException(status_code=500, detail=str(e)) - - -class UncraftItemRequest(BaseModel): - inventory_id: int - - -@app.post("/api/game/uncraft_item") -async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends(get_current_user)): - """Uncraft an item, returning materials with a chance of loss""" - try: - player = current_user # current_user is already the character dict - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - location_id = player['location_id'] - location = LOCATIONS.get(location_id) - - # Check if player is at a workbench - if not location or 'workbench' not in getattr(location, 'tags', []): - raise HTTPException(status_code=400, detail="You must be at a workbench to uncraft items") - - # Get inventory item - inventory = await db.get_inventory(current_user['id']) - inv_item = None - for item in inventory: - if item['id'] == request.inventory_id: - inv_item = item - break - - if not inv_item: - raise HTTPException(status_code=404, detail="Item not found in inventory") - - # Get item definition - item_def = ITEMS_MANAGER.items.get(inv_item['item_id']) - if not item_def: - raise HTTPException(status_code=404, detail="Item definition not found") - - if not getattr(item_def, 'uncraftable', False): - raise HTTPException(status_code=400, detail="This item cannot be uncrafted") - - uncraft_yield = getattr(item_def, 'uncraft_yield', []) - if not uncraft_yield: - raise HTTPException(status_code=400, detail="No uncraft recipe found") - - # Check tools requirement - uncraft_tools = getattr(item_def, 'uncraft_tools', []) - if uncraft_tools: - success, error_msg, tools_consumed = await consume_tool_durability(current_user['id'], uncraft_tools, inventory) - if not success: - raise HTTPException(status_code=400, detail=error_msg) - else: - tools_consumed = [] - - # Remove the item from inventory - # Use remove_inventory_row since we have the inventory ID - await db.remove_inventory_row(inv_item['id']) - - # Calculate durability ratio for yield reduction - durability_ratio = 1.0 # Default: full yield - if inv_item.get('unique_item_id'): - unique_item = await db.get_unique_item(inv_item['unique_item_id']) - if unique_item: - current_durability = unique_item.get('durability', 0) - max_durability = unique_item.get('max_durability', 1) - if max_durability > 0: - durability_ratio = current_durability / max_durability - - # Calculate materials with loss chance and durability reduction - import random - loss_chance = getattr(item_def, 'uncraft_loss_chance', 0.3) - materials_yielded = [] - materials_lost = [] - - for material in uncraft_yield: - # Apply durability reduction first - base_quantity = material['quantity'] - adjusted_quantity = int(base_quantity * durability_ratio) - - # If durability is too low (< 10%), yield nothing for this material - if durability_ratio < 0.1 or adjusted_quantity <= 0: - mat_def = ITEMS_MANAGER.items.get(material['item_id']) - materials_lost.append({ - 'item_id': material['item_id'], - 'name': mat_def.name if mat_def else material['item_id'], - 'quantity': base_quantity, - 'reason': 'durability_too_low' - }) - continue - - # Roll for each material separately with loss chance - if random.random() < loss_chance: - # Lost this material - mat_def = ITEMS_MANAGER.items.get(material['item_id']) - materials_lost.append({ - 'item_id': material['item_id'], - 'name': mat_def.name if mat_def else material['item_id'], - 'quantity': adjusted_quantity, - 'reason': 'random_loss' - }) - else: - # Yield this material - await db.add_item_to_inventory( - player_id=current_user['id'], - item_id=material['item_id'], - quantity=adjusted_quantity - ) - mat_def = ITEMS_MANAGER.items.get(material['item_id']) - materials_yielded.append({ - 'item_id': material['item_id'], - 'name': mat_def.name if mat_def else material['item_id'], - 'emoji': mat_def.emoji if mat_def else '๐Ÿ“ฆ', - 'quantity': adjusted_quantity - }) - - message = f"Uncrafted {item_def.name}!" - if durability_ratio < 1.0: - message += f" (Item condition reduced yield by {int((1 - durability_ratio) * 100)}%)" - if materials_lost: - message += f" Lost {len(materials_lost)} material type(s) in the process." - - return { - 'success': True, - 'message': message, - 'item_name': item_def.name, - 'materials_yielded': materials_yielded, - 'materials_lost': materials_lost, - 'tools_consumed': tools_consumed, - 'loss_chance': loss_chance, - 'durability_ratio': round(durability_ratio, 2) - } - - except Exception as e: - print(f"Error uncrafting item: {e}") - import traceback - traceback.print_exc() - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/api/game/repairable") -async def get_repairable_items(current_user: dict = Depends(get_current_user)): - """Get all repairable items from inventory and equipped slots""" - try: - player = current_user # current_user is already the character dict - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - location_id = player['location_id'] - location = LOCATIONS.get(location_id) - - # Check if player is at a repair station - if not location or 'repair_station' not in getattr(location, 'tags', []): - raise HTTPException(status_code=400, detail="You must be at a repair station to repair items") - - repairable_items = [] - - # Check inventory items - inventory = await db.get_inventory(current_user['id']) - inventory_counts = {} - for inv_item in inventory: - item_id = inv_item['item_id'] - quantity = inv_item.get('quantity', 1) - inventory_counts[item_id] = inventory_counts.get(item_id, 0) + quantity - - for inv_item in inventory: - if inv_item.get('unique_item_id'): - unique_item = await db.get_unique_item(inv_item['unique_item_id']) - if not unique_item: - continue - - item_def = ITEMS_MANAGER.items.get(inv_item['item_id']) - if not item_def or not getattr(item_def, 'repairable', False): - continue - - current_durability = unique_item.get('durability', 0) - max_durability = unique_item.get('max_durability', 100) - needs_repair = current_durability < max_durability - - # Check materials availability - repair_materials = getattr(item_def, 'repair_materials', []) - materials_info = [] - has_materials = True - for material in repair_materials: - mat_item_def = ITEMS_MANAGER.items.get(material['item_id']) - available = inventory_counts.get(material['item_id'], 0) - required = material['quantity'] - materials_info.append({ - 'item_id': material['item_id'], - 'name': mat_item_def.name if mat_item_def else material['item_id'], - 'emoji': mat_item_def.emoji if mat_item_def else '๐Ÿ“ฆ', - 'quantity': required, - 'available': available, - 'has_enough': available >= required - }) - if available < required: - has_materials = False - - # Check tools availability - repair_tools = getattr(item_def, 'repair_tools', []) - tools_info = [] - has_tools = True - for tool_req in repair_tools: - tool_id = tool_req['item_id'] - durability_cost = tool_req['durability_cost'] - tool_def = ITEMS_MANAGER.items.get(tool_id) - - # Check if player has this tool - tool_found = False - tool_durability = 0 - for check_item in inventory: - if check_item['item_id'] == tool_id and check_item.get('unique_item_id'): - unique = await db.get_unique_item(check_item['unique_item_id']) - if unique and unique.get('durability', 0) >= durability_cost: - tool_found = True - tool_durability = unique.get('durability', 0) - break - - tools_info.append({ - 'item_id': tool_id, - 'name': tool_def.name if tool_def else tool_id, - 'emoji': tool_def.emoji if tool_def else '๐Ÿ”ง', - 'durability_cost': durability_cost, - 'has_tool': tool_found, - 'tool_durability': tool_durability - }) - if not tool_found: - has_tools = False - - can_repair = needs_repair and has_materials and has_tools - - repairable_items.append({ - 'inventory_id': inv_item['id'], - 'unique_item_id': inv_item['unique_item_id'], - 'item_id': inv_item['item_id'], - 'name': item_def.name, - 'emoji': item_def.emoji, - 'tier': unique_item.get('tier', 1), - 'current_durability': current_durability, - 'max_durability': max_durability, - 'durability_percent': int((current_durability / max_durability) * 100), - 'repair_percentage': getattr(item_def, 'repair_percentage', 25), - 'needs_repair': needs_repair, - 'materials': materials_info, - 'tools': tools_info, - 'can_repair': can_repair, - 'location': 'inventory' - }) - - # Check equipped items - equipment_slots = ['head', 'weapon', 'torso', 'backpack', 'legs', 'feet'] - for slot in equipment_slots: - equipped_item_id = player.get(f'equipped_{slot}') - if not equipped_item_id: - continue - - unique_item = await db.get_unique_item(equipped_item_id) - if not unique_item: - continue - - item_id = unique_item['item_id'] - item_def = ITEMS_MANAGER.items.get(item_id) - if not item_def or not getattr(item_def, 'repairable', False): - continue - - current_durability = unique_item.get('durability', 0) - max_durability = unique_item.get('max_durability', 100) - needs_repair = current_durability < max_durability - - # Check materials availability - repair_materials = getattr(item_def, 'repair_materials', []) - materials_info = [] - has_materials = True - for material in repair_materials: - mat_item_def = ITEMS_MANAGER.items.get(material['item_id']) - available = inventory_counts.get(material['item_id'], 0) - required = material['quantity'] - materials_info.append({ - 'item_id': material['item_id'], - 'name': mat_item_def.name if mat_item_def else material['item_id'], - 'emoji': mat_item_def.emoji if mat_item_def else '๐Ÿ“ฆ', - 'quantity': required, - 'available': available, - 'has_enough': available >= required - }) - if available < required: - has_materials = False - - # Check tools availability - repair_tools = getattr(item_def, 'repair_tools', []) - tools_info = [] - has_tools = True - for tool_req in repair_tools: - tool_id = tool_req['item_id'] - durability_cost = tool_req['durability_cost'] - tool_def = ITEMS_MANAGER.items.get(tool_id) - - # Check if player has this tool - tool_found = False - tool_durability = 0 - for check_item in inventory: - if check_item['item_id'] == tool_id and check_item.get('unique_item_id'): - unique = await db.get_unique_item(check_item['unique_item_id']) - if unique and unique.get('durability', 0) >= durability_cost: - tool_found = True - tool_durability = unique.get('durability', 0) - break - - tools_info.append({ - 'item_id': tool_id, - 'name': tool_def.name if tool_def else tool_id, - 'emoji': tool_def.emoji if tool_def else '๐Ÿ”ง', - 'durability_cost': durability_cost, - 'has_tool': tool_found, - 'tool_durability': tool_durability - }) - if not tool_found: - has_tools = False - - can_repair = needs_repair and has_materials and has_tools - - repairable_items.append({ - 'unique_item_id': equipped_item_id, - 'item_id': item_id, - 'name': item_def.name, - 'emoji': item_def.emoji, - 'tier': unique_item.get('tier', 1), - 'current_durability': current_durability, - 'max_durability': max_durability, - 'durability_percent': int((current_durability / max_durability) * 100), - 'repair_percentage': getattr(item_def, 'repair_percentage', 25), - 'needs_repair': needs_repair, - 'materials': materials_info, - 'tools': tools_info, - 'can_repair': can_repair, - 'location': 'equipped', - 'slot': slot - }) - - # Sort: repairable items first (can_repair=True), then by durability percent (lowest first), then by name - repairable_items.sort(key=lambda x: (not x['can_repair'], -x['durability_percent'], x['name'])) - - return {'repairable_items': repairable_items} - - except Exception as e: - print(f"Error getting repairable items: {e}") - import traceback - traceback.print_exc() - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/api/game/salvageable") -async def get_salvageable_items(current_user: dict = Depends(get_current_user)): - """Get list of salvageable (uncraftable) items from inventory with their unique stats""" - try: - player = current_user # current_user is already the character dict - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - location_id = player['location_id'] - location = LOCATIONS.get(location_id) - - # Check if player is at a workbench - if not location or 'workbench' not in getattr(location, 'tags', []): - return {'salvageable_items': [], 'at_workbench': False} - - # Get inventory - inventory = await db.get_inventory(current_user['id']) - - salvageable_items = [] - for inv_item in inventory: - item_id = inv_item['item_id'] - item_def = ITEMS_MANAGER.items.get(item_id) - - if not item_def or not getattr(item_def, 'uncraftable', False): - continue - - # Get unique item details if it exists - unique_item_data = None - if inv_item.get('unique_item_id'): - unique_item = await db.get_unique_item(inv_item['unique_item_id']) - if unique_item: - current_durability = unique_item.get('durability', 0) - max_durability = unique_item.get('max_durability', 1) - durability_percent = int((current_durability / max_durability) * 100) if max_durability > 0 else 0 - - # Get item stats from definition merged with unique stats - item_stats = {} - if item_def.stats: - item_stats = dict(item_def.stats) - if unique_item.get('unique_stats'): - item_stats.update(unique_item.get('unique_stats')) - - unique_item_data = { - 'current_durability': current_durability, - 'max_durability': max_durability, - 'durability_percent': durability_percent, - 'tier': unique_item.get('tier', 1), - 'unique_stats': item_stats # Includes both base stats and unique overrides - } - - # Get uncraft yield - uncraft_yield = getattr(item_def, 'uncraft_yield', []) - yield_info = [] - for material in uncraft_yield: - mat_def = ITEMS_MANAGER.items.get(material['item_id']) - yield_info.append({ - 'item_id': material['item_id'], - 'name': mat_def.name if mat_def else material['item_id'], - 'emoji': mat_def.emoji if mat_def else '๐Ÿ“ฆ', - 'quantity': material['quantity'] - }) - - salvageable_items.append({ - 'inventory_id': inv_item['id'], - 'unique_item_id': inv_item.get('unique_item_id'), - 'item_id': item_id, - 'name': item_def.name, - 'emoji': item_def.emoji, - 'tier': getattr(item_def, 'tier', 1), - 'quantity': inv_item['quantity'], - 'unique_item_data': unique_item_data, - 'base_yield': yield_info, - 'loss_chance': getattr(item_def, 'uncraft_loss_chance', 0.3) - }) - - return { - 'salvageable_items': salvageable_items, - 'at_workbench': True - } - - except Exception as e: - print(f"Error getting salvageable items: {e}") - import traceback - traceback.print_exc() - raise HTTPException(status_code=500, detail=str(e)) - - -class LootCorpseRequest(BaseModel): - corpse_id: str - item_index: Optional[int] = None # Index of specific item to loot (None = all) - - -@app.get("/api/game/corpse/{corpse_id}") -async def get_corpse_details( - corpse_id: str, - current_user: dict = Depends(get_current_user) -): - """Get detailed information about a corpse's lootable items""" - import json - import sys - sys.path.insert(0, '/app') - from data.npcs import NPCS - - # Parse corpse ID - corpse_type, corpse_db_id = corpse_id.split('_', 1) - corpse_db_id = int(corpse_db_id) - - player = current_user # current_user is already the character dict - - # Get player's inventory to check available tools - inventory = await db.get_inventory(player['id']) - available_tools = set([item['item_id'] for item in inventory]) - - if corpse_type == 'npc': - # Get NPC corpse - corpse = await db.get_npc_corpse(corpse_db_id) - if not corpse: - raise HTTPException(status_code=404, detail="Corpse not found") - - if corpse['location_id'] != player['location_id']: - raise HTTPException(status_code=400, detail="Corpse not at this location") - - # Parse remaining loot - loot_remaining = json.loads(corpse['loot_remaining']) if corpse['loot_remaining'] else [] - - # Format loot items with tool requirements - loot_items = [] - for idx, loot_item in enumerate(loot_remaining): - required_tool = loot_item.get('required_tool') - item_def = ITEMS_MANAGER.get_item(loot_item['item_id']) - - has_tool = required_tool is None or required_tool in available_tools - tool_def = ITEMS_MANAGER.get_item(required_tool) if required_tool else None - - loot_items.append({ - 'index': idx, - 'item_id': loot_item['item_id'], - 'item_name': item_def.name if item_def else loot_item['item_id'], - 'emoji': item_def.emoji if item_def else '๐Ÿ“ฆ', - 'quantity_min': loot_item['quantity_min'], - 'quantity_max': loot_item['quantity_max'], - 'required_tool': required_tool, - 'required_tool_name': tool_def.name if tool_def else required_tool, - 'has_tool': has_tool, - 'can_loot': has_tool - }) - - npc_def = NPCS.get(corpse['npc_id']) - - return { - 'corpse_id': corpse_id, - 'type': 'npc', - 'name': f"{npc_def.name if npc_def else corpse['npc_id']} Corpse", - 'loot_items': loot_items, - 'total_items': len(loot_items) - } - - elif corpse_type == 'player': - # Get player corpse - corpse = await db.get_player_corpse(corpse_db_id) - if not corpse: - raise HTTPException(status_code=404, detail="Corpse not found") - - if corpse['location_id'] != player['location_id']: - raise HTTPException(status_code=400, detail="Corpse not at this location") - - # Parse items - items = json.loads(corpse['items']) if corpse['items'] else [] - - # Format items (player corpses don't require tools) - loot_items = [] - for idx, item in enumerate(items): - item_def = ITEMS_MANAGER.get_item(item['item_id']) - - loot_items.append({ - 'index': idx, - 'item_id': item['item_id'], - 'item_name': item_def.name if item_def else item['item_id'], - 'emoji': item_def.emoji if item_def else '๐Ÿ“ฆ', - 'quantity_min': item['quantity'], - 'quantity_max': item['quantity'], - 'required_tool': None, - 'required_tool_name': None, - 'has_tool': True, - 'can_loot': True - }) - - return { - 'corpse_id': corpse_id, - 'type': 'player', - 'name': f"{corpse['player_name']}'s Corpse", - 'loot_items': loot_items, - 'total_items': len(loot_items) - } - - else: - raise HTTPException(status_code=400, detail="Invalid corpse type") - - -@app.post("/api/game/loot_corpse") -async def loot_corpse( - req: LootCorpseRequest, - current_user: dict = Depends(get_current_user) -): - """Loot a corpse (NPC or player) - can loot specific item by index or all items""" - import json - import sys - import random - sys.path.insert(0, '/app') - from data.npcs import NPCS - - # Parse corpse ID - corpse_type, corpse_db_id = req.corpse_id.split('_', 1) - corpse_db_id = int(corpse_db_id) - - player = current_user # current_user is already the character dict - - # Get player's current capacity - current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(player['id']) - - if corpse_type == 'npc': - # Get NPC corpse - corpse = await db.get_npc_corpse(corpse_db_id) - if not corpse: - raise HTTPException(status_code=404, detail="Corpse not found") - - # Check if player is at the same location - if corpse['location_id'] != player['location_id']: - raise HTTPException(status_code=400, detail="Corpse not at this location") - - # Parse remaining loot - loot_remaining = json.loads(corpse['loot_remaining']) if corpse['loot_remaining'] else [] - - if not loot_remaining: - raise HTTPException(status_code=400, detail="Corpse has already been looted") - - # Get player's inventory to check tools - inventory = await db.get_inventory(player['id']) - available_tools = set([item['item_id'] for item in inventory]) - - looted_items = [] - remaining_loot = [] - dropped_items = [] # Items that couldn't fit in inventory - tools_consumed = [] # Track tool durability consumed - - # If specific item index provided, loot only that item - if req.item_index is not None: - if req.item_index < 0 or req.item_index >= len(loot_remaining): - raise HTTPException(status_code=400, detail="Invalid item index") - - loot_item = loot_remaining[req.item_index] - required_tool = loot_item.get('required_tool') - durability_cost = loot_item.get('tool_durability_cost', 5) # Default 5 durability per loot - - # Check if player has required tool and consume durability - if required_tool: - # Build tool requirement format for consume_tool_durability - tool_req = [{ - 'item_id': required_tool, - 'durability_cost': durability_cost - }] - - success, error_msg, tools_consumed = await consume_tool_durability(player['id'], tool_req, inventory) - if not success: - raise HTTPException(status_code=400, detail=error_msg) - - # Determine quantity - quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max']) - - if quantity > 0: - # Check if item fits in inventory - item_def = ITEMS_MANAGER.get_item(loot_item['item_id']) - if item_def: - item_weight = item_def.weight * quantity - item_volume = item_def.volume * quantity - - if current_weight + item_weight > max_weight or current_volume + item_volume > max_volume: - # Item doesn't fit - drop it on ground - await db.add_dropped_item(player['location_id'], loot_item['item_id'], quantity) - dropped_items.append({ - 'item_id': loot_item['item_id'], - 'quantity': quantity, - 'emoji': item_def.emoji - }) - else: - # Item fits - add to inventory - await db.add_item_to_inventory(player['id'], loot_item['item_id'], quantity) - current_weight += item_weight - current_volume += item_volume - looted_items.append({ - 'item_id': loot_item['item_id'], - 'quantity': quantity - }) - - # Remove this item from loot, keep others - remaining_loot = [item for i, item in enumerate(loot_remaining) if i != req.item_index] - else: - # Loot all items that don't require tools or player has tools for - for loot_item in loot_remaining: - required_tool = loot_item.get('required_tool') - durability_cost = loot_item.get('tool_durability_cost', 5) - - # If tool is required, consume durability - can_loot = True - if required_tool: - tool_req = [{ - 'item_id': required_tool, - 'durability_cost': durability_cost - }] - - # Check if player has tool with enough durability - success, error_msg, consumed_info = await consume_tool_durability(player['id'], tool_req, inventory) - if success: - # Tool consumed successfully - tools_consumed.extend(consumed_info) - # Refresh inventory after tool consumption - inventory = await db.get_inventory(player['id']) - else: - # Can't loot this item - can_loot = False - - if can_loot: - # Can loot this item - quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max']) - - if quantity > 0: - # Check if item fits in inventory - item_def = ITEMS_MANAGER.get_item(loot_item['item_id']) - if item_def: - item_weight = item_def.weight * quantity - item_volume = item_def.volume * quantity - - if current_weight + item_weight > max_weight or current_volume + item_volume > max_volume: - # Item doesn't fit - drop it on ground - await db.add_dropped_item(player['location_id'], loot_item['item_id'], quantity) - dropped_items.append({ - 'item_id': loot_item['item_id'], - 'quantity': quantity, - 'emoji': item_def.emoji - }) - else: - # Item fits - add to inventory - await db.add_item_to_inventory(player['id'], loot_item['item_id'], quantity) - current_weight += item_weight - current_volume += item_volume - looted_items.append({ - 'item_id': loot_item['item_id'], - 'quantity': quantity - }) - else: - # Keep in corpse - remaining_loot.append(loot_item) - - # Update or remove corpse - if remaining_loot: - await db.update_npc_corpse(corpse_db_id, json.dumps(remaining_loot)) - else: - await db.remove_npc_corpse(corpse_db_id) - - # Build response message - message_parts = [] - for item in looted_items: - item_def = ITEMS_MANAGER.get_item(item['item_id']) - item_name = item_def.name if item_def else item['item_id'] - message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}") - - dropped_parts = [] - for item in dropped_items: - item_def = ITEMS_MANAGER.get_item(item['item_id']) - item_name = item_def.name if item_def else item['item_id'] - dropped_parts.append(f"{item.get('emoji', '๐Ÿ“ฆ')} {item_name} x{item['quantity']}") - - message = "" - if message_parts: - message = "Looted: " + ", ".join(message_parts) - if dropped_parts: - if message: - message += "\n" - message += "โš ๏ธ Backpack full! Dropped on ground: " + ", ".join(dropped_parts) - if not message_parts and not dropped_parts: - message = "Nothing could be looted" - if remaining_loot and req.item_index is None: - message += f"\n{len(remaining_loot)} item(s) require tools to extract" - - # Broadcast to location about corpse looting - if len(remaining_loot) == 0: - # Corpse fully looted - await manager.send_to_location( - location_id=player['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} fully looted an NPC corpse", - "action": "corpse_looted" - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=player['id'] - ) - - return { - "success": True, - "message": message, - "looted_items": looted_items, - "dropped_items": dropped_items, - "tools_consumed": tools_consumed, - "corpse_empty": len(remaining_loot) == 0, - "remaining_count": len(remaining_loot) - } - - elif corpse_type == 'player': - # Get player corpse - corpse = await db.get_player_corpse(corpse_db_id) - if not corpse: - raise HTTPException(status_code=404, detail="Corpse not found") - - if corpse['location_id'] != player['location_id']: - raise HTTPException(status_code=400, detail="Corpse not at this location") - - # Parse items - items = json.loads(corpse['items']) if corpse['items'] else [] - - if not items: - raise HTTPException(status_code=400, detail="Corpse has no items") - - looted_items = [] - remaining_items = [] - dropped_items = [] # Items that couldn't fit in inventory - - # If specific item index provided, loot only that item - if req.item_index is not None: - if req.item_index < 0 or req.item_index >= len(items): - raise HTTPException(status_code=400, detail="Invalid item index") - - item = items[req.item_index] - - # Check if item fits in inventory - item_def = ITEMS_MANAGER.get_item(item['item_id']) - if item_def: - item_weight = item_def.weight * item['quantity'] - item_volume = item_def.volume * item['quantity'] - - if current_weight + item_weight > max_weight or current_volume + item_volume > max_volume: - # Item doesn't fit - drop it on ground - await db.add_dropped_item(player['location_id'], item['item_id'], item['quantity']) - dropped_items.append({ - 'item_id': item['item_id'], - 'quantity': item['quantity'], - 'emoji': item_def.emoji - }) - else: - # Item fits - add to inventory - await db.add_item_to_inventory(player['id'], item['item_id'], item['quantity']) - looted_items.append(item) - - # Remove this item, keep others - remaining_items = [it for i, it in enumerate(items) if i != req.item_index] - else: - # Loot all items - for item in items: - # Check if item fits in inventory - item_def = ITEMS_MANAGER.get_item(item['item_id']) - if item_def: - item_weight = item_def.weight * item['quantity'] - item_volume = item_def.volume * item['quantity'] - - if current_weight + item_weight > max_weight or current_volume + item_volume > max_volume: - # Item doesn't fit - drop it on ground - await db.add_dropped_item(player['location_id'], item['item_id'], item['quantity']) - dropped_items.append({ - 'item_id': item['item_id'], - 'quantity': item['quantity'], - 'emoji': item_def.emoji - }) - else: - # Item fits - add to inventory - await db.add_item_to_inventory(player['id'], item['item_id'], item['quantity']) - current_weight += item_weight - current_volume += item_volume - looted_items.append(item) - - # Update or remove corpse - if remaining_items: - await db.update_player_corpse(corpse_db_id, json.dumps(remaining_items)) - else: - await db.remove_player_corpse(corpse_db_id) - - # Build message - message_parts = [] - for item in looted_items: - item_def = ITEMS_MANAGER.get_item(item['item_id']) - item_name = item_def.name if item_def else item['item_id'] - message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}") - - dropped_parts = [] - for item in dropped_items: - item_def = ITEMS_MANAGER.get_item(item['item_id']) - item_name = item_def.name if item_def else item['item_id'] - dropped_parts.append(f"{item.get('emoji', '๐Ÿ“ฆ')} {item_name} x{item['quantity']}") - - message = "" - if message_parts: - message = "Looted: " + ", ".join(message_parts) - if dropped_parts: - if message: - message += "\n" - message += "โš ๏ธ Backpack full! Dropped on ground: " + ", ".join(dropped_parts) - if not message_parts and not dropped_parts: - message = "Nothing could be looted" - - # Broadcast to location about corpse looting - if len(remaining_items) == 0: - # Corpse fully looted - broadcast removal - await manager.send_to_location( - location_id=player['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} fully looted {corpse['player_name']}'s corpse", - "action": "player_corpse_emptied", - "corpse_id": req.corpse_id - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=player['id'] - ) - else: - # Corpse partially looted - broadcast item updates - await manager.send_to_location( - location_id=player['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} looted from {corpse['player_name']}'s corpse", - "action": "player_corpse_looted", - "corpse_id": req.corpse_id, - "remaining_items": remaining_items, - "looted_item_ids": [item['item_id'] for item in looted_items] if req.item_index is not None else None - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=player['id'] - ) - - return { - "success": True, - "message": message, - "looted_items": looted_items, - "dropped_items": dropped_items, - "corpse_empty": len(remaining_items) == 0, - "remaining_count": len(remaining_items) - } - - else: - raise HTTPException(status_code=400, detail="Invalid corpse type") - - -# ============================================================================ -# Combat Endpoints -# ============================================================================ - -@app.get("/api/game/combat") -async def get_combat_status(current_user: dict = Depends(get_current_user)): - """Get current combat status""" - combat = await db.get_active_combat(current_user['id']) - if not combat: - return {"in_combat": False} - - # Load NPC data from npcs.json - import sys - sys.path.insert(0, '/app') - from data.npcs import NPCS - npc_def = NPCS.get(combat['npc_id']) - - return { - "in_combat": True, - "combat": { - "npc_id": combat['npc_id'], - "npc_name": npc_def.name if npc_def else combat['npc_id'].replace('_', ' ').title(), - "npc_hp": combat['npc_hp'], - "npc_max_hp": combat['npc_max_hp'], - "npc_image": f"/images/npcs/{combat['npc_id']}.png" if npc_def else None, - "turn": combat['turn'], - "round": combat.get('round', 1) - } - } - - -@app.post("/api/game/combat/initiate") -async def initiate_combat( - req: InitiateCombatRequest, - current_user: dict = Depends(get_current_user) -): - """Start combat with a wandering enemy""" - import random - import sys - sys.path.insert(0, '/app') - from data.npcs import NPCS - - # Check if already in combat - existing_combat = await db.get_active_combat(current_user['id']) - if existing_combat: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Already in combat" - ) - - # Get enemy from wandering_enemies table - async with db.DatabaseSession() as session: - from sqlalchemy import select - stmt = select(db.wandering_enemies).where(db.wandering_enemies.c.id == req.enemy_id) - result = await session.execute(stmt) - enemy = result.fetchone() - - if not enemy: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Enemy not found" - ) - - # Get NPC definition - npc_def = NPCS.get(enemy.npc_id) - if not npc_def: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="NPC definition not found" - ) - - # Randomize HP - npc_hp = random.randint(npc_def.hp_min, npc_def.hp_max) - - # Create combat - combat = await db.create_combat( - player_id=current_user['id'], - npc_id=enemy.npc_id, - npc_hp=npc_hp, - npc_max_hp=npc_hp, - location_id=current_user['location_id'], - from_wandering=True - ) - - # Remove the wandering enemy from the location - async with db.DatabaseSession() as session: - from sqlalchemy import delete - stmt = delete(db.wandering_enemies).where(db.wandering_enemies.c.id == req.enemy_id) - await session.execute(stmt) - await session.commit() - - # Track combat initiation - await db.update_player_statistics(current_user['id'], combats_initiated=1, increment=True) - - # Get player info for broadcasts - player = current_user # current_user is already the character dict - - # Send WebSocket update to the player - await manager.send_personal_message(current_user['id'], { - "type": "combat_started", - "data": { - "message": f"Combat started with {npc_def.name}!", - "combat": { - "npc_id": enemy.npc_id, - "npc_name": npc_def.name, - "npc_hp": npc_hp, - "npc_max_hp": npc_hp, - "npc_image": f"/images/npcs/{enemy.npc_id}.png", - "turn": "player", - "round": 1 - } - }, - "timestamp": datetime.utcnow().isoformat() - }) - - # Broadcast to location that player entered combat - await manager.send_to_location( - location_id=current_user['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} entered combat with {npc_def.name}", - "action": "combat_started", - "player_id": player['id'] - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=current_user['id'] - ) - - return { - "success": True, - "message": f"Combat started with {npc_def.name}!", - "combat": { - "npc_id": enemy.npc_id, - "npc_name": npc_def.name, - "npc_hp": npc_hp, - "npc_max_hp": npc_hp, - "npc_image": f"/images/npcs/{enemy.npc_id}.png", - "turn": "player", - "round": 1 - } - } - - -@app.post("/api/game/combat/action") -async def combat_action( - req: CombatActionRequest, - current_user: dict = Depends(get_current_user) -): - """Perform a combat action""" - import random - import sys - sys.path.insert(0, '/app') - from data.npcs import NPCS - - # Get active combat - combat = await db.get_active_combat(current_user['id']) - if not combat: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Not in combat" - ) - - if combat['turn'] != 'player': - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Not your turn" - ) - - # Get player and NPC data - player = current_user # current_user is already the character dict - npc_def = NPCS.get(combat['npc_id']) - - result_message = "" - combat_over = False - player_won = False - - if req.action == 'attack': - # Calculate player damage - base_damage = 5 - strength_bonus = player['strength'] // 2 - level_bonus = player['level'] - weapon_damage = 0 - weapon_effects = {} - weapon_inv_id = None - - # Check for equipped weapon - equipment = await db.get_all_equipment(player['id']) - if equipment.get('weapon') and equipment['weapon']: - weapon_slot = equipment['weapon'] - inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id']) - if inv_item: - weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - if weapon_def and weapon_def.stats: - weapon_damage = random.randint( - weapon_def.stats.get('damage_min', 0), - weapon_def.stats.get('damage_max', 0) - ) - weapon_effects = weapon_def.weapon_effects if hasattr(weapon_def, 'weapon_effects') else {} - weapon_inv_id = weapon_slot['item_id'] - - # Check encumbrance penalty (higher encumbrance = chance to miss) - encumbrance = player.get('encumbrance', 0) - attack_failed = False - if encumbrance > 0: - miss_chance = min(0.3, encumbrance * 0.05) # Max 30% miss chance - if random.random() < miss_chance: - attack_failed = True - - variance = random.randint(-2, 2) - damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance) - - if attack_failed: - result_message = f"Your attack misses due to heavy encumbrance! " - new_npc_hp = combat['npc_hp'] - else: - # Apply damage to NPC - new_npc_hp = max(0, combat['npc_hp'] - damage) - result_message = f"You attack for {damage} damage! " - - # Apply weapon effects - if weapon_effects and 'bleeding' in weapon_effects: - bleeding = weapon_effects['bleeding'] - if random.random() < bleeding.get('chance', 0): - # Apply bleeding effect (would need combat effects table, for now just bonus damage) - bleed_damage = bleeding.get('damage', 0) - new_npc_hp = max(0, new_npc_hp - bleed_damage) - result_message += f"๐Ÿ’‰ Bleeding effect! +{bleed_damage} damage! " - - # Decrease weapon durability (from unique_item) - if weapon_inv_id and inv_item.get('unique_item_id'): - new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1) - if new_durability is None: - # Weapon broke (unique_item was deleted, cascades to inventory) - result_message += "\nโš ๏ธ Your weapon broke! " - await db.unequip_item(player['id'], 'weapon') - - if new_npc_hp <= 0: - # NPC defeated - result_message += f"{npc_def.name} has been defeated!" - combat_over = True - player_won = True - - # Award XP - xp_gained = npc_def.xp_reward - new_xp = player['xp'] + xp_gained - result_message += f"\n+{xp_gained} XP" - - await db.update_player(player['id'], xp=new_xp) - - # Track kill statistics - await db.update_player_statistics(player['id'], enemies_killed=1, damage_dealt=damage, increment=True) - - # Check for level up - level_up_result = await game_logic.check_and_apply_level_up(player['id']) - if level_up_result['leveled_up']: - result_message += f"\n๐ŸŽ‰ Level Up! You are now level {level_up_result['new_level']}!" - result_message += f"\n+{level_up_result['levels_gained']} stat point(s) to spend!" - - # Create corpse with loot - import json - corpse_loot = npc_def.corpse_loot if hasattr(npc_def, 'corpse_loot') else [] - # Convert CorpseLoot objects to dicts - corpse_loot_dicts = [] - for loot in corpse_loot: - if hasattr(loot, '__dict__'): - corpse_loot_dicts.append({ - 'item_id': loot.item_id, - 'quantity_min': loot.quantity_min, - 'quantity_max': loot.quantity_max, - 'required_tool': loot.required_tool - }) - else: - corpse_loot_dicts.append(loot) - await db.create_npc_corpse( - npc_id=combat['npc_id'], - location_id=player['location_id'], - loot_remaining=json.dumps(corpse_loot_dicts) - ) - - await db.end_combat(player['id']) - - # Update Redis: Delete combat state cache - if redis_manager: - await redis_manager.delete_combat_state(player['id']) - # Update player session - await redis_manager.update_player_session_field(player['id'], 'xp', new_xp) - if level_up_result['leveled_up']: - await redis_manager.update_player_session_field(player['id'], 'level', level_up_result['new_level']) - - # Broadcast to location that combat ended and corpse appeared - await manager.send_to_location( - location_id=player['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} defeated {npc_def.name}", - "action": "combat_ended", - "player_id": player['id'], - "corpse_created": True - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=player['id'] - ) - - else: - # NPC's turn - use shared logic - npc_attack_message, player_defeated = await game_logic.npc_attack( - player['id'], - {'npc_hp': new_npc_hp, 'npc_max_hp': combat['npc_max_hp']}, - npc_def, - reduce_armor_durability - ) - result_message += f"\n{npc_attack_message}" - - if player_defeated: - combat_over = True - else: - # Update NPC HP (combat turn already updated by npc_attack) - await db.update_combat(player['id'], { - 'npc_hp': new_npc_hp - }) - - elif req.action == 'flee': - # 50% chance to flee - if random.random() < 0.5: - result_message = "You successfully fled from combat!" - combat_over = True - player_won = False # Fled, not won - - # Track successful flee - await db.update_player_statistics(player['id'], successful_flees=1, increment=True) - - # Respawn the enemy back to the location if it came from wandering - if combat.get('from_wandering_enemy'): - # Respawn enemy with current HP at the combat location - import time - despawn_time = time.time() + 300 # 5 minutes - async with db.DatabaseSession() as session: - from sqlalchemy import insert - stmt = insert(db.wandering_enemies).values( - npc_id=combat['npc_id'], - location_id=combat['location_id'], - spawn_timestamp=time.time(), - despawn_timestamp=despawn_time - ) - await session.execute(stmt) - await session.commit() - - await db.end_combat(player['id']) - - # Broadcast to location that player fled from combat - await manager.send_to_location( - location_id=combat['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} fled from combat", - "action": "combat_fled", - "player_id": player['id'] - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=player['id'] - ) - else: - # Failed to flee, NPC attacks - npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max) - new_player_hp = max(0, player['hp'] - npc_damage) - result_message = f"Failed to flee! {npc_def.name} attacks for {npc_damage} damage!" - - if new_player_hp <= 0: - result_message += "\nYou have been defeated!" - combat_over = True - await db.update_player(player['id'], hp=0, is_dead=True) - await db.update_player_statistics(player['id'], deaths=1, failed_flees=1, damage_taken=npc_damage, increment=True) - - # Create corpse with player's inventory - import json - import time as time_module - inventory = await db.get_inventory(player['id']) - inventory_items = [] - for inv_item in inventory: - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - inventory_items.append({ - 'item_id': inv_item['item_id'], - 'name': item_def.name if item_def else inv_item['item_id'], - 'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '๐Ÿ“ฆ', - 'quantity': inv_item['quantity'], - 'durability': inv_item.get('durability'), - 'max_durability': inv_item.get('max_durability'), - 'tier': inv_item.get('tier') - }) - - logger.info(f"Creating player corpse (failed flee) for {player['name']} at {combat['location_id']} with {len(inventory_items)} items") - - corpse_id = await db.create_player_corpse( - player_name=player['name'], - location_id=combat['location_id'], - items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) - ) - - logger.info(f"Successfully created player corpse (failed flee): ID={corpse_id}, player={player['name']}, location={combat['location_id']}, items_count={len(inventory_items)}") - - # Clear player's inventory (items are now in corpse) - await db.clear_inventory(player['id']) - - # Build corpse data for broadcast - corpse_data = { - "id": f"player_{corpse_id}", - "type": "player", - "name": f"{player['name']}'s Corpse", - "emoji": "โšฐ๏ธ", - "player_name": player['name'], - "loot_count": len(inventory_items), - "items": inventory_items, - "timestamp": time_module.time() - } - - # Respawn enemy if from wandering - if combat.get('from_wandering_enemy'): - import time - despawn_time = time.time() + 300 - async with db.DatabaseSession() as session: - from sqlalchemy import insert - stmt = insert(db.wandering_enemies).values( - npc_id=combat['npc_id'], - location_id=combat['location_id'], - spawn_timestamp=time.time(), - despawn_timestamp=despawn_time - ) - await session.execute(stmt) - await session.commit() - - await db.end_combat(player['id']) - - # Broadcast to location that player died and corpse appeared - logger.info(f"Broadcasting player_died (failed flee) to location {combat['location_id']} for player {player['name']}") - await manager.send_to_location( - location_id=combat['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} was defeated in combat", - "action": "player_died", - "player_id": player['id'], - "corpse": corpse_data - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=player['id'] - ) - else: - # Player survived, update HP and turn back to player - await db.update_player(player['id'], hp=new_player_hp) - await db.update_player_statistics(player['id'], failed_flees=1, damage_taken=npc_damage, increment=True) - await db.update_combat(player['id'], {'turn': 'player'}) - - # Get updated combat state if not over - updated_combat = None - if not combat_over: - raw_combat = await db.get_active_combat(current_user['id']) - if raw_combat: - updated_combat = { - "npc_id": raw_combat['npc_id'], - "npc_name": npc_def.name, - "npc_hp": raw_combat['npc_hp'], - "npc_max_hp": raw_combat['npc_max_hp'], - "npc_image": f"/images/npcs/{raw_combat['npc_id']}.png", - "turn": raw_combat['turn'] - } - - # Get fresh player data with updated HP after NPC attack - updated_player = await db.get_player_by_id(current_user['id']) - if not updated_player: - updated_player = current_user # Fallback to current_user if something went wrong - - # Broadcast combat update via WebSocket - await manager.send_personal_message(current_user['id'], { - "type": "combat_update", - "data": { - "message": result_message, - "log_entry": result_message, # This should be APPENDED to combat log, not replace it - "combat_over": combat_over, - "player_won": player_won if combat_over else None, - "combat": updated_combat, - "player": { - "hp": updated_player['hp'], - "xp": updated_player['xp'], - "level": updated_player['level'] - } - }, - "timestamp": datetime.utcnow().isoformat() - }) - - return { - "success": True, - "message": result_message, - "combat_over": combat_over, - "player_won": player_won if combat_over else None, - "combat": updated_combat if updated_combat else None - } - - -# ============================================================================ -# PvP Combat Endpoints -# ============================================================================ - -class PvPCombatInitiateRequest(BaseModel): - target_player_id: int - - -@app.post("/api/game/pvp/initiate") -async def initiate_pvp_combat( - req: PvPCombatInitiateRequest, - current_user: dict = Depends(get_current_user) -): - """Initiate PvP combat with another player""" - # Get attacker (current user) - attacker = await db.get_player_by_id(current_user['id']) - if not attacker: - raise HTTPException(status_code=404, detail="Player not found") - - # Check if attacker is already in combat - existing_combat = await db.get_active_combat(attacker['id']) - if existing_combat: - raise HTTPException(status_code=400, detail="You are already in PvE combat") - - existing_pvp = await db.get_pvp_combat_by_player(attacker['id']) - if existing_pvp: - raise HTTPException(status_code=400, detail="You are already in PvP combat") - - # Get defender (target player) - defender = await db.get_player_by_id(req.target_player_id) - if not defender: - raise HTTPException(status_code=404, detail="Target player not found") - - # Check if defender is in combat - defender_pve = await db.get_active_combat(defender['id']) - if defender_pve: - raise HTTPException(status_code=400, detail="Target player is in PvE combat") - - defender_pvp = await db.get_pvp_combat_by_player(defender['id']) - if defender_pvp: - raise HTTPException(status_code=400, detail="Target player is in PvP combat") - - # Check same location - if attacker['location_id'] != defender['location_id']: - raise HTTPException(status_code=400, detail="Target player is not in your location") - - # Check danger level (>= 3 required for PvP) - location = LOCATIONS.get(attacker['location_id']) - if not location or location.danger_level < 3: - raise HTTPException(status_code=400, detail="PvP combat is only allowed in dangerous zones (danger level >= 3)") - - # Check level difference (+/- 3 levels) - level_diff = abs(attacker['level'] - defender['level']) - if level_diff > 3: - raise HTTPException( - status_code=400, - detail=f"Level difference too large! You can only fight players within 3 levels (target is level {defender['level']})" - ) - - # Create PvP combat - pvp_combat = await db.create_pvp_combat( - attacker_id=attacker['id'], - defender_id=defender['id'], - location_id=attacker['location_id'], - turn_timeout=300 # 5 minutes - ) - - # Track PvP combat initiation - await db.update_player_statistics(attacker['id'], pvp_combats_initiated=1, increment=True) - - # Send WebSocket notifications to both players - await manager.send_personal_message(attacker['id'], { - "type": "combat_started", - "data": { - "message": f"You have initiated combat with {defender['name']}! They get the first turn.", - "pvp_combat": pvp_combat - }, - "timestamp": datetime.utcnow().isoformat() - }) - - await manager.send_personal_message(defender['id'], { - "type": "combat_started", - "data": { - "message": f"{attacker['name']} has challenged you to PvP combat! It's your turn.", - "pvp_combat": pvp_combat - }, - "timestamp": datetime.utcnow().isoformat() - }) - - return { - "success": True, - "message": f"You have initiated combat with {defender['name']}! They get the first turn.", - "pvp_combat": pvp_combat - } - - -@app.get("/api/game/pvp/status") -async def get_pvp_combat_status(current_user: dict = Depends(get_current_user)): - """Get current PvP combat status""" - pvp_combat = await db.get_pvp_combat_by_player(current_user['id']) - if not pvp_combat: - return {"in_pvp_combat": False, "pvp_combat": None} - - # Check if current player has already acknowledged - if so, don't show combat anymore - is_attacker = pvp_combat['attacker_character_id'] == current_user['id'] - if (is_attacker and pvp_combat.get('attacker_acknowledged', False)) or \ - (not is_attacker and pvp_combat.get('defender_acknowledged', False)): - return {"in_pvp_combat": False, "pvp_combat": None} - - # Get both players' data - attacker = await db.get_player_by_id(pvp_combat['attacker_character_id']) - defender = await db.get_player_by_id(pvp_combat['defender_character_id']) - - # Determine if current user is attacker or defender - is_attacker = pvp_combat['attacker_character_id'] == current_user['id'] - your_turn = (is_attacker and pvp_combat['turn'] == 'attacker') or \ - (not is_attacker and pvp_combat['turn'] == 'defender') - - # Calculate time remaining for turn - import time - time_elapsed = time.time() - pvp_combat['turn_started_at'] - time_remaining = max(0, pvp_combat['turn_timeout_seconds'] - time_elapsed) - - # Auto-advance if time expired - if time_remaining == 0 and your_turn: - # Skip turn - new_turn = 'defender' if is_attacker else 'attacker' - await db.update_pvp_combat(pvp_combat['id'], { - 'turn': new_turn, - 'turn_started_at': time.time() - }) - pvp_combat = await db.get_pvp_combat_by_id(pvp_combat['id']) - your_turn = False - time_remaining = pvp_combat['turn_timeout_seconds'] - - return { - "in_pvp_combat": True, - "pvp_combat": { - "id": pvp_combat['id'], - "attacker": { - "id": attacker['id'], - "username": attacker['name'], - "level": attacker['level'], - "hp": attacker['hp'], # Use actual player HP - "max_hp": attacker['max_hp'] - }, - "defender": { - "id": defender['id'], - "username": defender['name'], - "level": defender['level'], - "hp": defender['hp'], # Use actual player HP - "max_hp": defender['max_hp'] - }, - "is_attacker": is_attacker, - "your_turn": your_turn, - "current_turn": pvp_combat['turn'], - "time_remaining": int(time_remaining), - "location_id": pvp_combat['location_id'], - "last_action": pvp_combat.get('last_action'), - "combat_over": pvp_combat.get('attacker_fled', False) or pvp_combat.get('defender_fled', False) or \ - attacker['hp'] <= 0 or defender['hp'] <= 0, - "attacker_fled": pvp_combat.get('attacker_fled', False), - "defender_fled": pvp_combat.get('defender_fled', False) - } - } - - -class PvPAcknowledgeRequest(BaseModel): - combat_id: int - - -@app.post("/api/game/pvp/acknowledge") -async def acknowledge_pvp_combat( - req: PvPAcknowledgeRequest, - current_user: dict = Depends(get_current_user) -): - """Acknowledge PvP combat end""" - await db.acknowledge_pvp_combat(req.combat_id, current_user['id']) - - # Broadcast to location that player has returned - player = current_user # current_user is already the character dict - if player: - await manager.send_to_location( - location_id=player['location_id'], - message={ - "type": "player_arrived", - "data": { - "player_id": player['id'], - "username": player['name'], - "message": f"{player['name']} has returned from PvP combat." - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=player['id'] - ) - - return {"success": True} - - -class PvPCombatActionRequest(BaseModel): - action: str # 'attack', 'flee', 'use_item' - item_id: Optional[str] = None # For use_item action - - -@app.post("/api/game/pvp/action") -async def pvp_combat_action( - req: PvPCombatActionRequest, - current_user: dict = Depends(get_current_user) -): - """Perform a PvP combat action""" - import random - import time - - # Get PvP combat - pvp_combat = await db.get_pvp_combat_by_player(current_user['id']) - if not pvp_combat: - raise HTTPException(status_code=400, detail="Not in PvP combat") - - # Determine roles - is_attacker = pvp_combat['attacker_character_id'] == current_user['id'] - your_turn = (is_attacker and pvp_combat['turn'] == 'attacker') or \ - (not is_attacker and pvp_combat['turn'] == 'defender') - - if not your_turn: - raise HTTPException(status_code=400, detail="It's not your turn") - - # Get both players - attacker = await db.get_player_by_id(pvp_combat['attacker_character_id']) - defender = await db.get_player_by_id(pvp_combat['defender_character_id']) - current_player = attacker if is_attacker else defender - opponent = defender if is_attacker else attacker - - result_message = "" - combat_over = False - winner_id = None - - if req.action == 'attack': - # Calculate damage (similar to PvE) - base_damage = 5 - strength_bonus = current_player['strength'] * 2 - level_bonus = current_player['level'] - - # Check for equipped weapon - weapon_damage = 0 - equipment = await db.get_all_equipment(current_player['id']) - if equipment.get('weapon') and equipment['weapon']: - weapon_slot = equipment['weapon'] - inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id']) - if inv_item: - weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - if weapon_def and weapon_def.stats: - weapon_damage = random.randint( - weapon_def.stats.get('damage_min', 0), - weapon_def.stats.get('damage_max', 0) - ) - # Decrease weapon durability - if inv_item.get('unique_item_id'): - new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1) - if new_durability is None: - result_message += "โš ๏ธ Your weapon broke! " - await db.unequip_item(current_player['id'], 'weapon') - - variance = random.randint(-2, 2) - damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance) - - # Apply armor reduction and durability loss to opponent - armor_absorbed, broken_armor = await reduce_armor_durability(opponent['id'], damage) - actual_damage = max(1, damage - armor_absorbed) - - # Update opponent HP (use actual player HP, not pvp_combat fields) - new_opponent_hp = max(0, opponent['hp'] - actual_damage) - - # Update opponent's HP in database - await db.update_player(opponent['id'], hp=new_opponent_hp) - - # Store message with attacker's username so both players can see it correctly - stored_message = f"{current_player['name']} attacks {opponent['name']} for {damage} damage!" - if armor_absorbed > 0: - stored_message += f" (Armor absorbed {armor_absorbed})" - - for broken in broken_armor: - stored_message += f"\n๐Ÿ’” {opponent['name']}'s {broken['emoji']} {broken['name']} broke!" - - # Check if opponent defeated - if new_opponent_hp <= 0: - stored_message += f"\n๐Ÿ† {current_player['name']} has defeated {opponent['name']}!" - result_message = "Combat victory!" # Simple message, details in stored_message - combat_over = True - winner_id = current_player['id'] - - # Update opponent to dead state - await db.update_player(opponent['id'], hp=0, is_dead=True) - - # Create corpse with opponent's inventory - import json - import time as time_module - inventory = await db.get_inventory(opponent['id']) - inventory_items = [] - for inv_item in inventory: - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - inventory_items.append({ - 'item_id': inv_item['item_id'], - 'name': item_def.name if item_def else inv_item['item_id'], - 'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '๐Ÿ“ฆ', - 'quantity': inv_item['quantity'], - 'durability': inv_item.get('durability'), - 'max_durability': inv_item.get('max_durability'), - 'tier': inv_item.get('tier') - }) - - logger.info(f"Creating player corpse (PvP death) for {opponent['name']} at {opponent['location_id']} with {len(inventory_items)} items") - - corpse_id = await db.create_player_corpse( - player_name=opponent['name'], - location_id=opponent['location_id'], - items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) - ) - - logger.info(f"Successfully created player corpse (PvP death): ID={corpse_id}, player={opponent['name']}, location={opponent['location_id']}, items_count={len(inventory_items)}") - - # Clear opponent's inventory (items are now in corpse) - await db.clear_inventory(opponent['id']) - - # Build corpse data for broadcast - corpse_data = { - "id": f"player_{corpse_id}", - "type": "player", - "name": f"{opponent['name']}'s Corpse", - "emoji": "โšฐ๏ธ", - "player_name": opponent['name'], - "loot_count": len(inventory_items), - "items": inventory_items, - "timestamp": time_module.time() - } - - # Update PvP statistics for both players - await db.update_player_statistics(opponent['id'], - pvp_deaths=1, - pvp_combats_lost=1, - pvp_damage_taken=actual_damage, - pvp_attacks_received=1, - increment=True - ) - await db.update_player_statistics(current_player['id'], - players_killed=1, - pvp_combats_won=1, - pvp_damage_dealt=damage, - pvp_attacks_landed=1, - increment=True - ) - - # Broadcast to location that player died and corpse appeared - logger.info(f"Broadcasting player_died (PvP death) to location {opponent['location_id']} for player {opponent['name']}") - await manager.send_to_location( - location_id=opponent['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{opponent['name']} was defeated by {current_player['name']} in PvP combat", - "action": "player_died", - "player_id": opponent['id'], - "corpse": corpse_data - }, - "timestamp": datetime.utcnow().isoformat() - } - ) - - # End PvP combat - await db.end_pvp_combat(pvp_combat['id']) - else: - # Combat continues - don't return detailed message, it's in stored_message - result_message = "" # Empty message, frontend will show stored_message from polling - - # Update PvP statistics for attack - await db.update_player_statistics(current_player['id'], - pvp_damage_dealt=damage, - pvp_attacks_landed=1, - increment=True - ) - await db.update_player_statistics(opponent['id'], - pvp_damage_taken=actual_damage, - pvp_attacks_received=1, - increment=True - ) - - # Update combat state and switch turns - # Add timestamp to make each action unique for duplicate detection - updates = { - 'turn': 'defender' if is_attacker else 'attacker', - 'turn_started_at': time.time(), - 'last_action': f"{stored_message}|{time.time()}" # Add timestamp for uniqueness - } - # No need to update HP in pvp_combat - we use player HP directly - - await db.update_pvp_combat(pvp_combat['id'], updates) - await db.update_player_statistics(current_player['id'], damage_dealt=damage, increment=True) - - elif req.action == 'flee': - # 50% chance to flee from PvP - if random.random() < 0.5: - result_message = f"You successfully fled from {opponent['name']}!" - combat_over = True - - # Mark as fled, store last action with timestamp, and end combat - flee_field = 'attacker_fled' if is_attacker else 'defender_fled' - await db.update_pvp_combat(pvp_combat['id'], { - flee_field: True, - 'last_action': f"{current_player['name']} fled from combat!|{time.time()}" - }) - await db.end_pvp_combat(pvp_combat['id']) - await db.update_player_statistics(current_player['id'], - pvp_successful_flees=1, - increment=True - ) - else: - # Failed to flee, skip turn - result_message = f"Failed to flee from {opponent['name']}!" - await db.update_pvp_combat(pvp_combat['id'], { - 'turn': 'defender' if is_attacker else 'attacker', - 'turn_started_at': time.time(), - 'last_action': f"{current_player['name']} tried to flee but failed!|{time.time()}" - }) - await db.update_player_statistics(current_player['id'], - pvp_failed_flees=1, - increment=True - ) - - # Send WebSocket combat updates to both players - # Get fresh PvP combat data - updated_pvp = await db.get_pvp_combat_by_id(pvp_combat['id']) - - # Get fresh player data for HP updates - fresh_attacker = await db.get_player_by_id(pvp_combat['attacker_character_id']) - fresh_defender = await db.get_player_by_id(pvp_combat['defender_character_id']) - - # Send to both players with enriched data (like the API endpoint does) - for player_id in [pvp_combat['attacker_character_id'], pvp_combat['defender_character_id']]: - is_attacker = player_id == pvp_combat['attacker_character_id'] - your_turn = (is_attacker and updated_pvp['turn'] == 'attacker') or \ - (not is_attacker and updated_pvp['turn'] == 'defender') - - # Calculate time remaining - import time - time_elapsed = time.time() - updated_pvp['turn_started_at'] - time_remaining = max(0, updated_pvp['turn_timeout_seconds'] - time_elapsed) - - # Build enriched pvp_combat object like the API does - enriched_pvp = { - "id": updated_pvp['id'], - "attacker": { - "id": fresh_attacker['id'], - "username": fresh_attacker['name'], - "level": fresh_attacker['level'], - "hp": fresh_attacker['hp'], - "max_hp": fresh_attacker['max_hp'] - }, - "defender": { - "id": fresh_defender['id'], - "username": fresh_defender['name'], - "level": fresh_defender['level'], - "hp": fresh_defender['hp'], - "max_hp": fresh_defender['max_hp'] - }, - "is_attacker": is_attacker, - "your_turn": your_turn, - "current_turn": updated_pvp['turn'], - "time_remaining": int(time_remaining), - "location_id": updated_pvp['location_id'], - "last_action": updated_pvp.get('last_action'), - "combat_over": updated_pvp.get('attacker_fled', False) or updated_pvp.get('defender_fled', False) or \ - fresh_attacker['hp'] <= 0 or fresh_defender['hp'] <= 0, - "attacker_fled": updated_pvp.get('attacker_fled', False), - "defender_fled": updated_pvp.get('defender_fled', False) - } - - await manager.send_personal_message(player_id, { - "type": "combat_update", - "data": { - "message": result_message if player_id == current_user['id'] else "", - "log_entry": result_message if player_id == current_user['id'] else "", # Append to combat log - "pvp_combat": enriched_pvp, - "combat_over": combat_over, - "winner_id": winner_id, - "attacker_hp": fresh_attacker['hp'], - "defender_hp": fresh_defender['hp'] - }, - "timestamp": datetime.utcnow().isoformat() - }) - - return { - "success": True, - "message": result_message, - "combat_over": combat_over, - "winner_id": winner_id - } - - -@app.get("/api/game/inventory") -async def get_inventory(current_user: dict = Depends(get_current_user)): - """Get player inventory""" - inventory = await db.get_inventory(current_user['id']) - - # Enrich with item data - inventory_items = [] - for inv_item in inventory: - item = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item: - item_data = { - "id": inv_item['id'], - "item_id": item.id, - "name": item.name, - "description": item.description, - "type": item.type, - "quantity": inv_item['quantity'], - "is_equipped": inv_item['is_equipped'], - "equippable": item.equippable, - "consumable": item.consumable, - "image_path": item.image_path, - "emoji": item.emoji if hasattr(item, 'emoji') else None, - "weight": item.weight if hasattr(item, 'weight') else 0, - "volume": item.volume if hasattr(item, 'volume') else 0, - "uncraftable": getattr(item, 'uncraftable', False), - "inventory_id": inv_item['id'], - "unique_item_id": inv_item.get('unique_item_id') - } - # Add combat/consumable stats if they exist - if hasattr(item, 'hp_restore'): - item_data["hp_restore"] = item.hp_restore - if hasattr(item, 'stamina_restore'): - item_data["stamina_restore"] = item.stamina_restore - if hasattr(item, 'damage_min'): - item_data["damage_min"] = item.damage_min - if hasattr(item, 'damage_max'): - item_data["damage_max"] = item.damage_max - - # Add tier if unique item - if inv_item.get('unique_item_id'): - unique_item = await db.get_unique_item(inv_item['unique_item_id']) - if unique_item: - item_data["tier"] = unique_item.get('tier', 1) - item_data["durability"] = unique_item.get('durability', 0) - item_data["max_durability"] = unique_item.get('max_durability', 100) - - # Add uncraft data if uncraftable - if getattr(item, 'uncraftable', False): - uncraft_yield = getattr(item, 'uncraft_yield', []) - uncraft_tools = getattr(item, 'uncraft_tools', []) - - # Format materials - yield_materials = [] - for mat in uncraft_yield: - mat_def = ITEMS_MANAGER.get_item(mat['item_id']) - yield_materials.append({ - 'item_id': mat['item_id'], - 'name': mat_def.name if mat_def else mat['item_id'], - 'emoji': mat_def.emoji if mat_def else '๐Ÿ“ฆ', - 'quantity': mat['quantity'] - }) - - # Check tools availability - tools_info = [] - can_uncraft = True - for tool_req in uncraft_tools: - tool_id = tool_req['item_id'] - durability_cost = tool_req['durability_cost'] - tool_def = ITEMS_MANAGER.get_item(tool_id) - - # Check if player has this tool - tool_found = False - tool_durability = 0 - for check_item in inventory: - if check_item['item_id'] == tool_id and check_item.get('unique_item_id'): - unique = await db.get_unique_item(check_item['unique_item_id']) - if unique and unique.get('durability', 0) >= durability_cost: - tool_found = True - tool_durability = unique.get('durability', 0) - break - - tools_info.append({ - 'item_id': tool_id, - 'name': tool_def.name if tool_def else tool_id, - 'emoji': tool_def.emoji if tool_def else '๐Ÿ”ง', - 'durability_cost': durability_cost, - 'has_tool': tool_found, - 'tool_durability': tool_durability - }) - if not tool_found: - can_uncraft = False - - item_data["uncraft_yield"] = yield_materials - item_data["uncraft_loss_chance"] = getattr(item, 'uncraft_loss_chance', 0.3) - item_data["uncraft_tools"] = tools_info - item_data["can_uncraft"] = can_uncraft - - inventory_items.append(item_data) - - return {"items": inventory_items} - - -@app.post("/api/game/item/drop") -async def drop_item( - drop_req: dict, - current_user: dict = Depends(get_current_user) -): - """Drop an item from inventory""" - player_id = current_user['id'] - item_id = drop_req.get('item_id') # This is the item_id string like "energy_bar" - quantity = drop_req.get('quantity', 1) - - # Get player to know their location - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - # Get inventory item by item_id (string), not database id - inventory = await db.get_inventory(player_id) - inv_item = None - for item in inventory: - if item['item_id'] == item_id: - inv_item = item - break - - if not inv_item: - raise HTTPException(status_code=404, detail="Item not found in inventory") - - if inv_item['quantity'] < quantity: - raise HTTPException(status_code=400, detail="Not enough items to drop") - - # For unique items, we need to handle each one individually - if inv_item.get('unique_item_id'): - # This is a unique item - drop it and remove from inventory by row ID - await db.add_dropped_item( - player['location_id'], - inv_item['item_id'], - 1, - unique_item_id=inv_item['unique_item_id'] - ) - # Remove this specific inventory row (not by item_id, by row id) - await db.remove_inventory_row(inv_item['id']) - else: - # Stackable item - drop the quantity requested - await db.add_dropped_item( - player['location_id'], - inv_item['item_id'], - quantity, - unique_item_id=None - ) - # Remove from inventory (handles quantity reduction automatically) - await db.remove_item_from_inventory(player_id, inv_item['item_id'], quantity) - - # Track drop statistics - await db.update_player_statistics(player_id, items_dropped=quantity, increment=True) - - # Invalidate inventory cache - if redis_manager: - await redis_manager.invalidate_inventory(player_id) - - # Get item details for broadcast - item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) - - # Broadcast to location that item was dropped - await manager.send_to_location( - location_id=player['location_id'], - message={ - "type": "location_update", - "data": { - "message": f"{player['name']} dropped {item_def.emoji} {item_def.name} x{quantity}", - "action": "item_dropped" - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=player_id - ) - - return { - "success": True, - "message": f"Dropped {item_def.emoji} {item_def.name} x{quantity}" - } - - -# ============================================================================ -# Internal API Endpoints (for bot communication) -# ============================================================================ - -async def verify_internal_key(authorization: str = Depends(security)): - """Verify internal API key""" - if authorization.credentials != API_INTERNAL_KEY: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid internal API key" - ) - return True - - -@app.get("/api/internal/player/by_id/{player_id}", dependencies=[Depends(verify_internal_key)]) -async def get_player_by_id(player_id: int): - """Get player by unique database ID (for bot)""" - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Player not found" - ) - return player - - -@app.get("/api/internal/player/{player_id}/combat", dependencies=[Depends(verify_internal_key)]) -async def get_player_combat(player_id: int): - """Get active combat for player (for bot)""" - combat = await db.get_active_combat(player_id) - return combat if combat else None - - -@app.post("/api/internal/combat/create", dependencies=[Depends(verify_internal_key)]) -async def create_combat(player_id: int, npc_id: str, npc_hp: int, npc_max_hp: int, location_id: str, from_wandering: bool = False): - """Create new combat (for bot)""" - combat = await db.create_combat(player_id, npc_id, npc_hp, npc_max_hp, location_id, from_wandering) - return combat - - -@app.patch("/api/internal/combat/{player_id}", dependencies=[Depends(verify_internal_key)]) -async def update_combat(player_id: int, updates: dict): - """Update combat state (for bot)""" - success = await db.update_combat(player_id, updates) - return {"success": success} - - -@app.delete("/api/internal/combat/{player_id}", dependencies=[Depends(verify_internal_key)]) -async def end_combat(player_id: int): - """End combat (for bot)""" - success = await db.end_combat(player_id) - return {"success": success} - - -@app.patch("/api/internal/player/{player_id}", dependencies=[Depends(verify_internal_key)]) -async def update_player(player_id: int, updates: dict): - """Update player fields (for bot)""" - success = await db.update_player(player_id, updates) - if not success: - raise HTTPException(status_code=404, detail="Player not found") - - # Return updated player - player = await db.get_player_by_id(player_id) - return player - - -@app.post("/api/internal/player/{player_id}/move", dependencies=[Depends(verify_internal_key)]) -async def bot_move_player(player_id: int, direction: str): - """Move player (for bot)""" - success, message, new_location_id, stamina_cost, distance = await game_logic.move_player( - player_id, - direction, - LOCATIONS - ) - - # Track distance for bot players too - if success: - await db.update_player_statistics(player_id, distance_walked=distance, increment=True) - - return { - "success": success, - "message": message, - "new_location_id": new_location_id - } - - -@app.get("/api/internal/player/{player_id}/inspect", dependencies=[Depends(verify_internal_key)]) -async def bot_inspect_area(player_id: int): - """Inspect area (for bot)""" - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - location = LOCATIONS.get(player['location_id']) - if not location: - raise HTTPException(status_code=404, detail="Location not found") - - message = await game_logic.inspect_area(player_id, location, {}) - return {"success": True, "message": message} - - -@app.post("/api/internal/player/{player_id}/interact", dependencies=[Depends(verify_internal_key)]) -async def bot_interact(player_id: int, interactable_id: str, action_id: str): - """Interact with object (for bot)""" - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - location = LOCATIONS.get(player['location_id']) - if not location: - raise HTTPException(status_code=404, detail="Location not found") - - result = await game_logic.interact_with_object( - player_id, - interactable_id, - action_id, - location, - ITEMS_MANAGER - ) - return result - - -@app.get("/api/internal/player/{player_id}/inventory", dependencies=[Depends(verify_internal_key)]) -async def bot_get_inventory(player_id: int): - """Get inventory (for bot)""" - inventory = await db.get_inventory(player_id) - - # Enrich with item data (include all properties for bot compatibility) - inventory_items = [] - for inv_item in inventory: - item = ITEMS_MANAGER.get_item(inv_item['item_id']) - if item: - inventory_items.append({ - "id": inv_item['id'], - "item_id": item.id, - "name": item.name, - "description": item.description, - "type": item.type, - "quantity": inv_item['quantity'], - "is_equipped": inv_item['is_equipped'], - "equippable": item.equippable, - "consumable": item.consumable, - "weight": getattr(item, 'weight', 0), - "volume": getattr(item, 'volume', 0), - "emoji": getattr(item, 'emoji', 'โ”'), - "damage_min": getattr(item, 'damage_min', 0), - "damage_max": getattr(item, 'damage_max', 0), - "hp_restore": getattr(item, 'hp_restore', 0), - "stamina_restore": getattr(item, 'stamina_restore', 0), - "treats": getattr(item, 'treats', None) - }) - - return {"success": True, "inventory": inventory_items} - - -@app.post("/api/internal/player/{player_id}/use_item", dependencies=[Depends(verify_internal_key)]) -async def bot_use_item(player_id: int, item_id: str): - """Use item (for bot)""" - result = await game_logic.use_item(player_id, item_id, ITEMS_MANAGER) - return result - - -@app.post("/api/internal/player/{player_id}/pickup", dependencies=[Depends(verify_internal_key)]) -async def bot_pickup_item(player_id: int, item_id: str): - """Pick up item (for bot)""" - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - result = await game_logic.pickup_item(player_id, item_id, player['location_id']) - return result - - -@app.post("/api/internal/player/{player_id}/drop_item", dependencies=[Depends(verify_internal_key)]) -async def bot_drop_item(player_id: int, item_id: str, quantity: int = 1): - """Drop item (for bot)""" - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - # Get the item from inventory - inventory = await db.get_inventory(player_id) - inv_item = next((i for i in inventory if i['item_id'] == item_id), None) - - if not inv_item or inv_item['quantity'] < quantity: - return {"success": False, "message": "You don't have that item"} - - # Remove from inventory - await db.remove_item_from_inventory(player_id, item_id, quantity) - - # Add to dropped items - await db.add_dropped_item(player['location_id'], item_id, quantity) - - item = ITEMS_MANAGER.get_item(item_id) - item_name = item.name if item else item_id - - return { - "success": True, - "message": f"You dropped {quantity}x {item_name}" - } - - -@app.post("/api/internal/player/{player_id}/equip", dependencies=[Depends(verify_internal_key)]) -async def bot_equip_item(player_id: int, item_id: str): - """Equip item (for bot)""" - # Get item info - item = ITEMS_MANAGER.get_item(item_id) - if not item or not item.equippable: - return {"success": False, "message": "This item cannot be equipped"} - - # Check inventory - inventory = await db.get_inventory(player_id) - inv_item = next((i for i in inventory if i['item_id'] == item_id), None) - - if not inv_item: - return {"success": False, "message": "You don't have this item"} - - if inv_item['is_equipped']: - return {"success": False, "message": "This item is already equipped"} - - # Unequip any item of the same type - for inv in inventory: - if inv['is_equipped']: - existing_item = ITEMS_MANAGER.get_item(inv['item_id']) - if existing_item and existing_item.type == item.type: - await db.update_item_equipped_status(player_id, inv['item_id'], False) - - # Equip the new item - await db.update_item_equipped_status(player_id, item_id, True) - - return {"success": True, "message": f"You equipped {item.name}"} - - -@app.post("/api/internal/player/{player_id}/unequip", dependencies=[Depends(verify_internal_key)]) -async def bot_unequip_item(player_id: int, item_id: str): - """Unequip item (for bot)""" - # Check inventory - inventory = await db.get_inventory(player_id) - inv_item = next((i for i in inventory if i['item_id'] == item_id), None) - - if not inv_item: - return {"success": False, "message": "You don't have this item"} - - if not inv_item['is_equipped']: - return {"success": False, "message": "This item is not equipped"} - - # Unequip the item - await db.update_item_equipped_status(player_id, item_id, False) - - item = ITEMS_MANAGER.get_item(item_id) - item_name = item.name if item else item_id - - return {"success": True, "message": f"You unequipped {item_name}"} - - -# ============================================================================ -# Dropped Items (Internal Bot API) -# ============================================================================ - -@app.post("/api/internal/dropped-items", dependencies=[Depends(verify_internal_key)]) -async def drop_item(item_id: str, quantity: int, location_id: str): - """Drop an item to the world (for bot)""" - success = await db.drop_item_to_world(item_id, quantity, location_id) - return {"success": success} - - -@app.get("/api/internal/dropped-items/{dropped_item_id}", dependencies=[Depends(verify_internal_key)]) -async def get_dropped_item(dropped_item_id: int): - """Get a specific dropped item (for bot)""" - item = await db.get_dropped_item(dropped_item_id) - if not item: - raise HTTPException(status_code=404, detail="Dropped item not found") - return item - - -@app.get("/api/internal/location/{location_id}/dropped-items", dependencies=[Depends(verify_internal_key)]) -async def get_dropped_items_in_location(location_id: str): - """Get all dropped items in a location (for bot)""" - items = await db.get_dropped_items_in_location(location_id) - return items - - -@app.patch("/api/internal/dropped-items/{dropped_item_id}", dependencies=[Depends(verify_internal_key)]) -async def update_dropped_item(dropped_item_id: int, quantity: int): - """Update dropped item quantity (for bot)""" - success = await db.update_dropped_item(dropped_item_id, quantity) - if not success: - raise HTTPException(status_code=404, detail="Dropped item not found") - return {"success": success} - - -@app.delete("/api/internal/dropped-items/{dropped_item_id}", dependencies=[Depends(verify_internal_key)]) -async def remove_dropped_item(dropped_item_id: int): - """Remove a dropped item (for bot)""" - success = await db.remove_dropped_item(dropped_item_id) - return {"success": success} - - -# ============================================================================ -# Corpses (Internal Bot API) -# ============================================================================ - -@app.post("/api/internal/corpses/player", dependencies=[Depends(verify_internal_key)]) -async def create_player_corpse(player_name: str, location_id: str, items: str): - """Create a player corpse (for bot)""" - corpse_id = await db.create_player_corpse(player_name, location_id, items) - return {"corpse_id": corpse_id} - - -@app.get("/api/internal/corpses/player/{corpse_id}", dependencies=[Depends(verify_internal_key)]) -async def get_player_corpse(corpse_id: int): - """Get a player corpse (for bot)""" - corpse = await db.get_player_corpse(corpse_id) - if not corpse: - raise HTTPException(status_code=404, detail="Player corpse not found") - return corpse - - -@app.patch("/api/internal/corpses/player/{corpse_id}", dependencies=[Depends(verify_internal_key)]) -async def update_player_corpse(corpse_id: int, items: str): - """Update player corpse items (for bot)""" - success = await db.update_player_corpse(corpse_id, items) - if not success: - raise HTTPException(status_code=404, detail="Player corpse not found") - return {"success": success} - - -@app.delete("/api/internal/corpses/player/{corpse_id}", dependencies=[Depends(verify_internal_key)]) -async def remove_player_corpse(corpse_id: int): - """Remove a player corpse (for bot)""" - success = await db.remove_player_corpse(corpse_id) - return {"success": success} - - -@app.post("/api/internal/corpses/npc", dependencies=[Depends(verify_internal_key)]) -async def create_npc_corpse(npc_id: str, location_id: str, loot_remaining: str): - """Create an NPC corpse (for bot)""" - corpse_id = await db.create_npc_corpse(npc_id, location_id, loot_remaining) - return {"corpse_id": corpse_id} - - -@app.get("/api/internal/corpses/npc/{corpse_id}", dependencies=[Depends(verify_internal_key)]) -async def get_npc_corpse(corpse_id: int): - """Get an NPC corpse (for bot)""" - corpse = await db.get_npc_corpse(corpse_id) - if not corpse: - raise HTTPException(status_code=404, detail="NPC corpse not found") - return corpse - - -@app.patch("/api/internal/corpses/npc/{corpse_id}", dependencies=[Depends(verify_internal_key)]) -async def update_npc_corpse(corpse_id: int, loot_remaining: str): - """Update NPC corpse loot (for bot)""" - success = await db.update_npc_corpse(corpse_id, loot_remaining) - if not success: - raise HTTPException(status_code=404, detail="NPC corpse not found") - return {"success": success} - - -@app.delete("/api/internal/corpses/npc/{corpse_id}", dependencies=[Depends(verify_internal_key)]) -async def remove_npc_corpse(corpse_id: int): - """Remove an NPC corpse (for bot)""" - success = await db.remove_npc_corpse(corpse_id) - return {"success": success} - - -# ============================================================================ -# Wandering Enemies (Internal Bot API) -# ============================================================================ - -@app.post("/api/internal/wandering-enemies", dependencies=[Depends(verify_internal_key)]) -async def spawn_wandering_enemy(npc_id: str, location_id: str, current_hp: int, max_hp: int): - """Spawn a wandering enemy (for bot)""" - enemy_id = await db.spawn_wandering_enemy(npc_id, location_id, current_hp, max_hp) - return {"enemy_id": enemy_id} - - -@app.get("/api/internal/location/{location_id}/wandering-enemies", dependencies=[Depends(verify_internal_key)]) -async def get_wandering_enemies_in_location(location_id: str): - """Get all wandering enemies in a location (for bot)""" - enemies = await db.get_wandering_enemies_in_location(location_id) - return enemies - - -@app.delete("/api/internal/wandering-enemies/{enemy_id}", dependencies=[Depends(verify_internal_key)]) -async def remove_wandering_enemy(enemy_id: int): - """Remove a wandering enemy (for bot)""" - success = await db.remove_wandering_enemy(enemy_id) - return {"success": success} - - -@app.get("/api/internal/inventory/item/{item_db_id}", dependencies=[Depends(verify_internal_key)]) -async def get_inventory_item(item_db_id: int): - """Get a specific inventory item by database ID (for bot)""" - item = await db.get_inventory_item(item_db_id) - if not item: - raise HTTPException(status_code=404, detail="Inventory item not found") - return item - - -# ============================================================================ -# Cooldowns (Internal Bot API) -# ============================================================================ - -@app.get("/api/internal/cooldown/{cooldown_key}", dependencies=[Depends(verify_internal_key)]) -async def get_cooldown(cooldown_key: str): - """Get remaining cooldown time in seconds (for bot)""" - remaining = await db.get_cooldown(cooldown_key) - return {"remaining_seconds": remaining} - - -@app.post("/api/internal/cooldown/{cooldown_key}", dependencies=[Depends(verify_internal_key)]) -async def set_cooldown(cooldown_key: str, duration_seconds: int = 600): - """Set a cooldown (for bot)""" - success = await db.set_cooldown(cooldown_key, duration_seconds) - return {"success": success} - - -# ============================================================================ -# Corpse Lists (Internal Bot API) -# ============================================================================ - -@app.get("/api/internal/location/{location_id}/corpses/player", dependencies=[Depends(verify_internal_key)]) -async def get_player_corpses_in_location(location_id: str): - """Get all player corpses in a location (for bot)""" - corpses = await db.get_player_corpses_in_location(location_id) - return corpses - - -@app.get("/api/internal/location/{location_id}/corpses/npc", dependencies=[Depends(verify_internal_key)]) -async def get_npc_corpses_in_location(location_id: str): - """Get all NPC corpses in a location (for bot)""" - corpses = await db.get_npc_corpses_in_location(location_id) - return corpses - - -# ============================================================================ -# Image Cache (Internal Bot API) -# ============================================================================ - -@app.get("/api/internal/image-cache/{image_path:path}", dependencies=[Depends(verify_internal_key)]) -async def get_cached_image(image_path: str): - """Get cached telegram file ID for an image (for bot)""" - file_id = await db.get_cached_image(image_path) - if not file_id: - raise HTTPException(status_code=404, detail="Image not cached") - return {"telegram_file_id": file_id} - - -@app.post("/api/internal/image-cache", dependencies=[Depends(verify_internal_key)]) -async def cache_image(image_path: str, telegram_file_id: str): - """Cache a telegram file ID for an image (for bot)""" - success = await db.cache_image(image_path, telegram_file_id) - return {"success": success} - - -# ============================================================================ -# Status Effects (Internal Bot API) -# ============================================================================ - -@app.get("/api/internal/player/{player_id}/status-effects", dependencies=[Depends(verify_internal_key)]) -async def get_player_status_effects(player_id: int): - """Get player status effects (for bot)""" - effects = await db.get_player_status_effects(player_id) - return effects - - -# ============================================================================ -# Statistics & Leaderboard Endpoints -# ============================================================================ - -@app.get("/api/statistics/{player_id}") -async def get_player_stats(player_id: int): - """Get character statistics by character ID (public)""" - stats = await db.get_player_statistics(player_id) - if not stats: - raise HTTPException(status_code=404, detail="Character statistics not found") - - player = await db.get_player_by_id(player_id) - if not player: - raise HTTPException(status_code=404, detail="Character not found") - - return { - "player": { - "id": player['id'], - "name": player['name'], - "level": player['level'] - }, - "statistics": stats - } - - -@app.get("/api/statistics/me") -async def get_my_stats(current_user: dict = Depends(get_current_user)): - """Get current user's statistics""" - stats = await db.get_player_statistics(current_user['id']) - return {"statistics": stats} - - -@app.get("/api/leaderboard/{stat_name}") -async def get_leaderboard_by_stat(stat_name: str, limit: int = 100): - """ - Get leaderboard for a specific statistic. - Available stats: distance_walked, enemies_killed, damage_dealt, damage_taken, - hp_restored, stamina_used, items_collected, deaths, etc. - """ - valid_stats = [ - "distance_walked", "enemies_killed", "damage_dealt", "damage_taken", - "hp_restored", "stamina_used", "stamina_restored", "items_collected", - "items_dropped", "items_used", "deaths", "successful_flees", "failed_flees", - "combats_initiated", "total_playtime" - ] - - if stat_name not in valid_stats: - raise HTTPException( - status_code=400, - detail=f"Invalid stat name. Valid stats: {', '.join(valid_stats)}" - ) - - leaderboard = await db.get_leaderboard(stat_name, limit) - return { - "stat_name": stat_name, - "leaderboard": leaderboard - } - - -# ============================================================================ -# WebSocket Endpoint -# ============================================================================ - -@app.websocket("/ws/game/{token}") -async def websocket_endpoint(websocket: WebSocket, token: str): - """ - WebSocket endpoint for real-time game updates. - Clients connect with their JWT token and receive live updates. - """ - character_id = None - - try: - # Authenticate the token - try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - # Support both character_id and old player_id - character_id = payload.get("character_id") or payload.get("player_id") - if character_id is None: - await websocket.close(code=4001, reason="Invalid token") - return - - player = await db.get_player_by_id(character_id) - if not player: - await websocket.close(code=4001, reason="Character not found") - return - - username = player.get('name') or player.get('name', 'Unknown') - except jwt.InvalidTokenError: - await websocket.close(code=4001, reason="Invalid token") - return - - # Connect the WebSocket - await manager.connect(websocket, character_id, username) - - # Initialize player session in Redis - if redis_manager: - player = await db.get_player_by_id(character_id) - await redis_manager.set_player_session(character_id, { - "username": username, - "location_id": player['location_id'], - "hp": player['hp'], - "max_hp": player['max_hp'], - "stamina": player['stamina'], - "max_stamina": player['max_stamina'], - "level": player['level'], - "xp": player['xp'], - "websocket_connected": "true" - }) - - # Add player to location registry - await redis_manager.add_player_to_location(character_id, player['location_id']) - - # Send initial connection success message - await manager.send_personal_message(character_id, { - "type": "connected", - "timestamp": datetime.utcnow().isoformat(), - "message": "WebSocket connected successfully" - }) - - # Send initial game state - player = await db.get_player_by_id(character_id) - location = LOCATIONS.get(player['location_id']) - - await manager.send_personal_message(character_id, { - "type": "state_update", - "data": { - "player": { - "hp": player['hp'], - "max_hp": player['max_hp'], - "stamina": player['stamina'], - "max_stamina": player['max_stamina'], - "location_id": player['location_id'], - "level": player['level'], - "xp": player['xp'] - }, - "location": { - "id": location.id, - "name": location.name - } if location else None - }, - "timestamp": datetime.utcnow().isoformat() - }) - - # Message loop - handle incoming messages - while True: - try: - data = await websocket.receive_json() - message_type = data.get("type") - - # Handle heartbeat - if message_type == "heartbeat": - await manager.send_personal_message(character_id, { - "type": "heartbeat_ack", - "timestamp": datetime.utcnow().isoformat() - }) - - # Handle ping - elif message_type == "ping": - await manager.send_personal_message(character_id, { - "type": "pong", - "timestamp": datetime.utcnow().isoformat() - }) - - # Future: Handle other message types (chat, emotes, etc.) - - except json.JSONDecodeError: - await manager.send_personal_message(character_id, { - "type": "error", - "message": "Invalid JSON", - "timestamp": datetime.utcnow().isoformat() - }) - - except WebSocketDisconnect: - if character_id: - await manager.disconnect(character_id) - except Exception as e: - print(f"โŒ WebSocket error for character {character_id}: {e}") - if character_id: - await manager.disconnect(character_id) - - -# ============================================================================ -# Health Check -# ============================================================================ - -@app.get("/health") -async def health_check(): - """Health check endpoint""" - return { - "status": "healthy", - "version": "2.0.0", - "locations_loaded": len(LOCATIONS), - "items_loaded": len(ITEMS_MANAGER.items) - } - - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/api/requirements.old.txt b/api/requirements.old.txt deleted file mode 100644 index f461a1c..0000000 --- a/api/requirements.old.txt +++ /dev/null @@ -1,6 +0,0 @@ -fastapi==0.104.1 -uvicorn[standard]==0.24.0 -pyjwt==2.8.0 -bcrypt==4.1.1 -pydantic==2.5.2 -python-multipart==0.0.6 diff --git a/docs/archive/COMPLETE_MIGRATION_SUCCESS.md b/docs/archive/COMPLETE_MIGRATION_SUCCESS.md new file mode 100644 index 0000000..770c43d --- /dev/null +++ b/docs/archive/COMPLETE_MIGRATION_SUCCESS.md @@ -0,0 +1,331 @@ +# ๐ŸŽ‰ Complete Backend Migration - SUCCESS + +## Migration Complete - November 12, 2025 + +Successfully completed full backend migration from monolithic main.py to modular router architecture. + +--- + +## ๐Ÿ“Š Results + +### Main.py Transformation +- **Before**: 5,573 lines (monolithic) +- **After**: 236 lines (initialization only) +- **Reduction**: 95.8% (5,337 lines moved to routers) + +### Router Architecture (9 Routers) +``` +api/routers/ +โ”œโ”€โ”€ auth.py - Authentication (3 endpoints) +โ”œโ”€โ”€ characters.py - Character management (4 endpoints) +โ”œโ”€โ”€ game_routes.py - Core game actions (11 endpoints) +โ”œโ”€โ”€ combat.py - Combat system (7 endpoints) +โ”œโ”€โ”€ equipment.py - Equipment management (6 endpoints) +โ”œโ”€โ”€ crafting.py - Crafting system (3 endpoints) +โ”œโ”€โ”€ loot.py - Loot generation (2 endpoints) +โ”œโ”€โ”€ statistics.py - Player statistics (3 endpoints) +โ””โ”€โ”€ admin.py - Internal API (30+ endpoints) +``` + +**Total**: 69+ endpoints extracted and organized + +--- + +## ๐Ÿ”ง Issues Fixed + +### 1. Redis Manager Undefined Error +**Problem**: `redis_manager is not defined` breaking player location features + +**Solution**: +- Added `redis_manager = None` to global scope in `game_routes.py` and `combat.py` +- Updated `init_router_dependencies()` to accept `redis_mgr` parameter +- Main.py now passes `redis_manager` to routers that need it + +**Affected Routers**: game_routes, combat + +### 2. Internal Endpoints Extraction +**Problem**: 30+ internal/admin endpoints still in main.py + +**Solution**: +- Created dedicated `admin.py` router +- Secured with `verify_internal_key` dependency +- Organized into logical sections (player, combat, corpses, etc.) +- Removed all internal endpoint code from main.py + +--- + +## ๐Ÿ“ Final Structure + +### api/main.py (236 lines) +```python +# Application initialization +# Router imports +# Database & Redis setup +# Router registration (9 routers) +# WebSocket endpoint +# Startup message +``` + +### Router Pattern +Each router follows consistent structure: +```python +# Global dependencies +LOCATIONS = None +ITEMS_MANAGER = None +WORLD = None +redis_manager = None # For routers that need Redis + +def init_router_dependencies(locations, items_manager, world, redis_mgr=None): + """Initialize router with shared dependencies""" + global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager + LOCATIONS = locations + ITEMS_MANAGER = items_manager + WORLD = world + redis_manager = redis_mgr + +# Endpoint definitions... +``` + +--- + +## ๐Ÿš€ Deployment Status + +### โœ… API Running Successfully +- All 5 workers started +- 9 routers registered +- 14 locations loaded +- 42 items loaded +- 6 background tasks active +- **Zero errors in logs** + +### โœ… Features Verified Working +- Redis manager integration (player location tracking) +- Combat system (state management) +- Internal API endpoints (admin tools) +- WebSocket connections +- Background tasks (spawn, decay, regeneration, etc.) + +--- + +## ๐Ÿ› ๏ธ Migration Tools Created + +### 1. analyze_endpoints.py +- Analyzes endpoint distribution in main.py +- Categorizes endpoints by domain +- Provides statistics for planning + +### 2. generate_routers.py +- **Automated endpoint extraction** from main.py +- Generated 6 routers automatically (1,900+ lines of code) +- Preserved all logic and function calls +- Maintained docstrings and comments + +--- + +## ๐Ÿ“ Key Achievements + +### Code Organization +- โœ… Endpoints grouped by logical domain +- โœ… Clear separation of concerns +- โœ… Consistent router patterns +- โœ… Proper dependency injection + +### Security Improvements +- โœ… Internal endpoints now secured with `verify_internal_key` +- โœ… Clean separation between public and admin API +- โœ… Router-level security policies + +### Maintainability +- โœ… 95.8% reduction in main.py size +- โœ… Each router focused on single domain +- โœ… Easy to locate and modify features +- โœ… Clear initialization pattern + +### Performance +- โœ… No performance degradation +- โœ… Redis integration working correctly +- โœ… Background tasks stable +- โœ… WebSocket functionality intact + +--- + +## ๐ŸŽฏ Router Breakdown + +### Public API Routers +1. **auth.py** (3 endpoints) + - Login, register, token refresh + - JWT token management + +2. **characters.py** (4 endpoints) + - Character creation, selection, deletion + - Character list retrieval + +3. **game_routes.py** (11 endpoints) + - Movement, inspection, interaction + - Item pickup/drop + - Uses Redis for location tracking + +4. **combat.py** (7 endpoints) + - PvE and PvP combat + - Fleeing, attacking + - Uses Redis for combat state + +5. **equipment.py** (6 endpoints) + - Equip/unequip items + - Equipment inspection + +6. **crafting.py** (3 endpoints) + - Recipe discovery + - Item crafting + +7. **loot.py** (2 endpoints) + - Loot generation + - Corpse looting + +8. **statistics.py** (3 endpoints) + - Player stats + - Leaderboards + +### Internal API Router +9. **admin.py** (30+ endpoints) + - **Player Management**: Get/update player, inventory, status effects + - **Combat Management**: Create/update/delete combat instances + - **Game Actions**: Move, inspect, interact, use item, pickup, drop + - **Equipment**: Equip/unequip operations + - **Dropped Items**: Full CRUD operations + - **Corpses**: Player and NPC corpse management (10 endpoints) + - **Wandering Enemies**: Spawn/delete/query + - **Inventory**: Direct inventory access + - **Cooldowns**: Cooldown management + - **Image Cache**: Image existence checks + +--- + +## ๐Ÿ” Security Model + +### Public Endpoints +- Protected by JWT token authentication +- User can only access own data +- Rate limiting applied + +### Internal Endpoints +- Protected by `verify_internal_key` dependency +- Requires `X-Internal-Key` header +- Only accessible by bot and admin tools +- Full access to all game data + +--- + +## ๐Ÿ“ˆ Statistics + +### Before Migration +- **1 file**: main.py (5,573 lines) +- **69+ endpoints** in single file +- **Mixed concerns**: public + internal API +- **Hard to maintain**: Scrolling through 5,000+ lines + +### After Migration +- **10 files**: main.py (236) + 9 routers (5,337 total) +- **69+ endpoints** organized by domain +- **Clear separation**: public API + admin API +- **Easy to maintain**: Average router ~600 lines + +### Endpoint Distribution +``` +Auth: 3 endpoints ( 5%) +Characters: 4 endpoints ( 6%) +Game: 11 endpoints ( 16%) +Combat: 7 endpoints ( 10%) +Equipment: 6 endpoints ( 9%) +Crafting: 3 endpoints ( 4%) +Loot: 2 endpoints ( 3%) +Statistics: 3 endpoints ( 4%) +Admin: 30 endpoints ( 43%) +``` + +--- + +## ๐ŸŽ“ Lessons Learned + +### What Worked Well +1. **Automated extraction script** saved massive time +2. **Consistent router pattern** made integration smooth +3. **Gradual testing** caught issues early +4. **Dependency injection** pattern scales well + +### Challenges Overcome +1. **Redis manager missing**: Fixed by adding to router globals +2. **Internal endpoints security**: Solved with dedicated admin router +3. **Large file editing**: Used automation instead of manual editing + +--- + +## โœ… Verification Checklist + +- [x] All routers created and organized +- [x] Main.py reduced to initialization only +- [x] Redis manager integrated correctly +- [x] Internal endpoints secured in admin router +- [x] API starts successfully +- [x] Zero errors in logs +- [x] All background tasks running +- [x] WebSocket functionality intact +- [x] 9 routers registered correctly + +--- + +## ๐Ÿš€ Next Steps + +### Backend (Complete โœ…) +- โœ… Router architecture +- โœ… Redis integration +- โœ… Security improvements +- โœ… Code organization + +### Frontend (Recommended) +The frontend could benefit from similar refactoring: +- `Game.tsx` is 3,315 lines (similar to old main.py) +- Could extract: Combat UI, Inventory UI, Map UI, Chat UI, etc. +- Would improve maintainability and code organization + +--- + +## ๐Ÿ“š Documentation + +### Updated Files +- `api/main.py` - Application initialization (236 lines) +- `api/routers/auth.py` - Authentication +- `api/routers/characters.py` - Character management +- `api/routers/game_routes.py` - Game actions (with Redis) +- `api/routers/combat.py` - Combat system (with Redis) +- `api/routers/equipment.py` - Equipment +- `api/routers/crafting.py` - Crafting +- `api/routers/loot.py` - Loot +- `api/routers/statistics.py` - Statistics +- `api/routers/admin.py` - Internal API (NEW) + +### Migration Tools +- `analyze_endpoints.py` - Endpoint analysis tool +- `generate_routers.py` - Automated extraction script +- `main_original_5573_lines.py` - Original backup +- `main_pre_migration_backup.py` - Pre-migration backup + +--- + +## ๐ŸŽ‰ Conclusion + +The backend migration is **COMPLETE and SUCCESSFUL**. The API is now: +- **Modular**: 9 focused routers instead of 1 monolithic file +- **Maintainable**: Average router size ~600 lines +- **Secure**: Internal API properly isolated and secured +- **Stable**: Zero errors, all features working +- **Scalable**: Easy to add new routers and endpoints + +**Main.py reduced from 5,573 lines to 236 lines (95.8% reduction)** + +Migration completed in one session with automated tools and systematic approach. + +--- + +*Generated: November 12, 2025* +*Status: โœ… Production Ready* diff --git a/docs/archive/PLAYERS_TAB_SCHEMA_FIX.md b/docs/archive/PLAYERS_TAB_SCHEMA_FIX.md new file mode 100644 index 0000000..ae745c3 --- /dev/null +++ b/docs/archive/PLAYERS_TAB_SCHEMA_FIX.md @@ -0,0 +1,146 @@ +# Database Schema Migration - Players Tab Fix + +## Summary +Fixed all database queries in the web-map editor to use the correct `accounts` + `characters` schema instead of the deprecated `players` table. + +## Schema Changes + +### Old Schema (Deprecated) +- `players` table with `telegram_id` as primary key +- Columns: `intelligence`, `weight_capacity`, `volume_capacity` +- `accounts` table with `is_banned`, `ban_reason`, `premium_until` + +### New Schema (Current) +- `accounts` table: `id`, `email`, `premium_expires_at`, `created_at` +- `characters` table: `id`, `account_id` (FK), `name`, `level`, `xp`, `hp`, `stamina`, `strength`, `agility`, `endurance`, `intellect`, `unspent_points`, `location_id`, `is_dead` +- `inventory` table: `character_id` (FK), `item_id`, `quantity`, `is_equipped`, `unique_item_id` (FK to unique_items) +- `unique_items` table: `id`, `item_id`, `durability`, `max_durability`, `tier`, `unique_stats` + +## Files Modified + +### 1. `/opt/dockers/echoes_of_the_ashes/web-map/server.py` + +**Changes:** +- โœ… Changed import from `bot.database` to `api.database` +- โœ… Updated all SQL queries to use `characters` and `accounts` tables +- โœ… Changed column names: + - `telegram_id` โ†’ `id` (character ID) + - `intelligence` โ†’ `intellect` + - `premium_until` โ†’ `premium_expires_at` + - `character_name` โ†’ `name` +- โœ… Updated API endpoints: + - `/api/editor/player/` โ†’ `/api/editor/player/` + - `/api/editor/account/` โ†’ `/api/editor/account/` +- โœ… Fixed inventory queries to use `character_id` and join with `unique_items` table +- โœ… Updated player count query for live stats (line 1080) +- โœ… Fixed delete account to use CASCADE (accounts โ†’ characters โ†’ inventory) +- โœ… Updated reset player to use correct default values + +**Endpoints Fixed:** +1. `GET /api/editor/players` - List all characters with account info +2. `GET /api/editor/player/` - Get character details + inventory +3. `POST /api/editor/player/` - Update character stats +4. `POST /api/editor/player//inventory` - Update inventory +5. `POST /api/editor/player//equipment` - Update equipment +6. `DELETE /api/editor/account//delete` - Delete account +7. `POST /api/editor/player//reset` - Reset character + +### 2. `/opt/dockers/echoes_of_the_ashes/web-map/editor_enhanced.js` + +**Changes:** +- โœ… Updated `renderPlayerList()` to use `player.id` instead of `player.telegram_id` +- โœ… Changed dataset attribute: `dataset.telegramId` โ†’ `dataset.characterId` +- โœ… Updated `selectPlayer()` function parameter and API call +- โœ… Fixed player editor display to show: + - Character ID instead of Telegram ID + - Account email + - Correct timestamp handling (character_created_at * 1000) +- โœ… Updated action buttons to use correct IDs: + - Ban/Unban: uses `account_id` + - Reset: uses character `id` + - Delete: uses `account_id` +- โœ… Fixed `deletePlayer()` to find player by `account_id` +- โœ… Updated status badge logic to use `is_premium` boolean + +## Testing Checklist + +### Backend Tests +- [ ] Start containers: `docker compose up -d` +- [ ] Check logs: `docker logs echoes_of_the_ashes_map` +- [ ] Test API endpoints: + ```bash + # Login first + curl -X POST http://localhost:8080/api/login \ + -H "Content-Type: application/json" \ + -d '{"password":"admin123"}' \ + -c cookies.txt + + # Get players list + curl http://localhost:8080/api/editor/players -b cookies.txt + + # Get specific player (replace 1 with actual character ID) + curl http://localhost:8080/api/editor/player/1 -b cookies.txt + ``` + +### Frontend Tests +1. Navigate to `http://localhost:8080/editor` +2. Login with password (default: `admin123`) +3. Click "๐Ÿ‘ฅ Players" tab +4. Verify: + - [ ] Player list loads correctly + - [ ] Search by name works + - [ ] Filter by status (All/Active/Banned/Premium) works + - [ ] Clicking a player loads their details + - [ ] Character stats display correctly + - [ ] Inventory shows (read-only) + - [ ] Equipment shows (read-only) + - [ ] Account info displays (email, premium status) +5. Test actions: + - [ ] Edit character stats and save + - [ ] Reset player (confirm it clears inventory) + - [ ] Delete account (confirm double-confirmation) + +## Known Limitations + +1. **Ban functionality**: Accounts table doesn't have `is_banned` or `ban_reason` columns in new schema + - Ban/Unban buttons will return "not implemented" message + - Need to add these columns to accounts table if ban feature is needed + +2. **Inventory editing**: Currently read-only display + - Full CRUD for inventory would require more complex UI + - Unique items support needs proper unique_items table integration + +3. **Equipment slots**: New schema uses `is_equipped` flag in inventory + - No separate `equipped_items` table + - Equipment is just inventory items with `is_equipped=true` + +## Rebuild Instructions + +```bash +# Rebuild map container with fixes +docker compose build echoes_of_the_ashes_map + +# Restart container +docker compose up -d echoes_of_the_ashes_map + +# Check logs +docker logs -f echoes_of_the_ashes_map +``` + +## Rollback Plan + +If issues occur: +```bash +# Restore from container (files are already synced) +./sync_from_containers.sh + +# Or restore from git +git checkout web-map/server.py web-map/editor_enhanced.js +``` + +## Additional Notes + +- All changes are backward compatible with existing data +- No database migrations needed (schema already exists) +- Frontend gracefully handles missing data (email, premium status) +- Timestamps are handled correctly (Unix timestamps in DB, converted to Date objects in JS) diff --git a/docs/archive/README_old.md b/docs/archive/README_old.md new file mode 100644 index 0000000..f8d0959 --- /dev/null +++ b/docs/archive/README_old.md @@ -0,0 +1,627 @@ +# Echoes of the Ash ๐ŸŒ† + +A dark fantasy post-apocalyptic survival RPG featuring exploration, combat, crafting, and scavenging in a ruined world. + +## ๐ŸŽฎ Game Features + +### Core Gameplay + +#### ๐Ÿ—บ๏ธ Exploration & Movement +- **Grid-based world navigation** with coordinates (x, y) +- **Stamina-based movement system** - each move costs stamina based on distance +- **Multiple biomes and locations** with varying danger levels (0-4) +- **Dynamic location discovery** as you explore +- **Compass-based directional movement** (North, South, East, West) + +#### โš”๏ธ Combat System +- **Turn-based combat** with real-time intent preview +- **NPC enemy encounters** with weighted spawn tables per location +- **Status effects system**: Bleeding, Infected, Radiation +- **Weapon effects**: Bleeding, Stun, Armor Break +- **Flee mechanics** - escape combat with success/failure chance +- **XP and leveling system** - gain XP from defeating enemies +- **PvP (Player vs Player) combat** - challenge other players +- **Death and respawn mechanics** + +#### ๐ŸŽ’ Inventory & Equipment +- **Weight and volume-based inventory** system +- **Equipment slots**: Weapon, Backpack, Armor, Head, Tool +- **Durability system** - items degrade with use +- **Item tiers** (1-3) affecting quality and stats +- **Encumbrance system** - affects stamina costs +- **Ground item drops** - pick up and drop items + +#### ๐Ÿ”จ Crafting & Repair +- **Crafting system** with material requirements +- **Tool requirements** for certain recipes +- **Repair mechanics** - restore item durability +- **Uncrafting/Disassembly** - break down items for materials +- **Workbench locations** for advanced crafting +- **Craft level requirements** - unlocked through progression + +#### ๐Ÿ” Scavenging & Interactables +- **Searchable objects** in each location (dumpsters, cars, houses, etc.) +- **Action-based interaction** system with stamina costs +- **Success/failure mechanics** with critical outcomes +- **Loot tables** with item drop chances +- **One-time and respawning interactables** +- **Status tracking** per player (already looted, depleted, etc.) + +#### ๐Ÿ“Š Character Progression +- **Level system** (1-50+) with XP requirements +- **Stat points** - allocate to Strength, Defense, Stamina +- **Character customization** on creation +- **Skill progression** tied to crafting levels + +#### ๐ŸŒ World Features +- **Multi-location world** (Downtown, Gas Station, Residential, Clinic, Plaza, Park, Warehouse, Office Buildings, Subway, etc.) +- **Location tags** - workbench, repair_station, safe_zone +- **Danger zones** with varying encounter rates +- **Location-specific loot** and enemy spawns + +#### ๐Ÿ’ฌ Social & Multiplayer +- **Online player tracking** via WebSockets +- **Real-time player position updates** +- **PvP combat system** with challenge mechanics +- **Character browsing** - see other players' stats + +#### ๐ŸŽจ PWA Features +- **Progressive Web App** - installable on mobile/desktop +- **Multi-language support** (English, Spanish) +- **Responsive UI** with mobile-first design +- **Real-time updates** via WebSockets +- **Offline capabilities** (service worker) + +--- + +## ๐Ÿ“ Gamedata Structure + +The game uses JSON files in the `gamedata/` directory to define all game content. This modular approach makes it easy to add new content without code changes. + +### Directory Layout + +``` +gamedata/ +โ”œโ”€โ”€ npcs.json # Enemy NPCs and combat encounters +โ”œโ”€โ”€ items.json # All items, weapons, consumables, and resources +โ”œโ”€โ”€ locations.json # World map locations and interactables +โ””โ”€โ”€ interactables.json # Interactable object templates +``` + +--- + +## ๐Ÿ“‹ `npcs.json` Structure + +Defines all enemy NPCs, their stats, loot tables, and spawn locations. + +### Top-Level Structure +```json +{ + "npcs": { ... }, // NPC definitions + "danger_levels": { ... }, // Danger settings per location + "spawn_tables": { ... } // Enemy spawn weights per location +} +``` + +### NPC Definition +```json +"npc_id": { + "npc_id": "unique_npc_identifier", + "name": { + "en": "English Name", + "es": "Spanish Name" + }, + "description": { + "en": "English description", + "es": "Spanish description" + }, + "emoji": "๐Ÿ•", + "hp_min": 15, // Minimum HP when spawned + "hp_max": 25, // Maximum HP when spawned + "damage_min": 3, // Minimum attack damage + "damage_max": 7, // Maximum attack damage + "defense": 0, // Damage reduction + "xp_reward": 10, // XP given on defeat + "loot_table": [ // Items dropped on death (automatic) + { + "item_id": "raw_meat", + "quantity_min": 1, + "quantity_max": 2, + "drop_chance": 0.6 // 60% chance to drop + } + ], + "corpse_loot": [ // Items harvestable from corpse + { + "item_id": "animal_hide", + "quantity_min": 1, + "quantity_max": 1, + "required_tool": "knife" // Tool needed to harvest (null = no requirement) + } + ], + "flee_chance": 0.3, // Chance NPC flees from combat + "status_inflict_chance": 0.15, // Chance to inflict status effect on hit + "image_path": "images/npcs/feral_dog.webp", + "death_message": "The feral dog whimpers and collapses..." +} +``` + +### Danger Levels +```json +"location_id": { + "danger_level": 2, // 0-4 scale + "encounter_rate": 0.2, // 20% chance per movement + "wandering_chance": 0.35 // 35% chance for random encounter while idle +} +``` + +### Spawn Tables +```json +"location_id": [ + { + "npc_id": "raider_scout", + "weight": 50 // Weighted random spawn (higher = more common) + }, + { + "npc_id": "infected_human", + "weight": 30 + } +] +``` + +**Available NPCs:** +- `feral_dog` - Wild, hungry canine (Tier 1) +- `mutant_rat` - Radiation-mutated rodent (Tier 1) +- `raider_scout` - Hostile human raider (Tier 2) +- `scavenger` - Aggressive survivor (Tier 2) +- `infected_human` - Virus-infected zombie-like human (Tier 3) + +--- + +## ๐ŸŽ’ `items.json` Structure + +Defines all items, equipment, weapons, consumables, and crafting materials. + +### Item Categories (Types) +- `resource` - Raw materials for crafting +- `consumable` - Food, medicine, usable items +- `weapon` - Melee and ranged weapons +- `backpack` - Inventory capacity upgrades +- `armor` - Protective equipment +- `tool` - Utility items (flashlight, etc.) +- `quest` - Story/quest items + +### Basic Item Structure +```json +"item_id": { + "name": { + "en": "Item Name", + "es": "Spanish Name" + }, + "description": { + "en": "Description text", + "es": "Spanish description" + }, + "type": "resource", + "weight": 0.5, // Kilograms + "volume": 0.2, // Liters + "emoji": "โš™๏ธ", + "image_path": "images/items/scrap_metal.webp" +} +``` + +### Consumable Items +```json +"item_id": { + ...basic fields..., + "type": "consumable", + "hp_restore": 20, // Health restored + "stamina_restore": 10, // Stamina restored + "treats": "Bleeding" // Status effect cured (optional) +} +``` + +### Weapon/Equipment Items +```json +"item_id": { + ...basic fields..., + "type": "weapon", + "equippable": true, + "slot": "weapon", // Equipment slot: weapon, backpack, armor, head, tool + "durability": 100, // Max durability + "tier": 2, // 1-3 quality tier + "encumbrance": 2, // Stamina penalty when equipped + "stats": { + "damage_min": 5, + "damage_max": 10, + "weight_capacity": 20, // For backpacks + "volume_capacity": 20, + "defense": 5 // For armor + }, + "weapon_effects": { // Status effects inflicted (optional) + "bleeding": { + "chance": 0.15, // 15% chance on hit + "damage": 2, // Damage per turn + "duration": 3 // Turns + } + } +} +``` + +### Craftable Items +```json +"item_id": { + ...other fields..., + "craftable": true, + "craft_level": 2, // Required crafting level + "craft_materials": [ + { + "item_id": "scrap_metal", + "quantity": 3 + } + ], + "craft_tools": [ // Tools consumed during crafting + { + "item_id": "hammer", + "durability_cost": 3 // Durability consumed + } + ] +} +``` + +### Repairable Items +```json +"item_id": { + ...other fields..., + "repairable": true, + "repair_materials": [ + { + "item_id": "scrap_metal", + "quantity": 2 + } + ], + "repair_tools": [ + { + "item_id": "hammer", + "durability_cost": 2 + } + ], + "repair_percentage": 30 // % of max durability restored +} +``` + +### Uncraftable Items (Disassembly) +```json +"item_id": { + ...other fields..., + "uncraftable": true, + "uncraft_yield": [ // Materials returned + { + "item_id": "scrap_metal", + "quantity": 2 + } + ], + "uncraft_loss_chance": 0.25, // 25% chance to lose materials + "uncraft_tools": [ + { + "item_id": "hammer", + "durability_cost": 1 + } + ] +} +``` + +**Item Examples:** +- **Resources:** `scrap_metal`, `cloth_scraps`, `wood_planks`, `bone`, `raw_meat` +- **Consumables:** `canned_food`, `water_bottle`, `bandage`, `antibiotics`, `rad_pills` +- **Weapons:** `rusty_knife`, `knife`, `tire_iron`, `makeshift_spear`, `reinforced_bat` +- **Backpacks:** `tattered_rucksack`, `hiking_backpack` +- **Tools:** `flashlight`, `hammer` + +--- + +## ๐Ÿ—บ๏ธ `locations.json` Structure + +Defines the game world, all locations, coordinates, and interactable objects. + +### Location Definition +```json +{ + "id": "location_id", + "name": { + "en": "๐Ÿš๏ธ Location Name", + "es": "Spanish Name" + }, + "description": { + "en": "Atmospheric description of the location...", + "es": "Spanish description" + }, + "image_path": "images/locations/location.webp", + "x": 0, // Grid X coordinate + "y": 2, // Grid Y coordinate + "tags": [ // Optional tags + "workbench", // Has crafting bench + "repair_station", // Can repair items + "safe_zone" // No random encounters + ], + "interactables": { ... } // Interactable objects at this location +} +``` + +### Interactable Object Instance +```json +"unique_interactable_id": { + "template_id": "dumpster", // References interactables.json + "outcomes": { + "action_id": { + "stamina_cost": 2, + "success_rate": 0.5, // 50% base success chance + "crit_success_chance": 0.1, // 10% chance for critical success + "crit_failure_chance": 0.1, // 10% chance for critical failure + "rewards": { + "damage": 0, // Damage on normal failure + "crit_damage": 8, // Damage on critical failure + "items": [ // Items on normal success + { + "item_id": "plastic_bottles", + "quantity": 3, + "chance": 1.0 // 100% drop rate + } + ], + "crit_items": [ // Items on critical success + { + "item_id": "rare_item", + "quantity": 1, + "chance": 0.5 + } + ] + }, + "text": { // Locale-specific text responses + "success": { + "en": "You find something useful!", + "es": "ยกEncuentras algo รบtil!" + }, + "failure": { + "en": "Nothing here.", + "es": "Nada aquรญ." + }, + "crit_success": { ... }, + "crit_failure": { ... } + } + } + } +} +``` + +**Available Locations:** +- `start_point` - Ruined Downtown Core (0, 0) - Starting location +- `gas_station` - Abandoned Gas Station (0, 2) - Has workbench +- `residential` - Residential Street (3, 0) +- `clinic` - Old Clinic (2, 3) - Medical supplies +- `plaza` - Shopping Plaza (-2.5, 0) +- `park` - Suburban Park (-1, -2) +- `overpass` - Highway Overpass (1.0, 4.5) +- `warehouse` - Warehouse District +- `office_building` - Office Tower +- `subway` - Subway Station + +--- + +## ๐Ÿ” `interactables.json` Structure + +Defines templates for interactable objects that can be placed in locations. + +### Interactable Template +```json +"template_id": { + "id": "template_id", + "name": { + "en": "๐Ÿ—‘๏ธ Object Name", + "es": "Spanish Name" + }, + "description": { + "en": "Object description", + "es": "Spanish description" + }, + "image_path": "images/interactables/object.webp", + "actions": { // Available actions for this object + "action_id": { + "id": "action_id", + "label": { + "en": "๐Ÿ”Ž Action Label", + "es": "Spanish Label" + }, + "stamina_cost": 2 // Base stamina cost (can be overridden in locations) + } + } +} +``` + +**Available Interactable Templates:** +- `rubble` - Pile of debris (Action: search) +- `dumpster` - Trash container (Action: search_dumpster) +- `sedan` - Abandoned car (Actions: search_glovebox, pop_trunk) +- `house` - Abandoned house (Action: search_house) +- `toolshed` - Tool shed (Action: search_shed) +- `medkit` - Medical supply cabinet (Action: search_medkit) +- `storage_box` - Storage container (Action: search) +- `vending_machine` - Vending machine (Actions: break, search) + +--- + +## ๐Ÿ› ๏ธ Replicating Gamedata + +### Adding a New NPC + +1. **Create NPC definition** in `npcs.json` under `"npcs"`: +```json +"my_new_npc": { + "npc_id": "my_new_npc", + "name": { "en": "My NPC", "es": "Mi NPC" }, + "description": { "en": "Description", "es": "Descripciรณn" }, + "emoji": "๐Ÿ‘น", + "hp_min": 20, "hp_max": 30, + "damage_min": 4, "damage_max": 8, + "defense": 1, + "xp_reward": 15, + "loot_table": [...], + "corpse_loot": [...], + "flee_chance": 0.2, + "status_inflict_chance": 0.1, + "image_path": "images/npcs/my_new_npc.webp", + "death_message": "The creature falls..." +} +``` + +2. **Add to spawn table** in `npcs.json` under `"spawn_tables"`: +```json +"location_id": [ + { "npc_id": "my_new_npc", "weight": 40 } +] +``` + +3. **Add image** at `images/npcs/my_new_npc.webp` + +### Adding a New Item + +1. **Create item definition** in `items.json`: +```json +"my_new_item": { + "name": { "en": "My Item", "es": "Mi Objeto" }, + "description": { "en": "Description", "es": "Descripciรณn" }, + "type": "resource", + "weight": 1.0, + "volume": 0.5, + "emoji": "๐Ÿ”ฎ", + "image_path": "images/items/my_new_item.webp" +} +``` + +2. **Add to loot tables** (optional) in locations or NPCs + +3. **Add image** at `images/items/my_new_item.webp` + +### Adding a New Location + +1. **Create location** in `locations.json`: +```json +{ + "id": "my_location", + "name": { "en": "๐Ÿญ My Location", "es": "Mi Ubicaciรณn" }, + "description": { "en": "Description", "es": "Descripciรณn" }, + "image_path": "images/locations/my_location.webp", + "x": 5, + "y": 3, + "tags": ["workbench"], + "interactables": { + "my_location_box": { + "template_id": "storage_box", + "outcomes": { + "search": { ...outcome definition... } + } + } + } +} +``` + +2. **Add danger level** in `npcs.json`: +```json +"my_location": { + "danger_level": 2, + "encounter_rate": 0.15, + "wandering_chance": 0.3 +} +``` + +3. **Add spawn table** in `npcs.json`: +```json +"my_location": [ + { "npc_id": "raider_scout", "weight": 60 }, + { "npc_id": "mutant_rat", "weight": 40 } +] +``` + +4. **Add image** at `images/locations/my_location.webp` + +### Adding a New Interactable Template + +1. **Create template** in `interactables.json`: +```json +"my_interactable": { + "id": "my_interactable", + "name": { "en": "๐ŸŽฐ My Object", "es": "Mi Objeto" }, + "description": { "en": "Description", "es": "Descripciรณn" }, + "image_path": "images/interactables/my_object.webp", + "actions": { + "my_action": { + "id": "my_action", + "label": { "en": "๐Ÿ”จ Do Action", "es": "Hacer Acciรณn" }, + "stamina_cost": 3 + } + } +} +``` + +2. **Use in locations** in `locations.json` interactables + +3. **Add image** at `images/interactables/my_object.webp` + +--- + +## ๐ŸŽฏ Key Game Mechanics + +### Stamina System +- Base stamina pool (increases with Stamina stat) +- Regenerates passively over time +- Consumed by: Movement, Combat Actions, Interactions, Crafting +- Encumbrance from equipment increases stamina costs + +### Combat Flow +1. Player or NPC initiates combat +2. Turn-based with initiative system +3. NPCs show **intent preview** (next planned action) +4. Player chooses: Attack, Defend, Use Item, Flee +5. Status effects tick each turn +6. Combat ends on death or successful flee + +### Loot System +- **Immediate drops** from loot_table (on death) +- **Corpse harvesting** from corpse_loot (requires tools) +- **Interactable loot** with success/failure mechanics +- **Respawn timers** for interactables + +### Crafting Requirements +- Sufficient materials in inventory +- Required tools with durability +- Crafting level unlocked +- Optional: Workbench location tag + +--- + +## ๐Ÿ“š Additional Documentation + +- **[CLAUDE.md](./CLAUDE.md)** - Project structure and development commands +- **[QUICK_REFERENCE.md](./QUICK_REFERENCE.md)** - API endpoints and architecture +- **[docker-compose.yml](./docker-compose.yml)** - Infrastructure setup + +--- + +## ๐Ÿš€ Quick Start + +```bash +# Start the game +docker compose up -d + +# View API logs +docker compose logs -f echoes_of_the_ashes_api + +# Rebuild after changes +docker compose build && docker compose up -d +``` + +Game runs at: `http://localhost` (PWA) and `http://localhost/api` (API) + +--- + +## ๐Ÿ“ License + +All rights reserved. Post-apocalyptic survival simulation for educational purposes. diff --git a/docs/archive/REDIS_MONITORING.md b/docs/archive/REDIS_MONITORING.md new file mode 100644 index 0000000..44b2c03 --- /dev/null +++ b/docs/archive/REDIS_MONITORING.md @@ -0,0 +1,180 @@ +# Redis Cache Monitoring Guide + +## Quick Methods to Monitor Redis Cache + +### 1. **API Endpoint (Easiest)** + +Access the cache stats endpoint: + +```bash +# Using curl (replace with your auth token) +curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8000/api/cache/stats +``` + +**Response:** +```json +{ + "enabled": true, + "redis_stats": { + "total_commands_processed": 15234, + "ops_per_second": 12, + "connected_clients": 8 + }, + "cache_performance": { + "hits": 8542, + "misses": 1234, + "total_requests": 9776, + "hit_rate_percent": 87.38 + }, + "current_user": { + "inventory_cached": true, + "player_id": 1 + } +} +``` + +**What to look for:** +- `hit_rate_percent`: Should be 70-90% for good cache performance +- `inventory_cached`: Shows if your inventory is currently in cache +- `ops_per_second`: Redis operations per second + +--- + +### 2. **Redis CLI - Real-time Monitoring** + +```bash +# Connect to Redis container +docker exec -it echoes_of_the_ashes_redis redis-cli + +# View detailed statistics +INFO stats + +# Monitor all commands in real-time (shows every cache hit/miss) +MONITOR + +# View all inventory cache keys +KEYS player:*:inventory + +# Check if specific player's inventory is cached +EXISTS player:1:inventory + +# Get TTL (time to live) of a cached inventory +TTL player:1:inventory + +# View cached inventory data +GET player:1:inventory +``` + +--- + +### 3. **Application Logs** + +```bash +# View all cache-related logs +docker logs echoes_of_the_ashes_api -f | grep -i "redis\|cache" + +# View only cache failures +docker logs echoes_of_the_ashes_api -f | grep "cache.*failed" +``` + +--- + +### 4. **Redis Commander (Web UI)** + +Add Redis Commander to your docker-compose.yml for a web-based UI: + +```yaml +redis-commander: + image: rediscommander/redis-commander:latest + environment: + - REDIS_HOSTS=local:echoes_of_the_ashes_redis:6379 + ports: + - "8081:8081" + depends_on: + - echoes_of_the_ashes_redis +``` + +Then access: http://localhost:8081 + +--- + +## Understanding Cache Metrics + +### Hit Rate +- **90%+**: Excellent - Most requests served from cache +- **70-90%**: Good - Cache is working well +- **50-70%**: Fair - Consider increasing TTL or investigating invalidation +- **<50%**: Poor - Cache may not be effective + +### Inventory Cache Keys +- Format: `player:{player_id}:inventory` +- TTL: 600 seconds (10 minutes) +- Invalidated on: add/remove items, equip/unequip, property updates + +### Expected Behavior +1. **First inventory load**: Cache MISS โ†’ Database query โ†’ Cache write +2. **Subsequent loads**: Cache HIT โ†’ Fast response (~1-3ms) +3. **After mutation** (pickup item): Cache invalidated โ†’ Next load is MISS +4. **After 10 minutes**: Cache expires โ†’ Next load is MISS + +--- + +## Testing Cache Performance + +### Test 1: Verify Caching Works +```bash +# 1. Load inventory (should be cache MISS) +curl -H "Authorization: Bearer TOKEN" http://localhost:8000/api/game/state + +# 2. Load again immediately (should be cache HIT - much faster) +curl -H "Authorization: Bearer TOKEN" http://localhost:8000/api/game/state + +# 3. Check stats +curl -H "Authorization: Bearer TOKEN" http://localhost:8000/api/cache/stats +``` + +### Test 2: Verify Invalidation Works +```bash +# 1. Load inventory (cache HIT if already loaded) +curl -H "Authorization: Bearer TOKEN" http://localhost:8000/api/game/state + +# 2. Pick up an item (invalidates cache) +curl -X POST -H "Authorization: Bearer TOKEN" http://localhost:8000/api/game/pickup_item + +# 3. Load inventory again (should be cache MISS) +curl -H "Authorization: Bearer TOKEN" http://localhost:8000/api/game/state +``` + +--- + +## Troubleshooting + +### Cache Not Working +```bash +# Check if Redis is running +docker ps | grep redis + +# Check Redis connectivity +docker exec -it echoes_of_the_ashes_redis redis-cli PING +# Should return: PONG + +# Check application logs for errors +docker logs echoes_of_the_ashes_api | grep -i "redis" +``` + +### Low Hit Rate +- Check if cache TTL is too short (currently 10 minutes) +- Verify invalidation isn't too aggressive +- Monitor which operations are causing cache misses + +### High Memory Usage +```bash +# Check Redis memory usage +docker exec -it echoes_of_the_ashes_redis redis-cli INFO memory + +# View all cached keys +docker exec -it echoes_of_the_ashes_redis redis-cli KEYS "*" + +# Clear all cache (use with caution!) +docker exec -it echoes_of_the_ashes_redis redis-cli FLUSHALL +``` diff --git a/docs/archive/REFACTORING_COMPLETE.md b/docs/archive/REFACTORING_COMPLETE.md new file mode 100644 index 0000000..a18cd89 --- /dev/null +++ b/docs/archive/REFACTORING_COMPLETE.md @@ -0,0 +1,335 @@ +# Backend Refactoring - Complete Summary + +## ๐ŸŽ‰ What We've Accomplished + +### โœ… Project Cleanup +**Moved to `old/` folder:** +- `bot/` - Unused Telegram bot code +- `web-map/` - Old map editor +- All `.md` documentation files +- Old migration scripts (`migrate_*.py`) +- Legacy Dockerfiles + +**Result:** Clean, organized project root + +--- + +### โœ… New Module Structure Created + +``` +api/ +โ”œโ”€โ”€ core/ # Core functionality +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”œโ”€โ”€ config.py # โœ… All configuration & constants +โ”‚ โ”œโ”€โ”€ security.py # โœ… JWT, auth, password hashing +โ”‚ โ””โ”€โ”€ websockets.py # โœ… ConnectionManager +โ”‚ +โ”œโ”€โ”€ services/ # Business logic & utilities +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”œโ”€โ”€ models.py # โœ… All Pydantic request/response models (17 models) +โ”‚ โ””โ”€โ”€ helpers.py # โœ… Utility functions (distance, stamina, armor, tools) +โ”‚ +โ”œโ”€โ”€ routers/ # API route handlers +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ””โ”€โ”€ auth.py # โœ… Auth router (register, login, me) +โ”‚ +โ””โ”€โ”€ main.py # Main application file (currently 5574 lines) +``` + +--- + +## ๐Ÿ“‹ What's in Each Module + +### `api/core/config.py` +```python +- SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES +- API_INTERNAL_KEY +- CORS_ORIGINS list +- IMAGES_DIR path +- Game constants (MOVEMENT_COOLDOWN, capacities) +``` + +### `api/core/security.py` +```python +- create_access_token(data: dict) -> str +- decode_token(token: str) -> dict +- hash_password(password: str) -> str +- verify_password(password: str, hash: str) -> bool +- get_current_user(credentials) -> Dict[str, Any] # Main auth dependency +- verify_internal_key(credentials) -> bool +``` + +### `api/core/websockets.py` +```python +class ConnectionManager: + - connect(websocket, player_id, username) + - disconnect(player_id) + - send_personal_message(player_id, message) + - send_to_location(location_id, message, exclude_player_id) + - broadcast(message, exclude_player_id) + - handle_redis_message(channel, data) +``` + +### `api/services/models.py` +**All Pydantic Models (17 total):** +- Auth: `UserRegister`, `UserLogin` +- Characters: `CharacterCreate`, `CharacterSelect` +- Game: `MoveRequest`, `InteractRequest`, `UseItemRequest`, `PickupItemRequest` +- Combat: `InitiateCombatRequest`, `CombatActionRequest`, `PvPCombatInitiateRequest`, `PvPAcknowledgeRequest`, `PvPCombatActionRequest` +- Equipment: `EquipItemRequest`, `UnequipItemRequest`, `RepairItemRequest` +- Crafting: `CraftItemRequest`, `UncraftItemRequest` +- Loot: `LootCorpseRequest` + +### `api/services/helpers.py` +**Utility Functions:** +- `calculate_distance(x1, y1, x2, y2) -> float` +- `calculate_stamina_cost(...) -> int` +- `calculate_player_capacity(player_id) -> Tuple[float, float, float, float]` +- `reduce_armor_durability(player_id, damage_taken) -> Tuple[int, List]` +- `consume_tool_durability(user_id, tools, inventory) -> Tuple[bool, str, list]` + +### `api/routers/auth.py` +**Endpoints (3):** +- `POST /api/auth/register` - Register new account +- `POST /api/auth/login` - Login with email/password +- `GET /api/auth/me` - Get current user profile + +--- + +## ๐ŸŽฏ How to Use the New Structure + +### Example: Using Security Module +```python +# OLD (in main.py): +from fastapi.security import HTTPBearer +security = HTTPBearer() +# ... 100+ lines of JWT code ... + +# NEW (anywhere): +from api.core.security import get_current_user, create_access_token, hash_password + +@router.post("/some-endpoint") +async def my_endpoint(current_user = Depends(get_current_user)): + # current_user is automatically validated and loaded + pass +``` + +### Example: Using Config +```python +# OLD: +SECRET_KEY = os.getenv("JWT_SECRET_KEY", "...") +CORS_ORIGINS = ["https://...", "http://..."] + +# NEW: +from api.core.config import SECRET_KEY, CORS_ORIGINS +``` + +### Example: Using Models +```python +# OLD (in main.py): +class MoveRequest(BaseModel): + direction: str + +# NEW (anywhere): +from api.services.models import MoveRequest +``` + +### Example: Using Helpers +```python +# OLD: +# Copy-paste helper function or import from main + +# NEW: +from api.services.helpers import calculate_distance, calculate_stamina_cost +``` + +--- + +## ๐Ÿ“Š Current State of main.py + +**Status:** Still 5574 lines (unchanged) +**Why:** We created the foundation but didn't migrate endpoints yet + +**What main.py currently contains:** +1. โœ… Clean imports (can now use new modules) +2. โŒ All 50+ endpoints still in the file +3. โŒ Helper functions still duplicated +4. โŒ Pydantic models still defined here + +--- + +## ๐Ÿš€ Migration Path Forward + +### Option 1: Gradual Migration (Recommended) +**Time:** 30 min - 2 hours per router +**Risk:** Low (test each router individually) + +**Steps for each router:** +1. Create router file (e.g., `routers/characters.py`) +2. Copy endpoint functions from main.py +3. Update imports to use new modules +4. Add router to main.py: `app.include_router(characters.router)` +5. Remove old endpoint code from main.py +6. Test the endpoints +7. Repeat for next router + +**Suggested Order:** +1. Characters (4 endpoints) - ~30 min +2. Game Actions (9 endpoints) - ~1 hour +3. Equipment (4 endpoints) - ~30 min +4. Crafting (3 endpoints) - ~30 min +5. Combat (3 PvE + 4 PvP = 7 endpoints) - ~1 hour +6. WebSocket (1 endpoint) - ~30 min + +**Total:** ~4-5 hours for complete migration + +### Option 2: Use Current Structure As-Is +**Time:** 0 hours +**Benefit:** Everything still works, new code uses clean modules + +**When creating new features:** +- Use the new modules (config, security, models, helpers) +- Create new routers instead of adding to main.py +- Gradually extract old code when you touch it + +--- + +## ๐Ÿ’ก Immediate Benefits (Already Achieved) + +Even without migrating endpoints, you already have: + +### 1. Clean Imports +```python +# Instead of scrolling through 5574 lines: +from api.core.security import get_current_user +from api.services.models import MoveRequest +from api.services.helpers import calculate_distance +``` + +### 2. Reusable Auth +```python +# Any new router can use: +@router.get("/new-endpoint") +async def my_new_endpoint(user = Depends(get_current_user)): + # Automatic auth! + pass +``` + +### 3. Centralized Config +```python +# Change CORS_ORIGINS in one place +# All routers automatically use it +from api.core.config import CORS_ORIGINS +``` + +### 4. Type Safety +```python +# All models in one place +# Easy to find, easy to reuse +from api.services.models import * +``` + +--- + +## ๐Ÿ“ File Sizes Comparison + +### Before Refactoring: +- `main.py`: **5,574 lines** ๐Ÿ˜ฑ +- Everything in one file + +### After Refactoring: +- `main.py`: 5,574 lines (unchanged, but ready for migration) +- `core/config.py`: 32 lines +- `core/security.py`: 128 lines +- `core/websockets.py`: 203 lines +- `services/models.py`: 122 lines +- `services/helpers.py`: 189 lines +- `routers/auth.py`: 152 lines + +**Total new code:** ~826 lines across 6 well-organized files + +### After Full Migration (Projected): +- `main.py`: ~150 lines (just app setup) +- 6 core/service files: ~826 lines +- 6-7 router files: ~1,200 lines +- **Total:** ~2,176 lines (vs 5,574 original) +- **Reduction:** 60% less code through deduplication and organization + +--- + +## ๐ŸŽ“ For Future Development + +### Creating a New Feature: +```python +# 1. Create router file +# api/routers/my_feature.py +from fastapi import APIRouter, Depends +from ..core.security import get_current_user +from ..services.models import MyRequest +from .. import database as db + +router = APIRouter(prefix="/api/my-feature", tags=["my-feature"]) + +@router.post("/action") +async def do_something( + request: MyRequest, + current_user = Depends(get_current_user) +): + # Your logic here + return {"success": True} + +# 2. Register in main.py +from .routers import my_feature +app.include_router(my_feature.router) +``` + +### Adding a New Model: +```python +# Just add to services/models.py +class MyNewRequest(BaseModel): + field1: str + field2: int +``` + +### Adding a Helper Function: +```python +# Just add to services/helpers.py +def my_helper_function(param1, param2): + # Your logic + return result +``` + +--- + +## โœ… Summary + +### What Works Now: +- โœ… All existing endpoints still work +- โœ… Clean module structure ready +- โœ… Auth router fully functional +- โœ… Logging properly configured +- โœ… Project root cleaned up + +### What's Ready: +- โœ… Foundation for gradual migration +- โœ… New features can use clean structure immediately +- โœ… No breaking changes +- โœ… Easy to understand and maintain + +### What's Next (Optional): +- Migrate remaining endpoints to routers +- Delete old code from main.py +- End result: ~150 line main.py instead of 5,574 + +--- + +## ๐ŸŽ‰ Conclusion + +**You now have a solid foundation for maintainable code!** + +The refactoring can be completed gradually, or you can use the new structure as-is for new features. Either way, the hardest part (creating the clean architecture) is done. + +**Time invested:** ~2 hours +**Value delivered:** Clean structure that will save hours in future development +**Breaking changes:** None +**Risk:** Zero diff --git a/docs/archive/REFACTORING_PLAN.md b/docs/archive/REFACTORING_PLAN.md new file mode 100644 index 0000000..8d7e904 --- /dev/null +++ b/docs/archive/REFACTORING_PLAN.md @@ -0,0 +1,160 @@ +# Project Refactoring Plan + +## Current Status + +### โœ… Completed +1. **Moved unused files to `old/` folder**: + - `bot/` - Old Telegram bot code (no longer used) + - `web-map/` - Old map editor + - All `.md` documentation files + - Old migration scripts + - Old Dockerfiles + +2. **Created new API module structure**: + ``` + api/ + โ”œโ”€โ”€ core/ # Core functionality (config, security, websockets) + โ”œโ”€โ”€ routers/ # API route handlers + โ”œโ”€โ”€ services/ # Business logic services + โ””โ”€โ”€ ...existing files... + ``` + +3. **Created core modules**: + - โœ… `api/core/config.py` - All configuration and constants + - โœ… `api/core/security.py` - JWT, auth, password hashing + - โœ… `api/core/websockets.py` - WebSocket ConnectionManager + +### ๐Ÿ”„ Next Steps + +#### Backend API Refactoring + +**Router Files to Create** (in `api/routers/`): +1. `auth.py` - `/api/auth/*` endpoints (register, login, me) +2. `characters.py` - `/api/characters/*` endpoints (list, create, select, delete) +3. `game.py` - `/api/game/*` endpoints (state, location, profile, move, inspect, interact, pickup, use_item) +4. `combat.py` - `/api/game/combat/*` endpoints (initiate, action) + PvP combat +5. `equipment.py` - `/api/game/equip/*` endpoints (equip, unequip, repair) +6. `crafting.py` - `/api/game/craft/*` endpoints (craftable, craft_item) +7. `corpses.py` - `/api/game/corpses/*` and `/api/internal/corpses/*` endpoints +8. `websocket.py` - `/ws/game/*` WebSocket endpoint + +**Helper Files to Create** (in `api/services/`): +1. `helpers.py` - Utility functions (distance calculation, stamina cost, armor durability, etc.) +2. `models.py` - Pydantic models (all request/response models) + +**Final `api/main.py`** will contain ONLY: +- FastAPI app initialization +- Middleware setup (CORS) +- Static file mounting +- Router registration +- Lifespan context (startup/shutdown) +- ~100 lines instead of 5500+ + +#### Frontend Refactoring + +**Components to Extract from Game.tsx**: + +In `pwa/src/components/game/`: +1. `Compass.tsx` - Navigation compass with stamina costs +2. `LocationView.tsx` - Location description and image +3. `Surroundings.tsx` - NPCs, players, items, corpses, interactables +4. `InventoryPanel.tsx` - Inventory management +5. `EquipmentPanel.tsx` - Equipment slots +6. `CombatView.tsx` - Combat interface (PvE and PvP) +7. `ProfilePanel.tsx` - Player stats and info +8. `CraftingPanel.tsx` - Crafting interface +9. `DeathOverlay.tsx` - Death screen + +**Shared hooks** (in `pwa/src/hooks/`): +1. `useWebSocket.ts` - WebSocket connection and message handling +2. `useGameState.ts` - Game state management +3. `useCombat.ts` - Combat state and actions + +**Type definitions** (in `pwa/src/types/`): +1. `game.ts` - Game entities (Player, Location, Item, NPC, etc.) +2. `combat.ts` - Combat-related types +3. `websocket.ts` - WebSocket message types + +**Final `Game.tsx`** will contain ONLY: +- Component composition +- State management coordination +- WebSocket message routing +- ~300-400 lines instead of 3300+ + +### ๐Ÿ“‹ Estimated File Count + +**Before**: +- Backend: 1 massive file (5574 lines) +- Frontend: 1 massive file (3315 lines) +- Total: 2 files, ~9000 lines + +**After**: +- Backend: ~15 files, average ~200-400 lines each +- Frontend: ~15 files, average ~100-300 lines each +- Total: ~30 files, all maintainable and focused + +### ๐ŸŽฏ Benefits + +1. **Easier to navigate** - Each file has a single responsibility +2. **Easier to test** - Isolated components and functions +3. **Easier to maintain** - Changes don't affect unrelated code +4. **Easier to understand** - Clear module boundaries +5. **Better IDE support** - Faster autocomplete, better error detection +6. **Team-friendly** - Multiple developers can work without conflicts + +## Implementation Strategy + +### Phase 1: Backend (4-5 hours) +1. Create all router files with endpoints +2. Create service/helper files +3. Extract Pydantic models +4. Refactor main.py to just registration +5. Test all endpoints still work + +### Phase 2: Frontend (3-4 hours) +1. Create type definitions +2. Extract hooks +3. Create component files +4. Refactor Game.tsx to use components +5. Test all functionality still works + +### Phase 3: TypeScript Configuration (30 minutes) +1. Create/update `tsconfig.json` +2. Add proper type definitions +3. Fix VSCode errors + +### Phase 4: Testing & Documentation (1 hour) +1. Verify all features work +2. Update README with new structure +3. Create architecture diagram + +## Questions Before Proceeding + +1. **Should I continue with the full refactoring now?** + - This will take significant time (8-10 hours of work) + - Will create 30+ new files + - Will require thorough testing + +2. **Do you want me to do it all at once or in phases?** + - All at once: Complete transformation + - Phases: Backend first, then frontend, then testing + +3. **Any specific preferences for file organization?** + - Current plan follows standard FastAPI/React best practices + - Open to adjustments + +## Recommendation + +I recommend doing this in **phases with testing after each**: +1. **Phase 1**: Backend refactoring (today) - Most critical, easier to test +2. **Phase 2**: Frontend refactoring (next session) - Can verify backend works first +3. **Phase 3**: TypeScript fixes (quick win) +4. **Phase 4**: Final testing and documentation + +This approach: +- Allows for testing and validation at each step +- Reduces risk of breaking everything at once +- Gives you time to review and provide feedback +- Easier to roll back if issues arise + +Would you like me to proceed with **Phase 1: Backend Refactoring** now? diff --git a/docs/archive/WEBSOCKET_HANDLER_FIX.md b/docs/archive/WEBSOCKET_HANDLER_FIX.md new file mode 100644 index 0000000..b7f06d9 --- /dev/null +++ b/docs/archive/WEBSOCKET_HANDLER_FIX.md @@ -0,0 +1,181 @@ +# WebSocket Message Handler Implementation + +## Date: 2025-11-17 + +## Problem +WebSocket was receiving `location_update` messages but not processing them correctly: +- Console showed: "Unknown WebSocket message type: location_update" +- All WebSocket messages triggered full `fetchGameData()` API call (inefficient) +- Players entering/leaving zones not visible until page refresh +- Real-time multiplayer updates broken + +## Solution Implemented + +### 1. Added Comprehensive WebSocket Message Handlers (Game.tsx) + +Replaced simple `fetchGameData()` calls with intelligent, granular state updates: + +#### Message Types Now Handled: + +**location_update** (NEW): +- Handles: player_arrived, player_left, corpse_looted, enemy_despawned +- Action: Calls `refreshLocation()` to update only location data +- Enables real-time multiplayer visibility + +**state_update**: +- Checks message.data for player, location, or encounter updates +- Updates only relevant state slices +- No full game state refresh needed + +**combat_started/combat_update/combat_ended**: +- Updates combat state directly from message.data +- Updates player HP/XP/level in real-time during combat +- Refreshes location after combat ends (for corpses/loot) + +**item_picked_up/item_dropped**: +- Refreshes location items only +- Shows real-time item changes for all players in zone + +**interactable_cooldown** (NEW): +- Updates cooldown state directly +- No API call needed + +### 2. Added WebSocket Helper Functions (useGameEngine.ts) + +Created 5 new helper functions exported via actions: + +```typescript +// Refresh only location data (efficient) +refreshLocation: () => Promise + +// Refresh only combat data (efficient) +refreshCombat: () => Promise + +// Update player state directly (HP/XP/level) +updatePlayerState: (playerData: any) => void + +// Update combat state directly +updateCombatState: (combatData: any) => void + +// Update interactable cooldowns directly +updateCooldowns: (cooldowns: Record) => void +``` + +### 3. Updated Type Definitions + +**vite-env.d.ts**: +- Added `VITE_WS_URL` to ImportMetaEnv interface +- Fixes TypeScript error for WebSocket URL env var + +**GameEngineActions interface**: +- Added 5 new WebSocket helper functions +- Maintains type safety throughout + +## Backend Message Structure + +### location_update Messages: +```json +{ + "type": "location_update", + "data": { + "message": "PlayerName arrived", + "action": "player_arrived", + "player_id": 123, + "player_name": "PlayerName", + "player_level": 5, + "can_pvp": true + }, + "timestamp": "2025-11-17T14:23:37.000Z" +} +``` + +**Actions**: player_arrived, player_left, corpse_looted, enemy_despawned + +### state_update Messages: +```json +{ + "type": "state_update", + "data": { + "player": { "stamina": 95, "location_id": "location_001" }, + "location": { "id": "location_001", "name": "The Ruins" }, + "encounter": { ... } + }, + "timestamp": "..." +} +``` + +### combat_update Messages: +```json +{ + "type": "combat_update", + "data": { + "message": "You dealt 15 damage!", + "log_entry": "You dealt 15 damage!", + "combat_over": false, + "combat": { ... }, + "player": { "hp": 85, "xp": 1250, "level": 5 } + }, + "timestamp": "..." +} +``` + +## Performance Impact + +### Before: +- Every WebSocket message โ†’ Full `fetchGameData()` API call +- Fetches: player state, location, profile, combat, equipment, PvP +- ~5-10 API calls for every WebSocket message +- High server load, slow UI updates + +### After: +- `location_update` โ†’ Only location data refresh (1 API call) +- `combat_update` โ†’ Direct state update (0 API calls if data provided) +- `state_update` โ†’ Targeted updates (0-2 API calls) +- 80-90% reduction in unnecessary API calls + +## User Experience Improvements + +1. **Real-time Multiplayer**: Players see others enter/leave zones immediately +2. **Combat Updates**: HP changes visible during combat, not after +3. **Item Changes**: Loot/drops visible to all players instantly +4. **Reduced Lag**: Fewer API calls = faster UI response +5. **Better Feedback**: Specific console logs for debugging + +## Files Modified + +1. **pwa/src/components/Game.tsx**: + - handleWebSocketMessage function (lines 16-118) + - Added all message type handlers with granular updates + +2. **pwa/src/components/game/hooks/useGameEngine.ts**: + - Added 5 WebSocket helper functions (lines 916-962) + - Updated GameEngineActions interface (lines 64-131) + - Updated actions export (lines 970-1013) + +3. **pwa/src/vite-env.d.ts**: + - Added VITE_WS_URL to ImportMetaEnv interface + +## Testing Recommendations + +1. Open game in two browser windows +2. Move one player between locations +3. Verify other window shows "PlayerName arrived" immediately +4. Test combat - HP should update in real-time +5. Test looting - other players should see corpse disappear +6. Check console for message type logs + +## Next Steps (Optional Improvements) + +1. Add typing for message.data structures +2. Implement retry logic for failed WebSocket messages +3. Add message queue for offline message buffering +4. Consider adding WebSocket message acknowledgments +5. Implement heartbeat/keepalive mechanism + +## Conclusion + +WebSocket message handling is now efficient and complete. All message types from backend are properly handled, state updates are granular, and unnecessary API calls are eliminated. Real-time multiplayer features now work as expected. + +**Build Status**: โœ… Successful +**Deployment Status**: โœ… Deployed +**TypeScript Errors**: โœ… None diff --git a/docs/archive/refactor_summary.md b/docs/archive/refactor_summary.md new file mode 100644 index 0000000..cf27cd9 --- /dev/null +++ b/docs/archive/refactor_summary.md @@ -0,0 +1,51 @@ +# Backend Refactoring Summary + +## โœ… Completed Structure + +### Core Modules (`api/core/`) +- โœ… `config.py` - All configuration, constants, CORS origins +- โœ… `security.py` - JWT, auth, password hashing, dependencies +- โœ… `websockets.py` - ConnectionManager for WebSocket handling + +### Services (`api/services/`) +- โœ… `models.py` - All Pydantic request/response models +- โœ… `helpers.py` - Utility functions (distance, stamina, armor, tools) + +### Routers (`api/routers/`) +- โœ… `auth.py` - Authentication endpoints (register, login, me) +- ๐Ÿ”„ `characters.py` - Character management (create, list, select, delete) +- ๐Ÿ”„ `game_routes.py` - Game actions (state, location, move, interact, pickup, use_item) +- ๐Ÿ”„ `combat.py` - PvE and PvP combat endpoints +- ๐Ÿ”„ `equipment.py` - Equipment management (equip, unequip, repair) +- ๐Ÿ”„ `crafting.py` - Crafting system +- ๐Ÿ”„ `websocket_route.py` - WebSocket connection endpoint + +## ๐Ÿ“‹ Next Steps + +Due to the massive size of main.py (5574 lines), I recommend: + +### Option A: Gradual Migration (RECOMMENDED) +1. Keep current main.py as `main_legacy.py` +2. Create new slim `main.py` that imports from both legacy and new routers +3. Migrate endpoints one router at a time +4. Test after each migration +5. Remove legacy code when all routers are migrated + +### Option B: Complete Rewrite (RISKY) +1. Create all router files at once +2. Create new main.py +3. Test everything comprehensively +4. High risk of breaking changes + +## ๐ŸŽฏ Recommended Implementation + +I can create a **hybrid approach**: +1. Create the new clean main.py structure +2. Keep all existing endpoint code in the file temporarily +3. You can then gradually extract endpoints to routers as needed +4. This gives you the clean structure without breaking anything + +Would you like me to: +A) Create the clean main.py with router registration (keeping existing code for now)? +B) Continue creating all router files (will take significant time)? +C) Create a migration script to help you do it gradually? diff --git a/pwa/src/components/CharacterSelection.tsx b/pwa/src/components/CharacterSelection.tsx index a5d1910..cedde47 100644 --- a/pwa/src/components/CharacterSelection.tsx +++ b/pwa/src/components/CharacterSelection.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom' import { useAuth } from '../hooks/useAuth' import { Character } from '../services/api' import './CharacterSelection.css' +import { GameTooltip } from './common/GameTooltip' function CharacterSelection() { const { characters, account, selectCharacter, deleteCharacter, logout } = useAuth() @@ -14,7 +15,7 @@ function CharacterSelection() { const handleSelectCharacter = async (characterId: number) => { setLoading(true) setError('') - + try { await selectCharacter(characterId) navigate('/game') @@ -32,7 +33,7 @@ function CharacterSelection() { setDeletingId(characterId) setError('') - + try { await deleteCharacter(characterId) } catch (err: any) { @@ -102,12 +103,12 @@ function CharacterSelection() { ) } -function CharacterCard({ - character, - onSelect, - onDelete, - loading -}: { +function CharacterCard({ + character, + onSelect, + onDelete, + loading +}: { character: Character onSelect: () => void onDelete: () => void @@ -135,11 +136,21 @@ function CharacterCard({ Level {character.level} HP: {character.hp}/{character.max_hp} + +
- ๐Ÿ’ช {character.strength} - โšก {character.agility} - ๐Ÿ›ก๏ธ {character.endurance} - ๐Ÿง  {character.intellect} + + ๐Ÿ’ช {character.strength} + + + โšก {character.agility} + + + ๐Ÿ›ก๏ธ {character.endurance} + + + ๐Ÿง  {character.intellect} +

Last played: {formatDate(character.last_played_at)} @@ -147,15 +158,15 @@ function CharacterCard({

- - + + + ); })() )} diff --git a/pwa/src/components/game/LocationView.tsx b/pwa/src/components/game/LocationView.tsx index f400cf4..34c583e 100644 --- a/pwa/src/components/game/LocationView.tsx +++ b/pwa/src/components/game/LocationView.tsx @@ -3,6 +3,7 @@ import type { Location, PlayerState, CombatState, Profile, WorkbenchTab } from ' import { useTranslation } from 'react-i18next' import { useAudio } from '../../contexts/AudioContext' import Workbench from './Workbench' +import { GameTooltip } from '../common/GameTooltip' import { getAssetPath } from '../../utils/assetPath' import { getTranslatedText } from '../../utils/i18nUtils' @@ -91,12 +92,16 @@ function LocationView({

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

@@ -110,24 +115,24 @@ function LocationView({ } return ( - - {tag === 'workbench' && t('tags.workbench')} - {tag === 'repair_station' && t('tags.repairStation')} - {tag === 'safe_zone' && t('tags.safeZone')} - {tag === 'shop' && t('tags.shop')} - {tag === 'shelter' && t('tags.shelter')} - {tag === 'medical' && t('tags.medical')} - {tag === 'storage' && t('tags.storage')} - {tag === 'water_source' && t('tags.water')} - {tag === 'food_source' && t('tags.food')} - {tag !== 'workbench' && tag !== 'repair_station' && tag !== 'safe_zone' && tag !== 'shop' && tag !== 'shelter' && tag !== 'medical' && tag !== 'storage' && tag !== 'water_source' && tag !== 'food_source' && `๐Ÿท๏ธ ${tag}`} - + + + {tag === 'workbench' && t('tags.workbench')} + {tag === 'repair_station' && t('tags.repairStation')} + {tag === 'safe_zone' && t('tags.safeZone')} + {tag === 'shop' && t('tags.shop')} + {tag === 'shelter' && t('tags.shelter')} + {tag === 'medical' && t('tags.medical')} + {tag === 'storage' && t('tags.storage')} + {tag === 'water_source' && t('tags.water')} + {tag === 'food_source' && t('tags.food')} + {tag !== 'workbench' && tag !== 'repair_station' && tag !== 'safe_zone' && tag !== 'shop' && tag !== 'shelter' && tag !== 'medical' && tag !== 'storage' && tag !== 'water_source' && tag !== 'food_source' && `๐Ÿท๏ธ ${tag}`} + + ) })}
@@ -257,14 +262,15 @@ function LocationView({ )} - + + + ))} @@ -328,39 +334,42 @@ function LocationView({ {item.quantity > 1 &&
ร—{item.quantity}
}
- -
- {item.description &&
{getTranslatedText(item.description)}
} - {item.weight !== undefined && item.weight > 0 && ( -
- โš–๏ธ {t('stats.weight')}: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`} -
- )} - {item.volume !== undefined && item.volume > 0 && ( -
- ๐Ÿ“ฆ {t('stats.volume')}: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`} -
- )} - {item.hp_restore && item.hp_restore > 0 && ( -
โค๏ธ {t('stats.hpRestore')}: +{item.hp_restore}
- )} - {item.stamina_restore && item.stamina_restore > 0 && ( -
โšก {t('stats.staminaRestore')}: +{item.stamina_restore}
- )} - {item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && ( -
- โš”๏ธ {t('stats.damage')}: {item.damage_min}-{item.damage_max} -
- )} - {item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && ( -
- ๐Ÿ”ง {t('stats.durability')}: {item.durability}/{item.max_durability} -
- )} - {item.tier !== undefined && item.tier !== null && item.tier > 0 && ( -
โญ {t('stats.tier')}: {item.tier}
- )} -
+ + {item.description &&
{getTranslatedText(item.description)}
} + {item.weight !== undefined && item.weight > 0 && ( +
+ โš–๏ธ {t('stats.weight')}: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`} +
+ )} + {item.volume !== undefined && item.volume > 0 && ( +
+ ๐Ÿ“ฆ {t('stats.volume')}: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`} +
+ )} + {item.hp_restore && item.hp_restore > 0 && ( +
โค๏ธ {t('stats.hpRestore')}: +{item.hp_restore}
+ )} + {item.stamina_restore && item.stamina_restore > 0 && ( +
โšก {t('stats.staminaRestore')}: +{item.stamina_restore}
+ )} + {item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && ( +
+ โš”๏ธ {t('stats.damage')}: {item.damage_min}-{item.damage_max} +
+ )} + {item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && ( +
+ ๐Ÿ”ง {t('stats.durability')}: {item.durability}/{item.max_durability} +
+ )} + {item.tier !== undefined && item.tier !== null && item.tier > 0 && ( +
โญ {t('stats.tier')}: {item.tier}
+ )} +
+ }> + + {item.quantity === 1 ? ( + + + )} {!player.can_pvp && player.level_diff !== undefined && Math.abs(player.level_diff) > 3 && (
{t('game.levelDifferenceTooHigh')}
diff --git a/pwa/src/components/game/MovementControls.tsx b/pwa/src/components/game/MovementControls.tsx index 71f0dad..ab2a9a3 100644 --- a/pwa/src/components/game/MovementControls.tsx +++ b/pwa/src/components/game/MovementControls.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { getAssetPath } from '../../utils/assetPath' import { getTranslatedText } from '../../utils/i18nUtils' +import { GameTooltip } from '../common/GameTooltip' interface MovementControlsProps { location: Location @@ -77,24 +78,29 @@ function MovementControls({ movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : insufficientStamina ? t('messages.notEnoughStamina', { need: stamina, have: profile?.stamina ?? 0 }) : - available ? `${destination}\n${t('game.distance')}: ${distance}m\n${t('game.stamina')}: ${stamina}` : - t('messages.cannotGo', { direction: t('directions.' + direction) }) + available ? ( +
+
{destination}
+
๐Ÿ“ {t('game.distance')}: {distance}m
+
โšก {t('game.stamina')}: {stamina}
+
+ ) : t('messages.cannotGo', { direction: t('directions.' + direction) }) return ( - + + + ) } @@ -131,64 +137,95 @@ function MovementControls({ {/* Special movements */}
{location.directions.includes('up') && ( - + 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : ( +
+
{t('directions.up')}
+
โšก {t('game.stamina')}: {getStaminaCost('up')}
+
+ )}> + +
)} {location.directions.includes('down') && ( - + 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : ( +
+
{t('directions.down')}
+
โšก {t('game.stamina')}: {getStaminaCost('down')}
+
+ )}> + +
)} {location.directions.includes('enter') && ( - + 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : ( +
+
{t('directions.enter')}
+
โšก {t('game.stamina')}: {getStaminaCost('enter')}
+
+ )}> + +
)} {location.directions.includes('inside') && ( - + 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : ( +
+
{t('directions.inside')}
+
โšก {t('game.stamina')}: {getStaminaCost('inside')}
+
+ )}> + +
)} {location.directions.includes('exit') && ( - + 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : t('directions.exit')}> + + )} {location.directions.includes('outside') && ( - + 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : ( +
+
{t('directions.outside')}
+
โšก {t('game.stamina')}: {getStaminaCost('outside')}
+
+ )}> + +
)}
@@ -228,28 +265,28 @@ function MovementControls({ const insufficientStamina = profile ? profile.stamina < staminaCost : false return ( - + 0 + ? t('messages.interactionCooldown', { seconds: cooldownRemaining }) + : getTranslatedText(action.description) + }> + + ) })} diff --git a/pwa/src/components/game/PlayerSidebar.tsx b/pwa/src/components/game/PlayerSidebar.tsx index 98f81c2..4b2f3a6 100644 --- a/pwa/src/components/game/PlayerSidebar.tsx +++ b/pwa/src/components/game/PlayerSidebar.tsx @@ -5,6 +5,7 @@ import { getAssetPath } from '../../utils/assetPath' import { getTranslatedText } from '../../utils/i18nUtils' import InventoryModal from './InventoryModal' import { GameProgressBar } from '../common/GameProgressBar' +import { GameTooltip } from '../common/GameTooltip' interface PlayerSidebarProps { playerState: PlayerState @@ -40,106 +41,118 @@ function PlayerSidebar({ const [showInventory, setShowInventory] = useState(false) const { t } = useTranslation() - const renderEquipmentSlot = (slot: string, item: any, label: string) => ( -
- {item ? ( - <> - -
- {item.image_path ? ( - {getTranslatedText(item.name)} - ) : ( - {item.emoji} - )} - {item.durability !== undefined && item.durability !== null && ( -
- -
- )} + const renderEquipmentSlot = (slot: string, item: any, label: string) => { + // Construct the tooltip content if item exists + const tooltipContent = item ? ( +
+
+ {getTranslatedText(item.name)} +
+ {item.tier !== undefined && item.tier !== null && item.tier > 0 && ( +
+ โญ Tier: {item.tier}
-
-
{getTranslatedText(item.name)}
- {item.tier !== undefined && item.tier !== null && item.tier > 0 && ( -
- โญ Tier: {item.tier} -
- )} - {item.description &&
{getTranslatedText(item.description)}
} - {(item.unique_stats || item.stats) && Object.keys(item.unique_stats || item.stats).length > 0 && ( - <> - {(item.unique_stats?.armor || item.stats?.armor) && ( -
- {t('stats.armor')}: +{item.unique_stats?.armor || item.stats?.armor} -
- )} - {(item.unique_stats?.hp_max || item.stats?.hp_max) && ( -
- {t('stats.hp')}: +{item.unique_stats?.hp_max || item.stats?.hp_max} -
- )} - {(item.unique_stats?.stamina_max || item.stats?.stamina_max) && ( -
- {t('stats.stamina')}: +{item.unique_stats?.stamina_max || item.stats?.stamina_max} -
- )} - {(item.unique_stats?.damage_min !== undefined || item.stats?.damage_min !== undefined) && - (item.unique_stats?.damage_max !== undefined || item.stats?.damage_max !== undefined) && ( -
- {t('stats.damage')}: {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max} -
- )} - {(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && ( -
- {t('stats.weight')}: +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg -
- )} - {(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && ( -
- {t('stats.volume')}: +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L -
- )} - - )} - {item.durability !== undefined && item.durability !== null && ( + )} + {item.description &&
{getTranslatedText(item.description)}
} + {(item.unique_stats || item.stats) && Object.keys(item.unique_stats || item.stats).length > 0 && ( +
+ {(item.unique_stats?.armor || item.stats?.armor) && (
-
- {t('stats.durability')}: - {item.durability}/{item.max_durability} + {t('stats.armor')}: +{item.unique_stats?.armor || item.stats?.armor} +
+ )} + {(item.unique_stats?.hp_max || item.stats?.hp_max) && ( +
+ {t('stats.hp')}: +{item.unique_stats?.hp_max || item.stats?.hp_max} +
+ )} + {(item.unique_stats?.stamina_max || item.stats?.stamina_max) && ( +
+ {t('stats.stamina')}: +{item.unique_stats?.stamina_max || item.stats?.stamina_max} +
+ )} + {(item.unique_stats?.damage_min !== undefined || item.stats?.damage_min !== undefined) && + (item.unique_stats?.damage_max !== undefined || item.stats?.damage_max !== undefined) && ( +
+ {t('stats.damage')}: {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}
- + )} + {(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && ( +
+ {t('stats.weight')}: +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg +
+ )} + {(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && ( +
+ {t('stats.volume')}: +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L
)}
- - ) : ( - <> - {label} - - )} -
- ) + )} + {item.durability !== undefined && item.durability !== null && ( +
+
+ {t('stats.durability')}: + {item.durability}/{item.max_durability} +
+ +
+ )} +
+ ) : label; // Show label if no item + + return ( + +
+ {item ? ( + <> + + + +
+ {item.image_path ? ( + {getTranslatedText(item.name)} + ) : ( + {item.emoji} + )} + {item.durability !== undefined && item.durability !== null && ( +
+ +
+ )} +
+ + ) : ( + <> + {label} + + )} +
+
+ ) + } return (
diff --git a/pwa/src/components/game/Workbench.css b/pwa/src/components/game/Workbench.css index d5f06ca..1185a45 100644 --- a/pwa/src/components/game/Workbench.css +++ b/pwa/src/components/game/Workbench.css @@ -17,13 +17,15 @@ width: 95vw; max-width: 1400px; height: 85vh; - background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%); - border: 1px solid #4a5568; - border-radius: 12px; + background: var(--game-bg-modal); + border: 1px solid var(--game-border-color); + border-radius: var(--game-radius-lg); display: flex; flex-direction: column; - box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); + box-shadow: var(--game-shadow-modal); overflow: hidden; + color: var(--game-text-primary); + font-family: var(--game-font-main); } .workbench-header { @@ -31,14 +33,14 @@ justify-content: space-between; align-items: center; padding: 1rem 1.5rem; - background: rgba(0, 0, 0, 0.2); - border-bottom: 1px solid #4a5568; + background: var(--game-bg-panel); + border-bottom: 1px solid var(--game-border-color); } .workbench-header h3 { margin: 0; font-size: 1.5rem; - color: #e2e8f0; + color: var(--game-text-highlight); display: flex; align-items: center; gap: 0.5rem; @@ -98,8 +100,8 @@ /* Column 1: Sidebar */ .workbench-sidebar { - background: rgba(0, 0, 0, 0.2); - border-right: 1px solid #3a4b5c; + background: var(--game-bg-panel); + border-right: 1px solid var(--game-border-color); padding: 1rem; display: flex; flex-direction: column; @@ -142,9 +144,9 @@ } .workbench-sidebar .category-btn.active { - background: rgba(66, 153, 225, 0.15); - border-color: #4299e1; - color: #63b3ed; + background: rgba(234, 113, 66, 0.15); + border-color: var(--game-color-primary); + color: var(--game-color-primary); } .workbench-sidebar .cat-icon { @@ -187,9 +189,9 @@ display: flex; align-items: center; padding: 0.75rem; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.05); - border-radius: 6px; + background: var(--game-bg-card); + border: 1px solid var(--game-border-color); + border-radius: var(--game-radius-md); cursor: pointer; transition: all 0.2s; gap: 0.5rem; @@ -201,20 +203,21 @@ } .workbench-item-card.selected { - background: rgba(66, 153, 225, 0.1); - border-color: #4299e1; + background: rgba(234, 113, 66, 0.1); + border-color: var(--game-color-primary); + box-shadow: 0 0 0 1px var(--game-color-primary); } .workbench-item-card.craftable { - border-left: 3px solid #4caf50; + border-left: 3px solid var(--game-color-success); } .workbench-item-card.repairable { - border-left: 3px solid #ff9800; + border-left: 3px solid var(--game-color-warning); } .workbench-item-card.salvageable { - border-left: 3px solid #9c27b0; + border-left: 3px solid var(--game-color-danger); } .item-card-content { @@ -446,7 +449,7 @@ display: flex; flex-direction: column; align-items: center; - background: rgba(0, 0, 0, 0.2); + background: var(--game-bg-panel); } .detail-header { @@ -460,11 +463,11 @@ width: 120px; height: 120px; margin: 0 auto 1.5rem auto; - border-radius: 12px; + border-radius: var(--game-radius-md); overflow: hidden; - border: 2px solid #4a5568; - background: rgba(0, 0, 0, 0.3); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + border: 2px solid var(--game-border-color); + background: var(--game-bg-input); + box-shadow: var(--game-shadow-card); } .detail-image { diff --git a/pwa/src/index.css b/pwa/src/index.css index 66c093a..7810b94 100644 --- a/pwa/src/index.css +++ b/pwa/src/index.css @@ -1,11 +1,64 @@ :root { - font-family: 'Saira Condensed', system-ui, sans-serif; + /* --- Core Colors (Mature/Industrial) --- */ + --game-bg-app: #050505; + /* Deepest black */ + --game-bg-panel: rgba(18, 18, 24, 0.98); + /* Almost solid panels */ + --game-bg-glass: rgba(10, 10, 15, 0.9); + /* Overlays */ + --game-bg-slot: rgba(0, 0, 0, 0.6); + /* Item slots */ + --game-bg-slot-hover: rgba(255, 255, 255, 0.1); + --game-bg-tooltip: rgba(15, 15, 20, 0.98); + + /* --- Borders & Separators --- */ + --game-border-color: rgba(255, 255, 255, 0.12); + --game-border-active: rgba(255, 255, 255, 0.4); + --game-border-highlight: #ff6b6b; + /* Red accent border */ + + /* --- Dimensions --- */ + --game-radius-xs: 2px; + --game-radius-sm: 4px; + --game-radius-md: 6px; + + /* --- Typography --- */ + --game-font-main: 'Saira Condensed', system-ui, sans-serif; + --game-text-primary: #e0e0e0; + --game-text-secondary: #94a3b8; + --game-text-highlight: #fbbf24; + --game-text-danger: #ef4444; + + /* --- Semantic Colors --- */ + --game-color-primary: #e11d48; + /* Blood Red */ + --game-color-stamina: #d97706; + /* Amber */ + --game-color-magic: #3b82f6; + /* Blue */ + --game-color-success: #10b981; + /* Emerald */ + --game-color-warning: #f59e0b; + /* Amber */ + + /* --- Rarity --- */ + --rarity-common: #9ca3af; + --rarity-uncommon: #ffffff; + --rarity-rare: #34d399; + --rarity-epic: #60a5fa; + --rarity-legendary: #fbbf24; + + /* --- Effects --- */ + --game-shadow-panel: 0 8px 32px rgba(0, 0, 0, 0.8); + --game-shadow-tooltip: 0 4px 12px rgba(0, 0, 0, 0.8); + --game-shadow-glow: 0 0 15px rgba(225, 29, 72, 0.3); + + font-family: var(--game-font-main); line-height: 1.5; font-weight: 400; - color-scheme: dark; - color: rgba(255, 255, 255, 0.87); - background-color: #1a1a1a; + color: var(--game-text-primary); + background-color: var(--game-bg-app); font-synthesis: none; text-rendering: optimizeLegibility; @@ -13,58 +66,105 @@ -moz-osx-font-smoothing: grayscale; } -* { - margin: 0; - padding: 0; - box-sizing: border-box; +/* --- Reusable Game Classes --- */ + +/* Panels */ +.game-panel { + background: var(--game-bg-panel); + border: 1px solid var(--game-border-color); + box-shadow: var(--game-shadow-panel); + border-radius: var(--game-radius-sm); + backdrop-filter: blur(8px); } -body { - margin: 0; +.game-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(4px); + z-index: 1000; display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; - background-color: #1a1a1a; + align-items: center; + justify-content: center; } -#root { - width: 100%; - min-height: 100vh; -} - -button { - border-radius: 8px; - border: 1px solid transparent; +/* Buttons */ +.game-btn { + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--game-border-color); + color: var(--game-text-primary); padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #2a2a2a; + font-family: var(--game-font-main); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; cursor: pointer; - transition: border-color 0.25s; + transition: all 0.2s ease; + border-radius: var(--game-radius-xs); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; } -button:hover { - border-color: #646cff; +.game-btn:hover { + background: rgba(255, 255, 255, 0.1); + border-color: var(--game-text-secondary); + box-shadow: 0 0 8px rgba(255, 255, 255, 0.1); } -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; +.game-btn:active { + transform: translateY(1px); } -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - - button { - background-color: #f9f9f9; - } +.game-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; } +.game-btn-primary { + background: rgba(225, 29, 72, 0.2); + border-color: rgba(225, 29, 72, 0.5); + color: #ffcccc; +} + +.game-btn-primary:hover { + background: rgba(225, 29, 72, 0.3); + border-color: var(--game-color-primary); + box-shadow: var(--game-shadow-glow); +} + +.game-btn-icon { + padding: 0.5rem; + border-radius: 50%; + /* Or keep square for industrial look */ + line-height: 1; +} + +/* Slots */ +.game-slot { + background: var(--game-bg-slot); + border: 1px solid var(--game-border-color); + border-radius: var(--game-radius-xs); + position: relative; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1; +} + +.game-slot:hover { + background: var(--game-bg-slot-hover); + border-color: var(--game-border-active); +} + + + /* Twemoji styles */ img.emoji { height: 1em; diff --git a/scripts/convert_to_template_format.py b/scripts/convert_to_template_format.py new file mode 100644 index 0000000..3d46e2c --- /dev/null +++ b/scripts/convert_to_template_format.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +Convert locations.json to use template-based interactables format. +This script converts the new format (full instance data) to old format (template_id + outcomes). +""" + +import json +import sys + +def convert_interactables_to_template_format(interactables_dict): + """Convert interactables from full format to template format.""" + if not interactables_dict: + return {} + + converted = {} + + for instance_id, instance_data in interactables_dict.items(): + # Check if already in template format + if 'template_id' in instance_data: + # Already in template format, keep as is + converted[instance_id] = instance_data + continue + + # Check if in new format with 'id' and 'actions' + if 'id' in instance_data and 'actions' in instance_data: + # Convert to template format + template_id = instance_data['id'] + + # Build outcomes from actions + outcomes = {} + for action_id, action_data in instance_data['actions'].items(): + outcome = { + 'success_rate': 0.5, # Default success rate + 'stamina_cost': action_data.get('stamina_cost', 2), + 'crit_success_chance': 0.1, + 'crit_failure_chance': 0.1, + 'text': { + 'success': '', + 'failure': '', + 'crit_success': '', + 'crit_failure': '' + }, + 'rewards': { + 'items': [], + 'damage': 0, + 'crit_items': [], + 'crit_damage': 0 + } + } + + # Extract text from outcomes if available + if 'outcomes' in action_data: + if 'success' in action_data['outcomes']: + outcome['text']['success'] = action_data['outcomes']['success'].get('text', '') + # Convert items_reward to items list + items_reward = action_data['outcomes']['success'].get('items_reward', {}) + for item_id, quantity in items_reward.items(): + outcome['rewards']['items'].append({ + 'item_id': item_id, + 'quantity': quantity, + 'chance': 1.0 + }) + outcome['rewards']['damage'] = action_data['outcomes']['success'].get('damage_taken', 0) + + if 'failure' in action_data['outcomes']: + outcome['text']['failure'] = action_data['outcomes']['failure'].get('text', '') + + if 'critical_failure' in action_data['outcomes']: + outcome['text']['crit_failure'] = action_data['outcomes']['critical_failure'].get('text', '') + outcome['rewards']['crit_damage'] = action_data['outcomes']['critical_failure'].get('damage_taken', 0) + + outcomes[action_id] = outcome + + converted[instance_id] = { + 'template_id': template_id, + 'outcomes': outcomes + } + else: + # Unknown format, keep as is + converted[instance_id] = instance_data + + return converted + +def main(): + # Load locations.json + locations_file = 'gamedata/locations.json' + + try: + with open(locations_file, 'r', encoding='utf-8') as f: + data = json.load(f) + except Exception as e: + print(f"Error loading {locations_file}: {e}") + sys.exit(1) + + # Convert all location interactables + locations_converted = 0 + interactables_converted = 0 + + for location in data.get('locations', []): + if 'interactables' in location and location['interactables']: + original_count = len(location['interactables']) + location['interactables'] = convert_interactables_to_template_format(location['interactables']) + + if original_count > 0: + locations_converted += 1 + interactables_converted += original_count + + # Save back to file + try: + with open(locations_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + print(f"โœ… Conversion complete!") + print(f" Processed {locations_converted} locations") + print(f" Converted {interactables_converted} interactables to template format") + except Exception as e: + print(f"โŒ Error saving {locations_file}: {e}") + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/scripts/export_to_json.py b/scripts/export_to_json.py new file mode 100644 index 0000000..4fd8f97 --- /dev/null +++ b/scripts/export_to_json.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Extract current game data to JSON files +This script reads the current Python-based game data and exports it to JSON format +""" +import sys +import json +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent)) + +from data.world_loader import game_world, export_map_data +from data.npcs import NPCS, LOCATION_DANGER, LOCATION_SPAWNS +from data.items import ITEMS + +def export_to_json(): + """Export all game data to JSON files""" + gamedata_dir = Path(__file__).parent / 'gamedata' + gamedata_dir.mkdir(exist_ok=True) + + print("๐Ÿ”„ Exporting game data to JSON...") + + # 1. Export locations and world data + print(" ๐Ÿ“ Exporting locations...") + locations_data = { + "locations": [], + "connections": [] + } + + for location in game_world.locations.values(): + loc_data = { + "id": location.id, + "name": location.name, + "description": location.description, + "x": location.x, + "y": location.y, + "image_path": location.image_path, + "interactables": {} + } + + # Add interactables + for instance_id, interactable in location.interactables.items(): + inter_data = { + "id": interactable.id, + "name": interactable.name, + "image_path": interactable.image_path, + "actions": {} + } + + # Add actions + for action_id, action in interactable.actions.items(): + action_data = { + "id": action.id, + "label": action.label, + "stamina_cost": action.stamina_cost, + "outcomes": {} + } + + # Add outcomes + for outcome_name, outcome in action.outcomes.items(): + outcome_data = { + "text": outcome.text, + "items_reward": outcome.items_reward, + "damage_taken": outcome.damage_taken + } + action_data["outcomes"][outcome_name] = outcome_data + + inter_data["actions"][action_id] = action_data + + loc_data["interactables"][instance_id] = inter_data + + locations_data["locations"].append(loc_data) + + # Add connections with distance-based stamina cost + for direction, dest_id in location.exits.items(): + dest_loc = game_world.get_location(dest_id) + if dest_loc: + from data.travel_helpers import calculate_base_stamina_cost + stamina_cost = calculate_base_stamina_cost(location, dest_loc) + else: + stamina_cost = 5 # Fallback + + locations_data["connections"].append({ + "from": location.id, + "to": dest_id, + "direction": direction, + "stamina_cost": stamina_cost + }) + + with open(gamedata_dir / 'locations.json', 'w') as f: + json.dump(locations_data, f, indent=2) + print(f" โœ… Exported {len(locations_data['locations'])} locations and {len(locations_data['connections'])} connections") + + # 2. Export NPCs and spawns + print(" ๐Ÿ‘น Exporting NPCs...") + npcs_data = { + "npcs": {}, + "danger_levels": {}, + "spawn_tables": {} + } + + for npc_id, npc in NPCS.items(): + # Convert loot tables to serializable format + loot_table = [ + {"item_id": loot.item_id, "quantity_min": loot.quantity_min, "quantity_max": loot.quantity_max, "drop_chance": loot.drop_chance} + for loot in npc.loot_table + ] + corpse_loot = [ + {"item_id": loot.item_id, "quantity_min": loot.quantity_min, "quantity_max": loot.quantity_max, "required_tool": loot.required_tool} + for loot in npc.corpse_loot + ] + + npcs_data["npcs"][npc_id] = { + "npc_id": npc.npc_id, + "name": npc.name, + "description": npc.description, + "emoji": npc.emoji, + "hp_min": npc.hp_min, + "hp_max": npc.hp_max, + "damage_min": npc.damage_min, + "damage_max": npc.damage_max, + "defense": npc.defense, + "xp_reward": npc.xp_reward, + "loot_table": loot_table, + "corpse_loot": corpse_loot, + "flee_chance": npc.flee_chance, + "status_inflict_chance": npc.status_inflict_chance, + "image_url": npc.image_url, + "death_message": npc.death_message + } + + for location_id, (danger, encounter_rate, wandering_chance) in LOCATION_DANGER.items(): + npcs_data["danger_levels"][location_id] = { + "danger_level": danger, + "encounter_rate": encounter_rate, + "wandering_chance": wandering_chance + } + + for location_id, spawns in LOCATION_SPAWNS.items(): + npcs_data["spawn_tables"][location_id] = [ + {"npc_id": npc_id, "weight": weight} + for npc_id, weight in spawns + ] + + with open(gamedata_dir / 'npcs.json', 'w') as f: + json.dump(npcs_data, f, indent=2) + print(f" โœ… Exported {len(npcs_data['npcs'])} NPCs, {len(npcs_data['danger_levels'])} danger configs, {len(npcs_data['spawn_tables'])} spawn tables") + + # 3. Export items + print(" ๐ŸŽ’ Exporting items...") + items_data = { + "items": ITEMS # ITEMS is already a dict with the right structure + } + + with open(gamedata_dir / 'items.json', 'w') as f: + json.dump(items_data, f, indent=2) + print(f" โœ… Exported {len(items_data['items'])} items") + + print("\nโœ… Export complete! JSON files created in gamedata/") + print(f" ๐Ÿ“ {gamedata_dir.absolute()}") + +if __name__ == '__main__': + export_to_json() diff --git a/scripts/migrate_add_wandering_flag.py b/scripts/migrate_add_wandering_flag.py new file mode 100644 index 0000000..6c2d4f6 --- /dev/null +++ b/scripts/migrate_add_wandering_flag.py @@ -0,0 +1,79 @@ +""" +Migration script to add from_wandering_enemy column to active_combats table. +Run this once to update the database schema. +""" +import asyncio +import os +from dotenv import load_dotenv +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy import text + +load_dotenv() + +DB_USER = os.getenv("POSTGRES_USER") +DB_PASS = os.getenv("POSTGRES_PASSWORD") +DB_NAME = os.getenv("POSTGRES_DB") +DB_HOST = os.getenv("POSTGRES_HOST") +DB_PORT = os.getenv("POSTGRES_PORT") + +DATABASE_URL = f"postgresql+psycopg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + +async def migrate(): + """Add from_wandering_enemy column to active_combats table.""" + engine = create_async_engine(DATABASE_URL) + + async with engine.begin() as conn: + # Check if column already exists + result = await conn.execute(text(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name='active_combats' + AND column_name='from_wandering_enemy' + """)) + + exists = result.fetchone() + + if exists: + print("โœ… Column 'from_wandering_enemy' already exists. No migration needed.") + else: + print("๐Ÿ”ง Adding 'from_wandering_enemy' column to active_combats table...") + + # Add the column with default value False + await conn.execute(text(""" + ALTER TABLE active_combats + ADD COLUMN from_wandering_enemy BOOLEAN DEFAULT FALSE + """)) + + print("โœ… Column added successfully!") + + # Also check and create wandering_enemies table if it doesn't exist + result = await conn.execute(text(""" + SELECT table_name + FROM information_schema.tables + WHERE table_name='wandering_enemies' + """)) + + table_exists = result.fetchone() + + if table_exists: + print("โœ… Table 'wandering_enemies' already exists.") + else: + print("๐Ÿ”ง Creating 'wandering_enemies' table...") + + await conn.execute(text(""" + CREATE TABLE wandering_enemies ( + id SERIAL PRIMARY KEY, + npc_id VARCHAR NOT NULL, + location_id VARCHAR NOT NULL, + spawn_timestamp FLOAT NOT NULL, + despawn_timestamp FLOAT NOT NULL + ) + """)) + + print("โœ… Table 'wandering_enemies' created successfully!") + + await engine.dispose() + print("๐ŸŽ‰ Migration complete!") + +if __name__ == "__main__": + asyncio.run(migrate()) diff --git a/scripts/migrate_combat.py b/scripts/migrate_combat.py new file mode 100644 index 0000000..6dbf8a6 --- /dev/null +++ b/scripts/migrate_combat.py @@ -0,0 +1,117 @@ +""" +Migration script to add combat system columns to existing database. +Run this once to upgrade the database schema. +""" + +import asyncio +import os +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy import text +from dotenv import load_dotenv + +async def migrate(): + load_dotenv() + + DB_USER = os.getenv("POSTGRES_USER") + DB_PASS = os.getenv("POSTGRES_PASSWORD") + DB_NAME = os.getenv("POSTGRES_DB") + DB_HOST = os.getenv("POSTGRES_HOST") + DB_PORT = 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) + + print("Starting database migration...") + + async with engine.begin() as conn: + # Add level column if it doesn't exist + try: + await conn.execute(text( + "ALTER TABLE players ADD COLUMN level INTEGER DEFAULT 1" + )) + print("โœ… Added 'level' column") + except Exception as e: + if "already exists" in str(e): + print("โš ๏ธ 'level' column already exists, skipping") + else: + print(f"โŒ Error adding 'level': {e}") + + # Add xp column if it doesn't exist + try: + await conn.execute(text( + "ALTER TABLE players ADD COLUMN xp INTEGER DEFAULT 0" + )) + print("โœ… Added 'xp' column") + except Exception as e: + if "already exists" in str(e): + print("โš ๏ธ 'xp' column already exists, skipping") + else: + print(f"โŒ Error adding 'xp': {e}") + + # Add unspent_points column if it doesn't exist + try: + await conn.execute(text( + "ALTER TABLE players ADD COLUMN unspent_points INTEGER DEFAULT 0" + )) + print("โœ… Added 'unspent_points' column") + except Exception as e: + if "already exists" in str(e): + print("โš ๏ธ 'unspent_points' column already exists, skipping") + else: + print(f"โŒ Error adding 'unspent_points': {e}") + + # Create active_combats table if it doesn't exist + try: + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS active_combats ( + id SERIAL PRIMARY KEY, + player_id INTEGER UNIQUE REFERENCES players(telegram_id) ON DELETE CASCADE, + npc_id VARCHAR NOT NULL, + npc_hp INTEGER NOT NULL, + npc_max_hp INTEGER NOT NULL, + turn VARCHAR NOT NULL, + turn_started_at FLOAT NOT NULL, + player_status_effects VARCHAR DEFAULT '[]', + npc_status_effects VARCHAR DEFAULT '[]', + location_id VARCHAR NOT NULL + ) + """)) + print("โœ… Created 'active_combats' table") + except Exception as e: + print(f"โš ๏ธ 'active_combats' table: {e}") + + # Create player_corpses table if it doesn't exist + try: + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS player_corpses ( + id SERIAL PRIMARY KEY, + player_name VARCHAR NOT NULL, + location_id VARCHAR NOT NULL, + items VARCHAR NOT NULL, + death_timestamp FLOAT NOT NULL + ) + """)) + print("โœ… Created 'player_corpses' table") + except Exception as e: + print(f"โš ๏ธ 'player_corpses' table: {e}") + + # Create npc_corpses table if it doesn't exist + try: + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS npc_corpses ( + id SERIAL PRIMARY KEY, + npc_id VARCHAR NOT NULL, + location_id VARCHAR NOT NULL, + loot_remaining VARCHAR NOT NULL, + death_timestamp FLOAT NOT NULL + ) + """)) + print("โœ… Created 'npc_corpses' table") + except Exception as e: + print(f"โš ๏ธ 'npc_corpses' table: {e}") + + await engine.dispose() + print("\nโœ… Migration complete!") + +if __name__ == "__main__": + asyncio.run(migrate()) diff --git a/scripts/update_locations.py b/scripts/update_locations.py new file mode 100644 index 0000000..b58ed55 --- /dev/null +++ b/scripts/update_locations.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +""" +Update locations.json with missing interactables from world_loader_old.py +and spawn configurations from npcs_old.py +""" +import json +from pathlib import Path + +# Load current locations.json +locations_file = Path("gamedata/locations.json") +with open(locations_file, 'r') as f: + data = json.load(f) + +# Helper to find location by ID +def find_location(loc_id): + for loc in data['locations']: + if loc['id'] == loc_id: + return loc + return None + +# Add missing interactables to start_point +start_point = find_location('start_point') +if start_point and 'start_point_sedan' not in start_point.get('interactables', {}): + if 'interactables' not in start_point: + start_point['interactables'] = {} + + start_point['interactables']['start_point_sedan'] = { + "id": "sedan", + "name": "๐Ÿš— Rusty Sedan", + "image_path": "images/interactables/sedan.png", + "actions": { + "search_glovebox": { + "id": "search_glovebox", + "label": "๐Ÿ”Ž Search Glovebox", + "stamina_cost": 1, + "outcomes": { + "success": {"text": "You find a half-eaten [Stale Chocolate Bar].", "items_reward": {"stale_chocolate_bar": 1}, "damage_taken": 0}, + "failure": {"text": "The glovebox is empty except for dust and old receipts.", "items_reward": {}, "damage_taken": 0} + } + }, + "pop_trunk": { + "id": "pop_trunk", + "label": "๐Ÿ”ง Pop the Trunk", + "stamina_cost": 3, + "outcomes": { + "success": {"text": "With a great heave, you pry the trunk open and find a [Tire Iron]!", "items_reward": {"tire_iron": 1}, "damage_taken": 0}, + "failure": {"text": "The trunk is rusted shut. You can't get it open.", "items_reward": {}, "damage_taken": 0} + } + } + } + } + + start_point['interactables']['start_point_dumpster'] = { + "id": "dumpster", + "name": "๐Ÿ—‘๏ธ Dumpster", + "image_path": "images/interactables/dumpster.png", + "actions": { + "search_dumpster": { + "id": "search_dumpster", + "label": "๐Ÿ”Ž Dig Through Trash", + "stamina_cost": 2, + "outcomes": { + "success": {"text": "Despite the smell, you find some [Plastic Bottles] and [Cloth Scraps].", "items_reward": {"plastic_bottles": 3, "cloth_scraps": 2}, "damage_taken": 0}, + "failure": {"text": "Just rotting garbage. Nothing useful.", "items_reward": {}, "damage_taken": 0}, + "critical_failure": {"text": "You disturb a nest of rats! They bite you! (-8 HP)", "items_reward": {}, "damage_taken": 8} + } + } + } + } + +# Add missing interactables to subway_tunnels +subway_tunnels = find_location('subway_tunnels') +if subway_tunnels: + if 'interactables' not in subway_tunnels: + subway_tunnels['interactables'] = {} + + subway_tunnels['interactables']['subway_train_sedan'] = { + "id": "sedan", + "name": "๐Ÿš— Rusty Sedan", + "image_path": "images/interactables/sedan.png", + "actions": { + "search_glovebox": { + "id": "search_glovebox", + "label": "๐Ÿ”Ž Search Glovebox", + "stamina_cost": 1, + "outcomes": { + "success": {"text": "You find a half-eaten [Stale Chocolate Bar].", "items_reward": {"stale_chocolate_bar": 1}, "damage_taken": 0}, + "failure": {"text": "The glovebox is empty except for dust and old receipts.", "items_reward": {}, "damage_taken": 0} + } + }, + "pop_trunk": { + "id": "pop_trunk", + "label": "๐Ÿ”ง Pop the Trunk", + "stamina_cost": 3, + "outcomes": { + "success": {"text": "With a great heave, you pry the trunk open and find a [Tire Iron]!", "items_reward": {"tire_iron": 1}, "damage_taken": 0}, + "failure": {"text": "The trunk is rusted shut. You can't get it open.", "items_reward": {}, "damage_taken": 0} + } + } + } + } + + subway_tunnels['interactables']['subway_medkit'] = { + "id": "medkit", + "name": "๐Ÿฅ Medical Supply Cabinet", + "image_path": "images/interactables/medkit.png", + "actions": { + "search_medkit": { + "id": "search_medkit", + "label": "๐Ÿ”Ž Search Cabinet", + "stamina_cost": 2, + "outcomes": { + "success": {"text": "Jackpot! You find a [First Aid Kit] and some [Bandages]!", "items_reward": {"first_aid_kit": 1, "bandage": 2}, "damage_taken": 0}, + "failure": {"text": "The cabinet is empty. Someone got here first.", "items_reward": {}, "damage_taken": 0} + } + } + } + } + + subway_tunnels['interactables']['subway_rubble'] = { + "id": "rubble", + "name": "Pile of Rubble", + "image_path": "images/interactables/rubble.png", + "actions": { + "search": { + "id": "search", + "label": "๐Ÿ”Ž Search Rubble", + "stamina_cost": 2, + "outcomes": { + "success": {"text": "You dig through the debris and find some [Scrap Metal].", "items_reward": {"scrap_metal": 2}, "damage_taken": 0}, + "failure": {"text": "The pile seems to have been picked clean already.", "items_reward": {}, "damage_taken": 0}, + "critical_failure": {"text": "You cut your hand on a sharp piece of glass! (-5 HP)", "items_reward": {}, "damage_taken": 5} + } + } + } + } + +# Add missing interactables to office_interior +office_interior = find_location('office_interior') +if office_interior: + if 'interactables' not in office_interior: + office_interior['interactables'] = {} + + office_interior['interactables']['office_desk1'] = { + "id": "rubble", + "name": "Pile of Rubble", + "image_path": "images/interactables/rubble.png", + "actions": { + "search": { + "id": "search", + "label": "๐Ÿ”Ž Search Rubble", + "stamina_cost": 2, + "outcomes": { + "success": {"text": "You dig through the debris and find some [Scrap Metal].", "items_reward": {"scrap_metal": 2}, "damage_taken": 0}, + "failure": {"text": "The pile seems to have been picked clean already.", "items_reward": {}, "damage_taken": 0}, + "critical_failure": {"text": "You cut your hand on a sharp piece of glass! (-5 HP)", "items_reward": {}, "damage_taken": 5} + } + } + } + } + + office_interior['interactables']['office_desk2'] = { + "id": "rubble", + "name": "Pile of Rubble", + "image_path": "images/interactables/rubble.png", + "actions": { + "search": { + "id": "search", + "label": "๐Ÿ”Ž Search Rubble", + "stamina_cost": 2, + "outcomes": { + "success": {"text": "You dig through the debris and find some [Scrap Metal].", "items_reward": {"scrap_metal": 2}, "damage_taken": 0}, + "failure": {"text": "The pile seems to have been picked clean already.", "items_reward": {}, "damage_taken": 0}, + "critical_failure": {"text": "You cut your hand on a sharp piece of glass! (-5 HP)", "items_reward": {}, "damage_taken": 5} + } + } + } + } + + office_interior['interactables']['office_corner'] = { + "id": "house", + "name": "๐Ÿš๏ธ Abandoned House", + "image_path": "images/interactables/house.png", + "actions": { + "search_house": { + "id": "search_house", + "label": "๐Ÿ”Ž Search House", + "stamina_cost": 3, + "outcomes": { + "success": {"text": "You find some useful supplies: [Canned Beans], [Bottled Water], and [Cloth Scraps]!", "items_reward": {"canned_beans": 1, "bottled_water": 1, "cloth_scraps": 3}, "damage_taken": 0}, + "failure": {"text": "The house has already been thoroughly looted. Nothing remains.", "items_reward": {}, "damage_taken": 0}, + "critical_failure": {"text": "The floor collapses beneath you! (-10 HP)", "items_reward": {}, "damage_taken": 10} + } + } + } + } + +# Update danger_config from npcs_old.py +data['danger_config'] = { + "start_point": {"danger_level": 0, "encounter_rate": 0.0, "wandering_chance": 0.0}, + "gas_station": {"danger_level": 0, "encounter_rate": 0.0, "wandering_chance": 0.0}, + "residential": {"danger_level": 1, "encounter_rate": 0.10, "wandering_chance": 0.20}, + "park": {"danger_level": 1, "encounter_rate": 0.10, "wandering_chance": 0.20}, + "clinic": {"danger_level": 2, "encounter_rate": 0.20, "wandering_chance": 0.35}, + "plaza": {"danger_level": 2, "encounter_rate": 0.15, "wandering_chance": 0.30}, + "warehouse": {"danger_level": 2, "encounter_rate": 0.18, "wandering_chance": 0.32}, + "warehouse_interior": {"danger_level": 2, "encounter_rate": 0.22, "wandering_chance": 0.40}, + "overpass": {"danger_level": 3, "encounter_rate": 0.30, "wandering_chance": 0.45}, + "office_building": {"danger_level": 3, "encounter_rate": 0.25, "wandering_chance": 0.40}, + "office_interior": {"danger_level": 3, "encounter_rate": 0.35, "wandering_chance": 0.50}, + "subway": {"danger_level": 4, "encounter_rate": 0.35, "wandering_chance": 0.50}, + "subway_tunnels": {"danger_level": 4, "encounter_rate": 0.45, "wandering_chance": 0.65} +} + +# Update spawn_config from npcs_old.py +data['spawn_config'] = { + "start_point": [], + "gas_station": [], + "residential": [ + {"npc_id": "feral_dog", "weight": 60}, + {"npc_id": "mutant_rat", "weight": 40} + ], + "park": [ + {"npc_id": "feral_dog", "weight": 50}, + {"npc_id": "mutant_rat", "weight": 50} + ], + "clinic": [ + {"npc_id": "infected_human", "weight": 40}, + {"npc_id": "mutant_rat", "weight": 30}, + {"npc_id": "scavenger", "weight": 30} + ], + "plaza": [ + {"npc_id": "raider_scout", "weight": 40}, + {"npc_id": "scavenger", "weight": 35}, + {"npc_id": "feral_dog", "weight": 25} + ], + "warehouse": [ + {"npc_id": "raider_scout", "weight": 45}, + {"npc_id": "scavenger", "weight": 35}, + {"npc_id": "mutant_rat", "weight": 20} + ], + "warehouse_interior": [ + {"npc_id": "raider_scout", "weight": 50}, + {"npc_id": "scavenger", "weight": 30}, + {"npc_id": "mutant_rat", "weight": 20} + ], + "overpass": [ + {"npc_id": "raider_scout", "weight": 50}, + {"npc_id": "infected_human", "weight": 30}, + {"npc_id": "scavenger", "weight": 20} + ], + "office_building": [ + {"npc_id": "raider_scout", "weight": 45}, + {"npc_id": "infected_human", "weight": 35}, + {"npc_id": "scavenger", "weight": 20} + ], + "office_interior": [ + {"npc_id": "infected_human", "weight": 50}, + {"npc_id": "raider_scout", "weight": 30}, + {"npc_id": "scavenger", "weight": 20} + ], + "subway": [ + {"npc_id": "infected_human", "weight": 50}, + {"npc_id": "raider_scout", "weight": 30}, + {"npc_id": "mutant_rat", "weight": 20} + ], + "subway_tunnels": [ + {"npc_id": "infected_human", "weight": 60}, + {"npc_id": "raider_scout", "weight": 25}, + {"npc_id": "mutant_rat", "weight": 15} + ] +} + +# Save updated file +with open(locations_file, 'w') as f: + json.dump(data, f, indent=2) + +print("โœ… Updated locations.json with:") +print(" - Missing interactables for start_point, subway_tunnels, office_interior") +print(" - Complete danger_config from npcs_old.py") +print(" - Complete spawn_config from npcs_old.py")