diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2f6c588 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,56 @@ +# CLAUDE.md - Echoes of the Ash + +## Project Overview +- **Type**: Dark Fantasy RPG Adventure +- **Stack**: Monorepo with Python/FastAPI backend and React/Vite/TypeScript frontend. +- **Infrastructure**: Docker Compose (Postgres, Redis, Traefik). +- **Primary Target**: Web (PWA + API). Electron is secondary. + +## Commands + +### Development & Deployment +- **Start (Dev)**: `docker compose up -d` +- **Apply Changes**: `docker compose build && docker compose up -d` (Required for both code and env changes) +- **Restart API**: `docker compose restart echoes_of_the_ashes_api` +- **View Logs**: `docker compose logs -f [service_name]` (e.g., `echoes_of_the_ashes_api`, `echoes_of_the_ashes_pwa`) + +### Frontend (PWA) +- **Directory**: `pwa/` +- **Install**: `npm install` +- **Dev Server**: `npm run dev` +- **Build**: `npm run build` +- **Lint**: `npm run lint` + +### Backend (API) +- **Directory**: `api/` +- **Dependencies**: `requirements.txt` +- **Manual Run**: `uvicorn main:app --reload` (Local only, relies on env vars) + +### Testing +- **Directory**: `tests/` +- **Status**: Temporary/Manual scripts. +- **Run**: `python tests/test_api.py` (Run locally or inside container depending on env access) + +## Architecture & Code Structure + +### Backend (`api/`) +- **Entry**: `main.py` +- **Routers**: `routers/` (Modular endpoints: `game_routes.py`, `combat.py`, `auth.py`, etc.) +- **Core**: `core/` (Config, Security, WebSockets) +- **Services**: `services/` (Models, Helpers) +- **Pattern**: + - Use `routers` for new features. + - Register routers in `main.py` (auto-registration logic exists but explicit is clearer). + - Pydantic models in `services/models.py`. + +### Frontend (`pwa/`) +- **Entry**: `src/main.tsx` +- **Styling**: Standard CSS files per component (e.g., `components/Game.css`). No Tailwind/Modules. +- **State**: Zustand stores (`src/stores/`). +- **Translation**: i18next (`src/i18n/`). + +## Style Guidelines +- **Python**: PEP8 standard. No strict linter enforced. +- **TypeScript**: Standard ESLint rules from Vite template. +- **CSS**: Plain CSS. Keep component styles in dedicated files. +- **Docs**: update `QUICK_REFERENCE.md` if simplified logic or architecture changes. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8d0959 --- /dev/null +++ b/README.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/api/background_tasks.py b/api/background_tasks.py index e531cab..295be0d 100644 --- a/api/background_tasks.py +++ b/api/background_tasks.py @@ -135,18 +135,24 @@ async def spawn_manager_loop(manager=None): if manager: from datetime import datetime npc_def = NPCS.get(npc_id) - npc_name = npc_def.name if npc_def else npc_id.replace('_', ' ').title() + npc_name_obj = npc_def.name if npc_def else npc_id.replace('_', ' ').title() + # Handle localized name for the fallback message + if isinstance(npc_name_obj, dict): + npc_name_en = npc_name_obj.get('en', str(npc_name_obj)) + else: + npc_name_en = str(npc_name_obj) + await manager.send_to_location( location_id=location_id, message={ "type": "location_update", "data": { - "message": f"A {npc_name} appeared!", + "message": f"A {npc_name_en} appeared!", "action": "enemy_spawned", "npc_data": { "id": enemy_data['id'], "npc_id": npc_id, - "name": npc_name, + "name": npc_name_obj, "type": "enemy", "is_wandering": True, "image_path": npc_def.image_path if npc_def else None @@ -209,7 +215,8 @@ async def decay_dropped_items(manager=None): "type": "location_update", "data": { "message": f"{count} dropped item(s) decayed", - "action": "items_decayed" + "action": "items_decayed", + "count": count }, "timestamp": datetime.utcnow().isoformat() } @@ -472,7 +479,8 @@ async def decay_corpses(manager=None): "type": "location_update", "data": { "message": f"{total} {corpse_type} decayed", - "action": "corpses_decayed" + "action": "corpses_decayed", + "count": total }, "timestamp": datetime.utcnow().isoformat() } diff --git a/api/core/security.py b/api/core/security.py index 887858d..3a3f39b 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -70,7 +70,7 @@ async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(s detail="No character selected. Please select a character first." ) - player = await db.get_player_by_id(character_id) + player = await db.get_character_by_id(character_id) if player is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/api/database.py b/api/database.py index 20690e0..ca00efa 100644 --- a/api/database.py +++ b/api/database.py @@ -358,15 +358,7 @@ async def init_db(): await conn.execute(text(index_sql)) -# Player operations -async def get_player_by_id(player_id: int) -> Optional[Dict[str, Any]]: - """Get player by internal ID""" - async with DatabaseSession() as session: - result = await session.execute( - select(players).where(players.c.id == player_id) - ) - row = result.first() - return dict(row._mapping) if row else None + async def get_player_by_username(username: str) -> Optional[Dict[str, Any]]: @@ -421,13 +413,7 @@ async def create_player( return dict(row._mapping) if row else None -async def update_player(player_id: int, **kwargs) -> bool: - """Update player fields - OLD FUNCTION, use update_character instead""" - async with DatabaseSession() as session: - stmt = update(characters).where(characters.c.id == player_id).values(**kwargs) - await session.execute(stmt) - await session.commit() - return True + async def update_player_location(player_id: int, location_id: str) -> bool: diff --git a/api/game_logic.py b/api/game_logic.py index 5ab63b7..da5c177 100644 --- a/api/game_logic.py +++ b/api/game_logic.py @@ -6,14 +6,15 @@ import random import time from typing import Dict, Any, Tuple, Optional, List from . import database as db +from .services.helpers import get_locale_string, translate_travel_message, create_combat_message -async def move_player(player_id: int, direction: str, locations: Dict) -> Tuple[bool, str, Optional[str], int, int]: +async def move_player(player_id: int, direction: str, locations: Dict, locale: str = 'en') -> Tuple[bool, str, Optional[str], int, int]: """ Move player in a direction. Returns: (success, message, new_location_id, stamina_cost, distance_meters) """ - player = await db.get_player_by_id(player_id) + player = await db.get_character_by_id(player_id) if not player: return False, "Player not found", None, 0, 0 @@ -69,13 +70,15 @@ async def move_player(player_id: int, direction: str, locations: Dict) -> Tuple[ return False, "You're too exhausted to move. Wait for your stamina to regenerate.", None, 0, 0 # Update player location and stamina - await db.update_player( + await db.update_character( player_id, location_id=new_location_id, stamina=max(0, player['stamina'] - stamina_cost) ) - return True, f"You travel {direction} to {new_location.name}.", new_location_id, stamina_cost, distance + translated_location = get_locale_string(new_location.name, locale) + travel_message = translate_travel_message(direction, translated_location, locale) + return True, travel_message, new_location_id, stamina_cost, distance async def inspect_area(player_id: int, location, interactables_data: Dict) -> str: @@ -216,7 +219,7 @@ async def interact_with_object( if not item: continue - item_name = item.name if item else item_id + item_name = get_locale_string(item.name) if item else item_id emoji = item.emoji if item and hasattr(item, 'emoji') else '' # Check if item has durability (unique item) @@ -237,7 +240,7 @@ async def interact_with_object( max_durability=item.durability, tier=getattr(item, 'tier', None) ) - items_found.append(f"{emoji} {item_name}") + items_found.append(f"{emoji} {get_locale_string(item_name)}") current_weight += item.weight current_volume += item.volume else: @@ -252,7 +255,7 @@ async def interact_with_object( unique_stats=base_stats ) await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id) - items_dropped.append(f"{emoji} {item_name}") + items_dropped.append(f"{emoji} {get_locale_string(item_name)}") else: # Stackable items - process as before item_weight = item.weight * quantity @@ -262,13 +265,13 @@ async def interact_with_object( current_volume + item_volume <= max_volume): # Add to inventory await db.add_item_to_inventory(player_id, item_id, quantity) - items_found.append(f"{emoji} {item_name} x{quantity}") + items_found.append(f"{emoji} {get_locale_string(item_name)} x{quantity}") current_weight += item_weight current_volume += item_volume else: # Drop to ground await db.drop_item_to_world(item_id, quantity, player['location_id']) - items_dropped.append(f"{emoji} {item_name} x{quantity}") + items_dropped.append(f"{emoji} {get_locale_string(item_name)} x{quantity}") # Apply damage if damage_taken > 0: @@ -283,7 +286,7 @@ async def interact_with_object( await db.set_interactable_cooldown(interactable_id, action_id, 60) # Build message - final_message = outcome.text + final_message = get_locale_string(outcome.text) if items_dropped: final_message += f"\n⚠️ Inventory full! Dropped to ground: {', '.join(items_dropped)}" @@ -565,7 +568,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) - heal_amount = int(combat['npc_max_hp'] * 0.05) new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount) await db.update_combat(player_id, {'npc_hp': new_npc_hp}) - message = f"{npc_def.name} defends and recovers {heal_amount} HP!" + message = f"{get_locale_string(npc_def.name)} defends and recovers {heal_amount} HP!" elif intent_type == 'special': # Strong attack (1.5x damage) @@ -574,7 +577,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) - actual_damage = max(1, npc_damage - armor_absorbed) new_player_hp = max(0, player['hp'] - actual_damage) - message = f"{npc_def.name} uses a SPECIAL ATTACK for {npc_damage} damage!" + message = f"{get_locale_string(npc_def.name)} uses a SPECIAL ATTACK for {npc_damage} damage!" if armor_absorbed > 0: message += f" (Armor absorbed {armor_absorbed})" @@ -589,7 +592,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) - # Enrage bonus if NPC is below 30% HP if combat['npc_hp'] / combat['npc_max_hp'] < 0.3: npc_damage = int(npc_damage * 1.5) - message = f"{npc_def.name} is ENRAGED! " + message = f"{get_locale_string(npc_def.name)} is ENRAGED! " else: message = "" @@ -597,7 +600,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) - actual_damage = max(1, npc_damage - armor_absorbed) new_player_hp = max(0, player['hp'] - actual_damage) - message += f"{npc_def.name} attacks for {npc_damage} damage!" + message += create_combat_message("enemy_attack", npc_name=npc_def.name, damage=npc_damage, armor_absorbed=armor_absorbed) if armor_absorbed > 0: message += f" (Armor absorbed {armor_absorbed})" diff --git a/api/routers/combat.py b/api/routers/combat.py index e7a151e..71502ec 100644 --- a/api/routers/combat.py +++ b/api/routers/combat.py @@ -12,7 +12,7 @@ import logging from ..core.security import get_current_user, security, verify_internal_key from ..services.models import * -from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity +from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string, create_combat_message from .. import database as db from ..items import ItemsManager from .. import game_logic @@ -147,7 +147,7 @@ async def initiate_combat( await manager.send_personal_message(current_user['id'], { "type": "combat_started", "data": { - "message": f"Combat started with {npc_def.name}!", + "message": create_combat_message("combat_start", npc_name=npc_def.name), "combat": { "npc_id": enemy.npc_id, "npc_name": npc_def.name, @@ -167,7 +167,7 @@ async def initiate_combat( message={ "type": "location_update", "data": { - "message": f"{player['name']} entered combat with {npc_def.name}", + "message": f"{player['name']} entered combat with {get_locale_string(npc_def.name)}", "action": "combat_started", "player_id": player['id'] }, @@ -178,7 +178,7 @@ async def initiate_combat( return { "success": True, - "message": f"Combat started with {npc_def.name}!", + "message": create_combat_message("combat_start", npc_name=npc_def.name), "combat": { "npc_id": enemy.npc_id, "npc_name": npc_def.name, @@ -304,7 +304,7 @@ async def combat_action( if new_npc_hp <= 0: # NPC defeated - result_message += f"{npc_def.name} has been defeated!" + result_message += create_combat_message("victory", npc_name=npc_def.name) combat_over = True player_won = True @@ -435,7 +435,7 @@ async def combat_action( # 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!" + result_message = create_combat_message("flee_fail", npc_name=npc_def.name, damage=npc_damage) if new_player_hp <= 0: result_message += "\nYou have been defeated!" diff --git a/api/routers/crafting.py b/api/routers/crafting.py index 6a980c9..03f98d5 100644 --- a/api/routers/crafting.py +++ b/api/routers/crafting.py @@ -12,7 +12,7 @@ import logging from ..core.security import get_current_user, security, verify_internal_key from ..services.models import * -from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost +from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost, get_locale_string from .. import database as db from ..items import ItemsManager from .. import game_logic @@ -156,7 +156,7 @@ async def get_craftable_items(current_user: dict = Depends(get_current_user)): }) # Sort: craftable items first, then by tier, then by name - craftable_items.sort(key=lambda x: (not x['can_craft'], x['tier'], x['name'])) + craftable_items.sort(key=lambda x: (not x['can_craft'], x['tier'], get_locale_string(x['name']))) return {'craftable_items': craftable_items} diff --git a/api/routers/game_routes.py b/api/routers/game_routes.py index 8b66f49..6ec6922 100644 --- a/api/routers/game_routes.py +++ b/api/routers/game_routes.py @@ -2,7 +2,7 @@ Game Routes router. Auto-generated from main.py migration. """ -from fastapi import APIRouter, HTTPException, Depends, status +from fastapi import APIRouter, HTTPException, Depends, status, Request from fastapi.security import HTTPAuthorizationCredentials from typing import Optional, Dict, Any from datetime import datetime @@ -391,8 +391,11 @@ async def spend_stat_point( @router.get("/api/game/location") -async def get_current_location(current_user: dict = Depends(get_current_user)): +async def get_current_location(request: Request, current_user: dict = Depends(get_current_user)): """Get current location information""" + # Extract locale from Accept-Language header + locale = request.headers.get('Accept-Language', 'en') + location_id = current_user['location_id'] location = LOCATIONS.get(location_id) @@ -682,7 +685,7 @@ async def get_current_location(current_user: dict = Depends(get_current_user)): corpses_data.append({ "id": f"npc_{corpse['id']}", "type": "npc", - "name": f"{npc_def.name if npc_def else corpse['npc_id']} Corpse", + "name": f"{get_locale_string(npc_def.name, locale) if npc_def else corpse['npc_id']} Corpse", "emoji": "💀", "loot_count": len(loot), "timestamp": corpse['death_timestamp'] @@ -719,6 +722,7 @@ async def get_current_location(current_user: dict = Depends(get_current_user)): @router.post("/api/game/move") async def move( move_req: MoveRequest, + request: Request, current_user: dict = Depends(get_current_user) ): """Move player in a direction""" @@ -756,10 +760,14 @@ async def move( detail=f"You must wait {int(cooldown_remaining)} seconds before moving again." ) + # Extract locale from Accept-Language header + locale = request.headers.get('Accept-Language', 'en') + success, message, new_location_id, stamina_cost, distance = await game_logic.move_player( current_user['id'], move_req.direction, - LOCATIONS + LOCATIONS, + locale ) if not success: @@ -951,9 +959,13 @@ async def inspect(current_user: dict = Depends(get_current_user)): @router.post("/api/game/interact") async def interact( interact_req: InteractRequest, + request: Request, current_user: dict = Depends(get_current_user) ): - """Interact with an object""" + """Interact with an object in the game world""" + # Extract locale from Accept-Language header + locale = request.headers.get('Accept-Language', 'en') + # Check if player is in combat combat = await db.get_active_combat(current_user['id']) if combat: @@ -1026,7 +1038,7 @@ async def interact( "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}" + "message": f"{current_user['name']} used {get_locale_string(action_display, locale)} on {get_locale_string(interactable_name, locale)}" }, "timestamp": datetime.utcnow().isoformat() } @@ -1035,6 +1047,8 @@ async def interact( return result + + @router.post("/api/game/use_item") async def use_item( use_req: UseItemRequest, @@ -1159,15 +1173,19 @@ async def use_item( @router.post("/api/game/pickup") async def pickup( pickup_req: PickupItemRequest, + request: Request, current_user: dict = Depends(get_current_user) ): """Pick up an item from the ground""" + # Extract locale from Accept-Language header + locale = request.headers.get('Accept-Language', 'en') + # 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'] + item_name = get_locale_string(item_def.name, locale) if item_def else dropped_item['item_id'] else: item_name = "item" @@ -1392,5 +1410,5 @@ async def drop_item( return { "success": True, - "message": f"Dropped {item_def.emoji} {item_def.name} x{quantity}" + "message": f"Dropped {item_def.emoji} {get_locale_string(item_def.name)} x{quantity}" } \ No newline at end of file diff --git a/api/routers/loot.py b/api/routers/loot.py index 59df101..b1db641 100644 --- a/api/routers/loot.py +++ b/api/routers/loot.py @@ -12,7 +12,7 @@ import logging from ..core.security import get_current_user, security, verify_internal_key from ..services.models import * -from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity +from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string from .. import database as db from ..items import ItemsManager from .. import game_logic @@ -310,13 +310,13 @@ async def loot_corpse( 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'] + item_name = get_locale_string(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'] + item_name = get_locale_string(item_def.name) if item_def else item['item_id'] dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}") message = "" @@ -438,13 +438,13 @@ async def loot_corpse( 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'] + item_name = get_locale_string(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'] + item_name = get_locale_string(item_def.name) if item_def else item['item_id'] dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}") message = "" diff --git a/api/services/helpers.py b/api/services/helpers.py index a359579..77e8de1 100644 --- a/api/services/helpers.py +++ b/api/services/helpers.py @@ -15,6 +15,45 @@ def get_locale_string(value: Union[str, Dict[str, str]], lang: str = 'en') -> st return str(value) +# Translation maps for backend messages +DIRECTION_TRANSLATIONS = { + 'north': {'en': 'north', 'es': 'norte'}, + 'south': {'en': 'south', 'es': 'sur'}, + 'east': {'en': 'east', 'es': 'este'}, + 'west': {'en': 'west', 'es': 'oeste'}, + 'northeast': {'en': 'northeast', 'es': 'noreste'}, + 'northwest': {'en': 'northwest', 'es': 'noroeste'}, + 'southeast': {'en': 'southeast', 'es': 'sureste'}, + 'southwest': {'en': 'southwest', 'es': 'suroeste'}, +} + +def translate_travel_message(direction: str, location_name: str, lang: str = 'en') -> str: + """Translate a travel message to the user's language.""" + dir_translated = DIRECTION_TRANSLATIONS.get(direction, {}).get(lang, direction) + + if lang == 'es': + return f"Viajas al {dir_translated} hacia {location_name}." + else: + return f"You travel {dir_translated} to {location_name}." + + +import json + +def create_combat_message(message_type: str, **data) -> str: + """Create a structured combat message with type and data. + + Args: + message_type: Type of combat message (combat_start, player_attack, etc.) + **data: Dynamic data for the message (damage, npc_name, etc.) + + Returns: + Dictionary with 'type' and 'data' fields + """ + return json.dumps({ + "type": message_type, + "data": data + }) + def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float: """ Calculate distance between two points using Euclidean distance. diff --git a/gamedata/npcs.json b/gamedata/npcs.json index 5417f20..d46fab2 100644 --- a/gamedata/npcs.json +++ b/gamedata/npcs.json @@ -54,11 +54,11 @@ "npc_id": "raider_scout", "name": { "en": "Raider Scout", - "es": "" + "es": "Explorador" }, "description": { "en": "A lone raider wearing makeshift armor. They eye you with hostile intent.", - "es": "" + "es": "Un explorador solitario con ropa improvisada. Te mira con intención hostil." }, "emoji": "🏴☠️", "hp_min": 30, @@ -116,11 +116,11 @@ "npc_id": "mutant_rat", "name": { "en": "Mutant Rat", - "es": "" + "es": "Rata mutante" }, "description": { "en": "A grotesquely large rat, its fur patchy and eyes glowing with unnatural light.", - "es": "" + "es": "Una rata grotescamente grande, su pelaje es desgarrado y sus ojos brillan con luz unnatural." }, "emoji": "🐀", "hp_min": 10, @@ -160,11 +160,11 @@ "npc_id": "infected_human", "name": { "en": "Infected Human", - "es": "" + "es": "Humano infectado" }, "description": { "en": "Once human, now something else. Their movements are jerky and their skin shows signs of advanced infection.", - "es": "" + "es": "Una vez humano, ahora algo más. Sus movimientos son torpes y su piel muestra signos de infección avanzada." }, "emoji": "🧟", "hp_min": 35, diff --git a/nginx.conf b/nginx.conf index d8ca2d8..972ee32 100644 --- a/nginx.conf +++ b/nginx.conf @@ -34,14 +34,18 @@ server { add_header Expires "0"; } - # Manifest should be cached for a short time + # Manifest should never be cached location /manifest.webmanifest { - add_header Cache-Control "max-age=3600"; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; } # SPA fallback - all other requests go to index.html location / { try_files $uri $uri/ /index.html; - add_header Cache-Control "no-cache"; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; } } diff --git a/pwa/index.html b/pwa/index.html index 19cd249..3a9dd3e 100644 --- a/pwa/index.html +++ b/pwa/index.html @@ -1,17 +1,24 @@ -
- - - - - - - -