Push
This commit is contained in:
729
README.md
729
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
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
### Core Gameplay
|
## 🎮 What is Echoes of the Ash?
|
||||||
|
|
||||||
#### 🗺️ Exploration & Movement
|
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.
|
||||||
- **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
|
## 🌟 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 |
|
||||||
|
|
||||||
```
|
### Exploration & Interaction
|
||||||
gamedata/
|
|
||||||
├── npcs.json # Enemy NPCs and combat encounters
|
| Feature | Status | Description |
|
||||||
├── items.json # All items, weapons, consumables, and resources
|
|---------|--------|-------------|
|
||||||
├── locations.json # World map locations and interactables
|
| **World Map** | ✅ Complete | Graph-based location system with connections |
|
||||||
└── interactables.json # Interactable object templates
|
| **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
|
### Gameplay Loop
|
||||||
```json
|
1. **Explore** - Move between connected locations to discover new areas
|
||||||
{
|
2. **Scavenge** - Search containers, corpses, and interactables for supplies
|
||||||
"npcs": { ... }, // NPC definitions
|
3. **Fight** - Engage hostile NPCs in turn-based combat
|
||||||
"danger_levels": { ... }, // Danger settings per location
|
4. **Craft** - Use workbenches to create, repair, or salvage items
|
||||||
"spawn_tables": { ... } // Enemy spawn weights per location
|
5. **Level Up** - Gain XP from combat and allocate stat points
|
||||||
}
|
6. **Survive** - Manage HP, stamina, and inventory weight
|
||||||
```
|
|
||||||
|
|
||||||
### NPC Definition
|
### Combat
|
||||||
```json
|
- **Attack** enemies with equipped weapons
|
||||||
"npc_id": {
|
- **Use Items** during battle (healing, buffs)
|
||||||
"npc_id": "unique_npc_identifier",
|
- **Flee** when outmatched (success based on Agility)
|
||||||
"name": {
|
- **PvP** - Challenge other players in combat
|
||||||
"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
|
### Character Progression
|
||||||
```json
|
- **4 Core Stats**: Strength, Agility, Endurance, Intellect
|
||||||
"location_id": {
|
- **Equipment**: Weapons, armor, backpacks
|
||||||
"danger_level": 2, // 0-4 scale
|
- **Stat Points**: Earn 1 per level to customize your build
|
||||||
"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
|
## 🛠️ 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)
|
### Backend (API)
|
||||||
- `resource` - Raw materials for crafting
|
- **Framework**: FastAPI (Python)
|
||||||
- `consumable` - Food, medicine, usable items
|
- **Database**: SQLite (development) / PostgreSQL (production)
|
||||||
- `weapon` - Melee and ranged weapons
|
- **Cache**: Redis for real-time state
|
||||||
- `backpack` - Inventory capacity upgrades
|
- **Auth**: JWT tokens
|
||||||
- `armor` - Protective equipment
|
|
||||||
- `tool` - Utility items (flashlight, etc.)
|
|
||||||
- `quest` - Story/quest items
|
|
||||||
|
|
||||||
### Basic Item Structure
|
### Desktop (Electron)
|
||||||
```json
|
- **Framework**: Electron 28
|
||||||
"item_id": {
|
- **Steam SDK**: steamworks.js integration
|
||||||
"name": {
|
- **Builds**: Windows (NSIS, Portable), Linux (AppImage, DEB), macOS
|
||||||
"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
|
## 📊 Asset Summary
|
||||||
|
|
||||||
Defines the game world, all locations, coordinates, and interactable objects.
|
| Category | Count | Size |
|
||||||
|
|----------|-------|------|
|
||||||
### Location Definition
|
| Location Images | 14 | - |
|
||||||
```json
|
| Item Images | 40 | - |
|
||||||
{
|
| NPC Images | 5 | - |
|
||||||
"id": "location_id",
|
| Interactable Images | 8 | - |
|
||||||
"name": {
|
| Icon Sets | 1 | - |
|
||||||
"en": "🏚️ Location Name",
|
| **Total Images** | **134 files** | **~79 MB** |
|
||||||
"es": "Spanish Name"
|
| Sound Effects | 0 | 0 |
|
||||||
},
|
| Music | 0 | 0 |
|
||||||
"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
|
## 🗺️ 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
|
### Planned Features
|
||||||
```json
|
- [ ] Crafting recipes expansion
|
||||||
"template_id": {
|
- [ ] Faction/reputation system
|
||||||
"id": "template_id",
|
- [ ] Player trading
|
||||||
"name": {
|
- [ ] Housing/storage
|
||||||
"en": "🗑️ Object Name",
|
- [ ] Skill tree system
|
||||||
"es": "Spanish Name"
|
- [ ] Status effects (poison, bleeding, etc.)
|
||||||
},
|
- [ ] Weather/day-night cycle
|
||||||
"description": {
|
- [ ] Achievements
|
||||||
"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
|
## 🚀 Running the Game
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
|
### Web/PWA (Docker)
|
||||||
```bash
|
```bash
|
||||||
# Start the game
|
docker compose up echoes_of_the_ashes_pwa echoes_of_the_ashes_api
|
||||||
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)
|
### 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
|
||||||
|
|||||||
@@ -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
|
|
||||||
499
api/main.old.py
499
api/main.old.py
@@ -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)
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
331
docs/archive/COMPLETE_MIGRATION_SUCCESS.md
Normal file
331
docs/archive/COMPLETE_MIGRATION_SUCCESS.md
Normal file
@@ -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*
|
||||||
146
docs/archive/PLAYERS_TAB_SCHEMA_FIX.md
Normal file
146
docs/archive/PLAYERS_TAB_SCHEMA_FIX.md
Normal file
@@ -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/<int:telegram_id>` → `/api/editor/player/<int:character_id>`
|
||||||
|
- `/api/editor/account/<int:telegram_id>` → `/api/editor/account/<int:account_id>`
|
||||||
|
- ✅ 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/<character_id>` - Get character details + inventory
|
||||||
|
3. `POST /api/editor/player/<character_id>` - Update character stats
|
||||||
|
4. `POST /api/editor/player/<character_id>/inventory` - Update inventory
|
||||||
|
5. `POST /api/editor/player/<character_id>/equipment` - Update equipment
|
||||||
|
6. `DELETE /api/editor/account/<account_id>/delete` - Delete account
|
||||||
|
7. `POST /api/editor/player/<character_id>/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)
|
||||||
627
docs/archive/README_old.md
Normal file
627
docs/archive/README_old.md
Normal file
@@ -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.
|
||||||
180
docs/archive/REDIS_MONITORING.md
Normal file
180
docs/archive/REDIS_MONITORING.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
335
docs/archive/REFACTORING_COMPLETE.md
Normal file
335
docs/archive/REFACTORING_COMPLETE.md
Normal file
@@ -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
|
||||||
160
docs/archive/REFACTORING_PLAN.md
Normal file
160
docs/archive/REFACTORING_PLAN.md
Normal file
@@ -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?
|
||||||
181
docs/archive/WEBSOCKET_HANDLER_FIX.md
Normal file
181
docs/archive/WEBSOCKET_HANDLER_FIX.md
Normal file
@@ -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<void>
|
||||||
|
|
||||||
|
// Refresh only combat data (efficient)
|
||||||
|
refreshCombat: () => Promise<void>
|
||||||
|
|
||||||
|
// 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<string, number>) => 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
|
||||||
51
docs/archive/refactor_summary.md
Normal file
51
docs/archive/refactor_summary.md
Normal file
@@ -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?
|
||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import { useAuth } from '../hooks/useAuth'
|
import { useAuth } from '../hooks/useAuth'
|
||||||
import { Character } from '../services/api'
|
import { Character } from '../services/api'
|
||||||
import './CharacterSelection.css'
|
import './CharacterSelection.css'
|
||||||
|
import { GameTooltip } from './common/GameTooltip'
|
||||||
|
|
||||||
function CharacterSelection() {
|
function CharacterSelection() {
|
||||||
const { characters, account, selectCharacter, deleteCharacter, logout } = useAuth()
|
const { characters, account, selectCharacter, deleteCharacter, logout } = useAuth()
|
||||||
@@ -135,11 +136,21 @@ function CharacterCard({
|
|||||||
<span className="stat">Level {character.level}</span>
|
<span className="stat">Level {character.level}</span>
|
||||||
<span className="stat">HP: {character.hp}/{character.max_hp}</span>
|
<span className="stat">HP: {character.hp}/{character.max_hp}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="character-attributes">
|
<div className="character-attributes">
|
||||||
<span title="Strength">💪 {character.strength}</span>
|
<GameTooltip content="Strength">
|
||||||
<span title="Agility">⚡ {character.agility}</span>
|
<span className="stat-icon">💪 {character.strength}</span>
|
||||||
<span title="Endurance">🛡️ {character.endurance}</span>
|
</GameTooltip>
|
||||||
<span title="Intellect">🧠 {character.intellect}</span>
|
<GameTooltip content="Agility">
|
||||||
|
<span>⚡ {character.agility}</span>
|
||||||
|
</GameTooltip>
|
||||||
|
<GameTooltip content="Endurance">
|
||||||
|
<span>🛡️ {character.endurance}</span>
|
||||||
|
</GameTooltip>
|
||||||
|
<GameTooltip content="Intellect">
|
||||||
|
<span>🧠 {character.intellect}</span>
|
||||||
|
</GameTooltip>
|
||||||
</div>
|
</div>
|
||||||
<p className="character-meta">
|
<p className="character-meta">
|
||||||
Last played: {formatDate(character.last_played_at)}
|
Last played: {formatDate(character.last_played_at)}
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ html {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
background: var(--game-bg-app);
|
||||||
color: #eee;
|
color: var(--game-text-primary);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
font-family: var(--game-font-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Death Overlay */
|
/* Death Overlay */
|
||||||
@@ -95,23 +96,23 @@ html {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
background-color: rgba(20, 20, 25, 0.95);
|
background-color: var(--game-bg-panel);
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
border-bottom: 1px solid var(--game-border-color);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
box-shadow: var(--game-shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left h1 {
|
.header-left h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
background: linear-gradient(45deg, #ff6b6b, #ffa502);
|
color: var(--game-text-highlight);
|
||||||
background-clip: text;
|
letter-spacing: 1px;
|
||||||
-webkit-background-clip: text;
|
text-transform: uppercase;
|
||||||
-webkit-text-fill-color: transparent;
|
text-shadow: 0 0 10px rgba(234, 113, 66, 0.3);
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Player Count Badge */
|
/* Player Count Badge */
|
||||||
@@ -172,28 +173,28 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: transparent;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid transparent;
|
||||||
border-radius: 8px;
|
padding: 0.5rem 1rem;
|
||||||
padding: 0.6rem 1.2rem;
|
color: var(--game-text-secondary);
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: all 0.3s;
|
transition: all 0.2s ease;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
color: var(--game-text-primary);
|
||||||
color: #fff;
|
text-shadow: 0 0 8px rgba(255, 255, 255, 0.3);
|
||||||
border-color: rgba(107, 185, 240, 0.5);
|
transform: translateY(-1px);
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link.active {
|
.nav-link.active {
|
||||||
background: rgba(107, 185, 240, 0.2);
|
color: var(--game-color-primary);
|
||||||
border-color: #6bb9f0;
|
border-bottom: 2px solid var(--game-color-primary);
|
||||||
color: #6bb9f0;
|
background: linear-gradient(to top, rgba(234, 113, 66, 0.1), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
@@ -231,9 +232,10 @@ html {
|
|||||||
.game-stats-bar {
|
.game-stats-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
padding: 1rem 2rem;
|
padding: 0.8rem 2rem;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: var(--game-bg-dark);
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
border-bottom: 1px solid var(--game-border-color);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-bar-container {
|
.stat-bar-container {
|
||||||
@@ -263,28 +265,27 @@ html {
|
|||||||
.progress-bar {
|
.progress-bar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
border-radius: 10px;
|
border-radius: var(--game-radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--game-border-color);
|
||||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3);
|
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill {
|
.progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
transition: width 0.5s ease;
|
transition: width 0.5s ease;
|
||||||
border-radius: 10px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill.health {
|
.progress-fill.health {
|
||||||
background: linear-gradient(90deg, #dc3545 0%, #ff6b6b 100%);
|
background: var(--game-gradient-health);
|
||||||
box-shadow: 0 0 10px rgba(255, 107, 107, 0.5);
|
box-shadow: 0 0 10px rgba(196, 92, 92, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill.stamina {
|
.progress-fill.stamina {
|
||||||
background: linear-gradient(90deg, #ffc107 0%, #ffeb3b 100%);
|
background: var(--game-gradient-stamina);
|
||||||
box-shadow: 0 0 10px rgba(255, 235, 59, 0.5);
|
box-shadow: 0 0 10px rgba(226, 180, 103, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Legacy stat styles for backwards compatibility */
|
/* Legacy stat styles for backwards compatibility */
|
||||||
@@ -302,9 +303,10 @@ html {
|
|||||||
|
|
||||||
.game-tabs {
|
.game-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: var(--game-bg-panel);
|
||||||
border-bottom: 2px solid rgba(255, 107, 107, 0.3);
|
border-bottom: 2px solid var(--game-border-color);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
@@ -312,22 +314,25 @@ html {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #aaa;
|
color: var(--game-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s;
|
transition: all 0.2s;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab:hover {
|
.tab:hover {
|
||||||
background: rgba(255, 107, 107, 0.1);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
color: #fff;
|
color: var(--game-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab.active {
|
.tab.active {
|
||||||
background: rgba(255, 107, 107, 0.2);
|
background: linear-gradient(to top, rgba(234, 113, 66, 0.1), transparent);
|
||||||
color: #ff6b6b;
|
color: var(--game-color-primary);
|
||||||
border-bottom: 3px solid #ff6b6b;
|
border-bottom: 3px solid var(--game-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-main {
|
.game-main {
|
||||||
@@ -385,70 +390,76 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.location-info {
|
.location-info {
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: var(--game-bg-panel);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-radius: 10px;
|
border-radius: var(--game-radius-md);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
border: 1px solid var(--game-border-color);
|
||||||
|
box-shadow: var(--game-shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-info h2 {
|
.location-info h2 {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
color: #ff6b6b;
|
color: var(--game-text-highlight);
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-badge {
|
.danger-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.5rem 1.2rem;
|
padding: 0.3rem 0.8rem;
|
||||||
border-radius: 24px;
|
border-radius: var(--game-radius-sm);
|
||||||
font-size: 1rem;
|
font-size: 0.9rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-safe {
|
.danger-safe {
|
||||||
background: rgba(76, 175, 80, 0.2);
|
background: rgba(139, 179, 128, 0.15);
|
||||||
color: #4caf50;
|
color: var(--game-danger-safe);
|
||||||
border: 2px solid #4caf50;
|
border-color: var(--game-danger-safe);
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-1 {
|
.danger-1 {
|
||||||
background: rgba(255, 193, 7, 0.2);
|
background: rgba(226, 180, 103, 0.15);
|
||||||
color: #ffc107;
|
color: var(--game-danger-low);
|
||||||
border: 2px solid #ffc107;
|
border-color: var(--game-danger-low);
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-2 {
|
.danger-2 {
|
||||||
background: rgba(255, 152, 0, 0.2);
|
background: rgba(234, 113, 66, 0.15);
|
||||||
color: #ff9800;
|
color: var(--game-danger-med);
|
||||||
border: 2px solid #ff9800;
|
border-color: var(--game-danger-med);
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-3 {
|
.danger-3 {
|
||||||
background: rgba(255, 87, 34, 0.2);
|
background: rgba(196, 92, 92, 0.15);
|
||||||
color: #ff5722;
|
color: var(--game-danger-high);
|
||||||
border: 2px solid #ff5722;
|
border-color: var(--game-danger-high);
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-4 {
|
.danger-4 {
|
||||||
background: rgba(244, 67, 54, 0.2);
|
background: rgba(196, 92, 92, 0.25);
|
||||||
color: #f44336;
|
color: var(--game-danger-high);
|
||||||
border: 2px solid #f44336;
|
border-color: var(--game-danger-high);
|
||||||
|
box-shadow: 0 0 8px rgba(196, 92, 92, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-5 {
|
.danger-5 {
|
||||||
background: rgba(156, 39, 176, 0.2);
|
background: rgba(163, 62, 62, 0.25);
|
||||||
color: #9c27b0;
|
color: var(--game-danger-extreme);
|
||||||
border: 2px solid #9c27b0;
|
border-color: var(--game-danger-extreme);
|
||||||
|
box-shadow: 0 0 12px rgba(163, 62, 62, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-tags {
|
.location-tags {
|
||||||
@@ -721,16 +732,19 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.movement-controls {
|
.movement-controls {
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: var(--game-bg-panel);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-radius: 10px;
|
border-radius: var(--game-radius-md);
|
||||||
border: 2px solid rgba(255, 107, 107, 0.3);
|
border: 1px solid var(--game-border-color);
|
||||||
|
box-shadow: var(--game-shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-controls h3 {
|
.movement-controls h3 {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
color: #ff6b6b;
|
color: var(--game-text-highlight);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 8-Direction Compass Grid */
|
/* 8-Direction Compass Grid */
|
||||||
@@ -746,18 +760,18 @@ html {
|
|||||||
.compass-btn {
|
.compass-btn {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
border: 2px solid rgba(255, 107, 107, 0.3);
|
border: 1px solid var(--game-border-color);
|
||||||
background: linear-gradient(135deg, rgba(255, 107, 107, 0.2) 0%, rgba(255, 107, 107, 0.3) 100%);
|
background: linear-gradient(135deg, rgba(80, 80, 90, 0.3) 0%, rgba(40, 40, 50, 0.5) 100%);
|
||||||
color: #fff;
|
color: var(--game-text-primary);
|
||||||
border-radius: 12px;
|
border-radius: var(--game-radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.2s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: var(--game-shadow-card);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -769,7 +783,7 @@ html {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, transparent 100%);
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, transparent 100%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,7 +797,7 @@ html {
|
|||||||
.compass-cost {
|
.compass-cost {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #ffc107;
|
color: var(--game-color-warning);
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||||
display: block;
|
display: block;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -791,10 +805,10 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.compass-btn:hover:not(:disabled) {
|
.compass-btn:hover:not(:disabled) {
|
||||||
background: linear-gradient(135deg, rgba(255, 107, 107, 0.4) 0%, rgba(255, 107, 107, 0.5) 100%);
|
background: linear-gradient(135deg, rgba(234, 113, 66, 0.2) 0%, rgba(234, 113, 66, 0.3) 100%);
|
||||||
transform: translateY(-2px) scale(1.05);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.4);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
border-color: rgba(255, 107, 107, 0.6);
|
border-color: var(--game-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.compass-btn:active:not(:disabled) {
|
.compass-btn:active:not(:disabled) {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import LanguageSelector from './LanguageSelector'
|
import LanguageSelector from './LanguageSelector'
|
||||||
import './Game.css'
|
import './Game.css'
|
||||||
|
|
||||||
|
import { GameTooltip } from './common/GameTooltip'
|
||||||
|
|
||||||
interface GameHeaderProps {
|
interface GameHeaderProps {
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
@@ -77,10 +79,12 @@ export default function GameHeader({ className = '' }: GameHeaderProps) {
|
|||||||
</nav>
|
</nav>
|
||||||
<div className="user-info">
|
<div className="user-info">
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
<div className="player-count-badge" title={t('game.onlineCount', { count: playerCount })}>
|
<GameTooltip content={t('game.onlineCount', { count: playerCount })}>
|
||||||
|
<div className="player-count-badge">
|
||||||
<span className="status-dot"></span>
|
<span className="status-dot"></span>
|
||||||
<span className="count-text">{t('game.onlineCount', { count: playerCount })}</span>
|
<span className="count-text">{t('game.onlineCount', { count: playerCount })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</GameTooltip>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/profile/${currentCharacter?.id}`)}
|
onClick={() => navigate(`/profile/${currentCharacter?.id}`)}
|
||||||
className={`username-link ${isOnOwnProfile ? 'active' : ''}`}
|
className={`username-link ${isOnOwnProfile ? 'active' : ''}`}
|
||||||
|
|||||||
@@ -8,10 +8,11 @@
|
|||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 4rem auto;
|
margin: 4rem auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: var(--game-bg-panel);
|
||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
border-radius: 12px;
|
border-radius: var(--game-radius-md);
|
||||||
border: 2px solid rgba(107, 185, 240, 0.3);
|
border: 2px solid var(--game-border-color);
|
||||||
|
box-shadow: var(--game-shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-main .profile-error button {
|
.game-main .profile-error button {
|
||||||
@@ -36,14 +37,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.profile-info-card {
|
.profile-info-card {
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: var(--game-bg-panel);
|
||||||
border: 2px solid rgba(107, 185, 240, 0.3);
|
border: 2px solid var(--game-border-color);
|
||||||
border-radius: 12px;
|
border-radius: var(--game-radius-md);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 2rem;
|
top: 2rem;
|
||||||
|
box-shadow: var(--game-shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-avatar {
|
.profile-avatar {
|
||||||
@@ -65,12 +67,12 @@
|
|||||||
.profile-name {
|
.profile-name {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
color: #6bb9f0;
|
color: var(--game-text-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-username {
|
.profile-username {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: var(--game-text-secondary);
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,17 +123,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stats-section {
|
.stats-section {
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: var(--game-bg-panel);
|
||||||
border: 2px solid rgba(107, 185, 240, 0.3);
|
border: 2px solid var(--game-border-color);
|
||||||
border-radius: 12px;
|
border-radius: var(--game-radius-md);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
box-shadow: var(--game-shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
color: #6bb9f0;
|
color: var(--game-color-primary);
|
||||||
border-bottom: 2px solid rgba(107, 185, 240, 0.3);
|
border-bottom: 2px solid var(--game-border-color);
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +151,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: var(--game-text-secondary);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
}
|
}
|
||||||
@@ -156,7 +159,7 @@
|
|||||||
.stat-value {
|
.stat-value {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: #fff;
|
color: var(--game-text-primary);
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +185,7 @@
|
|||||||
|
|
||||||
/* Mobile responsive */
|
/* Mobile responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|
||||||
/* Remove tab bar spacing for profile page */
|
/* Remove tab bar spacing for profile page */
|
||||||
.game-main {
|
.game-main {
|
||||||
margin-bottom: 0 !important;
|
margin-bottom: 0 !important;
|
||||||
@@ -190,7 +194,8 @@
|
|||||||
.game-main .profile-container {
|
.game-main .profile-container {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
padding-top: 4rem; /* Space for hamburger button */
|
padding-top: 4rem;
|
||||||
|
/* Space for hamburger button */
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
104
pwa/src/components/common/GameTooltip.tsx
Normal file
104
pwa/src/components/common/GameTooltip.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { useState, useRef, ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
interface GameTooltipProps {
|
||||||
|
content: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string; // Class for the wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GameTooltip
|
||||||
|
*
|
||||||
|
* Wraps an element and displays a custom, instant game-style tooltip on hover.
|
||||||
|
* Uses React Portal to render outside the DOM hierarchy to avoid overflow/z-index issues.
|
||||||
|
* Follows the mouse cursor.
|
||||||
|
*/
|
||||||
|
export const GameTooltip: React.FC<GameTooltipProps> = ({ content, children, className = '' }) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const updatePosition = (e: React.MouseEvent) => {
|
||||||
|
// Offset from cursor
|
||||||
|
const offsetX = 15;
|
||||||
|
const offsetY = 15;
|
||||||
|
|
||||||
|
// Check viewport boundaries to prevent overflow
|
||||||
|
let x = e.clientX + offsetX;
|
||||||
|
let y = e.clientY + offsetY;
|
||||||
|
|
||||||
|
// Simple boundary check (can be expanded if needed)
|
||||||
|
if (tooltipRef.current) {
|
||||||
|
const rect = tooltipRef.current.getBoundingClientRect();
|
||||||
|
if (x + rect.width > window.innerWidth) {
|
||||||
|
x = e.clientX - rect.width - 5;
|
||||||
|
}
|
||||||
|
if (y + rect.height > window.innerHeight) {
|
||||||
|
y = e.clientY - rect.height - 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosition({ x, y });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = (e: React.MouseEvent) => {
|
||||||
|
setIsVisible(true);
|
||||||
|
updatePosition(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
updatePosition(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setIsVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render the tooltip portal
|
||||||
|
const tooltip = isVisible && content ? (
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: position.y,
|
||||||
|
left: position.x,
|
||||||
|
zIndex: 9999,
|
||||||
|
pointerEvents: 'none', // Ensure mouse doesn't get stuck on the tooltip itself
|
||||||
|
maxWidth: '300px'
|
||||||
|
}}
|
||||||
|
className="game-tooltip-content"
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--game-bg-tooltip, #151515)',
|
||||||
|
border: '1px solid var(--game-border-color, #333)',
|
||||||
|
borderRadius: 'var(--game-radius-sm, 4px)',
|
||||||
|
padding: '0.5rem 0.8rem',
|
||||||
|
boxShadow: 'var(--game-shadow-tooltip, 0 4px 12px rgba(0,0,0,0.5))',
|
||||||
|
color: 'var(--game-text-primary, #ddd)',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
backdropFilter: 'blur(4px)'
|
||||||
|
}}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`game-tooltip-wrapper ${className}`}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
style={{ display: 'contents' }} // Use contents so the wrapper doesn't affect layout
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{tooltip}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
/* Weight and Volume Progress Bars */
|
/* Weight and Volume Progress Bars */
|
||||||
.sidebar-progress-fill.weight {
|
.sidebar-progress-fill.weight {
|
||||||
background: linear-gradient(90deg, #ff9800, #f57c00);
|
background: var(--game-gradient-health);
|
||||||
|
/* Using health/red-orange for weight/load */
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-progress-fill.volume {
|
.sidebar-progress-fill.volume {
|
||||||
background: linear-gradient(90deg, #9c27b0, #7b1fa2);
|
background: var(--game-gradient-stamina);
|
||||||
|
/* Using stamina/yellow-gold for volume */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inventory Tab - Full View */
|
/* Inventory Tab - Full View */
|
||||||
@@ -34,6 +36,7 @@
|
|||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Redesigned Inventory Modal --- */
|
||||||
/* --- Redesigned Inventory Modal --- */
|
/* --- Redesigned Inventory Modal --- */
|
||||||
.inventory-modal-redesign {
|
.inventory-modal-redesign {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -41,14 +44,13 @@
|
|||||||
height: 85vh;
|
height: 85vh;
|
||||||
width: 95vw;
|
width: 95vw;
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
/* Match Workbench width */
|
background: var(--game-bg-modal);
|
||||||
background: linear-gradient(135deg, #1e2a38 0%, #121820 100%);
|
border: 1px solid var(--game-border-color);
|
||||||
border: 1px solid #3a4b5c;
|
border-radius: var(--game-radius-lg);
|
||||||
border-radius: 12px;
|
box-shadow: var(--game-shadow-modal);
|
||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.8);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #e0e6ed;
|
color: var(--game-text-primary);
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
font-family: var(--game-font-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Top Bar */
|
/* Top Bar */
|
||||||
@@ -57,8 +59,8 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: var(--game-bg-panel);
|
||||||
border-bottom: 1px solid #3a4b5c;
|
border-bottom: 1px solid var(--game-border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,23 +96,24 @@
|
|||||||
|
|
||||||
.metric-bar {
|
.metric-bar {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
background: #2d3748;
|
background: rgba(0, 0, 0, 0.5);
|
||||||
border-radius: 4px;
|
border-radius: var(--game-radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--game-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-fill {
|
.metric-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 4px;
|
border-radius: var(--game-radius-sm);
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-fill.weight {
|
.metric-fill.weight {
|
||||||
background: linear-gradient(90deg, #48bb78, #38a169);
|
background: var(--game-gradient-health);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-fill.volume {
|
.metric-fill.volume {
|
||||||
background: linear-gradient(90deg, #4299e1, #3182ce);
|
background: var(--game-gradient-stamina);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-backpack-info {
|
.inventory-backpack-info {
|
||||||
@@ -168,11 +171,12 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar Filters */
|
||||||
/* Sidebar Filters */
|
/* Sidebar Filters */
|
||||||
.inventory-sidebar-filters {
|
.inventory-sidebar-filters {
|
||||||
width: 220px;
|
width: 220px;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: var(--game-bg-panel);
|
||||||
border-right: 1px solid #3a4b5c;
|
border-right: 1px solid var(--game-border-color);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -231,10 +235,11 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: var(--game-bg-input);
|
||||||
border: 1px solid #3a4b5c;
|
border: 1px solid var(--game-border-color);
|
||||||
border-radius: 8px;
|
border-radius: var(--game-radius-md);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--game-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-search-bar input {
|
.inventory-search-bar input {
|
||||||
@@ -255,19 +260,20 @@
|
|||||||
padding-right: 0.5rem;
|
padding-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact Item Card */
|
||||||
/* Compact Item Card */
|
/* Compact Item Card */
|
||||||
.inventory-item-card.compact {
|
.inventory-item-card.compact {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
background-color: rgba(26, 32, 44, 0.8);
|
background-color: var(--game-bg-card);
|
||||||
border: 1px solid #2d3748;
|
border: 1px solid var(--game-border-color);
|
||||||
border-radius: 0.5rem;
|
border-radius: var(--game-radius-md);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
/* Add separation between cards */
|
box-shadow: var(--game-shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-item-card.compact:hover {
|
.inventory-item-card.compact:hover {
|
||||||
@@ -311,13 +317,14 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -5px;
|
bottom: -5px;
|
||||||
right: -5px;
|
right: -5px;
|
||||||
background: #2d3748;
|
background: var(--game-bg-panel);
|
||||||
border: 1px solid #4a5568;
|
border: 1px solid var(--game-border-color);
|
||||||
color: #fff;
|
color: var(--game-text-primary);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 10px;
|
border-radius: var(--game-radius-sm);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
box-shadow: var(--game-shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-info-section {
|
.item-info-section {
|
||||||
@@ -705,13 +712,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-btn.unequip {
|
.action-btn.unequip {
|
||||||
background: rgba(237, 137, 54, 0.2);
|
background: rgba(234, 113, 66, 0.1);
|
||||||
color: #ed8936;
|
color: var(--game-color-primary);
|
||||||
border: 1px solid rgba(237, 137, 54, 0.4);
|
border: 1px solid var(--game-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn.unequip:hover {
|
.action-btn.unequip:hover {
|
||||||
background: rgba(237, 137, 54, 0.3);
|
background: rgba(234, 113, 66, 0.2);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getAssetPath } from '../../utils/assetPath'
|
|||||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||||
import './InventoryModal.css'
|
import './InventoryModal.css'
|
||||||
import { EffectBadge } from './EffectBadge'
|
import { EffectBadge } from './EffectBadge'
|
||||||
|
import { GameTooltip } from '../common/GameTooltip'
|
||||||
|
|
||||||
interface InventoryModalProps {
|
interface InventoryModalProps {
|
||||||
playerState: PlayerState
|
playerState: PlayerState
|
||||||
@@ -285,10 +286,10 @@ function InventoryModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<GameTooltip content={isEffectActive ? t('game.effectAlreadyActive') : ''}>
|
||||||
<button
|
<button
|
||||||
className={`action-btn use ${isEffectActive ? 'disabled' : ''}`}
|
className={`action-btn use ${isEffectActive ? 'disabled' : ''}`}
|
||||||
disabled={isEffectActive}
|
disabled={isEffectActive}
|
||||||
title={isEffectActive ? t('game.effectAlreadyActive') : ''}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isEffectActive) {
|
if (!isEffectActive) {
|
||||||
playSfx('/audio/sfx/use.wav')
|
playSfx('/audio/sfx/use.wav')
|
||||||
@@ -298,6 +299,7 @@ function InventoryModal({
|
|||||||
>
|
>
|
||||||
{t('game.use')}
|
{t('game.use')}
|
||||||
</button>
|
</button>
|
||||||
|
</GameTooltip>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Location, PlayerState, CombatState, Profile, WorkbenchTab } from '
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAudio } from '../../contexts/AudioContext'
|
import { useAudio } from '../../contexts/AudioContext'
|
||||||
import Workbench from './Workbench'
|
import Workbench from './Workbench'
|
||||||
|
import { GameTooltip } from '../common/GameTooltip'
|
||||||
import { getAssetPath } from '../../utils/assetPath'
|
import { getAssetPath } from '../../utils/assetPath'
|
||||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||||
|
|
||||||
@@ -91,12 +92,16 @@ function LocationView({
|
|||||||
<h2 className="centered-heading">
|
<h2 className="centered-heading">
|
||||||
{getTranslatedText(location.name)}
|
{getTranslatedText(location.name)}
|
||||||
{location.danger_level !== undefined && location.danger_level === 0 && (
|
{location.danger_level !== undefined && location.danger_level === 0 && (
|
||||||
<span className="danger-badge danger-safe" title="Safe Zone">✓ Safe</span>
|
<GameTooltip content="Safe Zone">
|
||||||
|
<span className="danger-badge danger-safe">✓ Safe</span>
|
||||||
|
</GameTooltip>
|
||||||
)}
|
)}
|
||||||
{location.danger_level !== undefined && location.danger_level > 0 && (
|
{location.danger_level !== undefined && location.danger_level > 0 && (
|
||||||
<span className={`danger-badge danger-${location.danger_level}`} title={`Danger Level: ${location.danger_level}`}>
|
<GameTooltip content={`Danger Level: ${location.danger_level}`}>
|
||||||
|
<span className={`danger-badge danger-${location.danger_level}`}>
|
||||||
⚠️ {location.danger_level}
|
⚠️ {location.danger_level}
|
||||||
</span>
|
</span>
|
||||||
|
</GameTooltip>
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -110,10 +115,9 @@ function LocationView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<GameTooltip key={i} content={isClickable ? `Click to ${tag === 'workbench' ? 'craft items' : 'repair items'}` : `This location has: ${tag}`}>
|
||||||
<span
|
<span
|
||||||
key={i}
|
|
||||||
className={`location-tag tag-${tag} ${isClickable ? 'clickable' : ''}`}
|
className={`location-tag tag-${tag} ${isClickable ? 'clickable' : ''}`}
|
||||||
title={isClickable ? `Click to ${tag === 'workbench' ? 'craft items' : 'repair items'}` : `This location has: ${tag}`}
|
|
||||||
onClick={isClickable ? handleClick : undefined}
|
onClick={isClickable ? handleClick : undefined}
|
||||||
style={isClickable ? { cursor: 'pointer' } : undefined}
|
style={isClickable ? { cursor: 'pointer' } : undefined}
|
||||||
>
|
>
|
||||||
@@ -128,6 +132,7 @@ function LocationView({
|
|||||||
{tag === 'food_source' && t('tags.food')}
|
{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' && tag !== 'repair_station' && tag !== 'safe_zone' && tag !== 'shop' && tag !== 'shelter' && tag !== 'medical' && tag !== 'storage' && tag !== 'water_source' && tag !== 'food_source' && `🏷️ ${tag}`}
|
||||||
</span>
|
</span>
|
||||||
|
</GameTooltip>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -257,14 +262,15 @@ function LocationView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<GameTooltip content={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'}>
|
||||||
<button
|
<button
|
||||||
className="corpse-item-loot-btn"
|
className="corpse-item-loot-btn"
|
||||||
onClick={() => onLootCorpseItem(String(corpse.id), item.index)}
|
onClick={() => onLootCorpseItem(String(corpse.id), item.index)}
|
||||||
disabled={!item.can_loot}
|
disabled={!item.can_loot}
|
||||||
title={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'}
|
|
||||||
>
|
>
|
||||||
{item.can_loot ? `📦 ${t('common.loot')}` : '🔒'}
|
{item.can_loot ? `📦 ${t('common.loot')}` : '🔒'}
|
||||||
</button>
|
</button>
|
||||||
|
</GameTooltip>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -328,8 +334,8 @@ function LocationView({
|
|||||||
{item.quantity > 1 && <div className="entity-quantity">×{item.quantity}</div>}
|
{item.quantity > 1 && <div className="entity-quantity">×{item.quantity}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="item-info-btn-container">
|
<div className="item-info-btn-container">
|
||||||
<button className="entity-action-btn info" title="Item Info">{t('common.info')}</button>
|
<GameTooltip content={
|
||||||
<div className="item-info-tooltip">
|
<div className="item-info-tooltip-content">
|
||||||
{item.description && <div className="item-tooltip-desc">{getTranslatedText(item.description)}</div>}
|
{item.description && <div className="item-tooltip-desc">{getTranslatedText(item.description)}</div>}
|
||||||
{item.weight !== undefined && item.weight > 0 && (
|
{item.weight !== undefined && item.weight > 0 && (
|
||||||
<div className="item-tooltip-stat">
|
<div className="item-tooltip-stat">
|
||||||
@@ -361,6 +367,9 @@ function LocationView({
|
|||||||
<div className="item-tooltip-stat">⭐ {t('stats.tier')}: {item.tier}</div>
|
<div className="item-tooltip-stat">⭐ {t('stats.tier')}: {item.tier}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
}>
|
||||||
|
<button className="entity-action-btn info">{t('common.info')}</button>
|
||||||
|
</GameTooltip>
|
||||||
</div>
|
</div>
|
||||||
{item.quantity === 1 ? (
|
{item.quantity === 1 ? (
|
||||||
<button
|
<button
|
||||||
@@ -425,13 +434,14 @@ function LocationView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{player.can_pvp && (
|
{player.can_pvp && (
|
||||||
|
<GameTooltip content={`Attack ${player.name || player.username}`}>
|
||||||
<button
|
<button
|
||||||
className="pvp-btn"
|
className="pvp-btn"
|
||||||
onClick={() => onInitiatePvP(player.id)}
|
onClick={() => onInitiatePvP(player.id)}
|
||||||
title={`Attack ${player.name || player.username}`}
|
|
||||||
>
|
>
|
||||||
{t('game.attack')}
|
{t('game.attack')}
|
||||||
</button>
|
</button>
|
||||||
|
</GameTooltip>
|
||||||
)}
|
)}
|
||||||
{!player.can_pvp && player.level_diff !== undefined && Math.abs(player.level_diff) > 3 && (
|
{!player.can_pvp && player.level_diff !== undefined && Math.abs(player.level_diff) > 3 && (
|
||||||
<div className="pvp-disabled-reason">{t('game.levelDifferenceTooHigh')}</div>
|
<div className="pvp-disabled-reason">{t('game.levelDifferenceTooHigh')}</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getAssetPath } from '../../utils/assetPath'
|
import { getAssetPath } from '../../utils/assetPath'
|
||||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||||
|
import { GameTooltip } from '../common/GameTooltip'
|
||||||
|
|
||||||
interface MovementControlsProps {
|
interface MovementControlsProps {
|
||||||
location: Location
|
location: Location
|
||||||
@@ -77,24 +78,29 @@ function MovementControls({
|
|||||||
movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) :
|
movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) :
|
||||||
combatState ? t('messages.cannotTravelCombat') :
|
combatState ? t('messages.cannotTravelCombat') :
|
||||||
insufficientStamina ? t('messages.notEnoughStamina', { need: stamina, have: profile?.stamina ?? 0 }) :
|
insufficientStamina ? t('messages.notEnoughStamina', { need: stamina, have: profile?.stamina ?? 0 }) :
|
||||||
available ? `${destination}\n${t('game.distance')}: ${distance}m\n${t('game.stamina')}: ${stamina}` :
|
available ? (
|
||||||
t('messages.cannotGo', { direction: t('directions.' + direction) })
|
<div className="movement-tooltip">
|
||||||
|
<div className="tooltip-title">{destination}</div>
|
||||||
|
<div className="tooltip-stat">📏 {t('game.distance')}: {distance}m</div>
|
||||||
|
<div className="tooltip-stat">⚡ {t('game.stamina')}: {stamina}</div>
|
||||||
|
</div>
|
||||||
|
) : t('messages.cannotGo', { direction: t('directions.' + direction) })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<GameTooltip key={direction} content={tooltipText}>
|
||||||
<button
|
<button
|
||||||
key={direction}
|
|
||||||
className={`compass-btn ${className} ${disabled ? 'disabled' : ''} ${combatState ? 'in-combat' : ''}`}
|
className={`compass-btn ${className} ${disabled ? 'disabled' : ''} ${combatState ? 'in-combat' : ''}`}
|
||||||
onClick={() => onMove(direction)}
|
onClick={() => onMove(direction)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
title={tooltipText}
|
|
||||||
>
|
>
|
||||||
<span className="compass-arrow">{arrow}</span>
|
<span className="compass-arrow">{arrow}</span>
|
||||||
{available && movementCooldown > 0 ? (
|
{available && movementCooldown > 0 ? (
|
||||||
<span className="compass-cost" title={t('messages.waitBeforeMoving', { seconds: movementCooldown })}>⏳{movementCooldown}s</span>
|
<span className="compass-cost">⏳{movementCooldown}s</span>
|
||||||
) : available && (
|
) : available && (
|
||||||
<span className="compass-cost">⚡{stamina}</span>
|
<span className="compass-cost">⚡{stamina}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
</GameTooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,64 +137,95 @@ function MovementControls({
|
|||||||
{/* Special movements */}
|
{/* Special movements */}
|
||||||
<div className="special-moves">
|
<div className="special-moves">
|
||||||
{location.directions.includes('up') && (
|
{location.directions.includes('up') && (
|
||||||
|
<GameTooltip content={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : (
|
||||||
|
<div className="movement-tooltip">
|
||||||
|
<div className="tooltip-title">{t('directions.up')}</div>
|
||||||
|
<div className="tooltip-stat">⚡ {t('game.stamina')}: {getStaminaCost('up')}</div>
|
||||||
|
</div>
|
||||||
|
)}>
|
||||||
<button
|
<button
|
||||||
onClick={() => onMove('up')}
|
onClick={() => onMove('up')}
|
||||||
className="special-btn"
|
className="special-btn"
|
||||||
disabled={!!combatState || movementCooldown > 0}
|
disabled={!!combatState || movementCooldown > 0}
|
||||||
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.up')}\n${t('game.stamina')}: ${getStaminaCost('up')}`}
|
|
||||||
>
|
>
|
||||||
⬆️ {t('directions.up')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('up')}`}</span>
|
⬆️ {t('directions.up')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('up')}`}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</GameTooltip>
|
||||||
)}
|
)}
|
||||||
{location.directions.includes('down') && (
|
{location.directions.includes('down') && (
|
||||||
|
<GameTooltip content={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : (
|
||||||
|
<div className="movement-tooltip">
|
||||||
|
<div className="tooltip-title">{t('directions.down')}</div>
|
||||||
|
<div className="tooltip-stat">⚡ {t('game.stamina')}: {getStaminaCost('down')}</div>
|
||||||
|
</div>
|
||||||
|
)}>
|
||||||
<button
|
<button
|
||||||
onClick={() => onMove('down')}
|
onClick={() => onMove('down')}
|
||||||
className="special-btn"
|
className="special-btn"
|
||||||
disabled={!!combatState || movementCooldown > 0}
|
disabled={!!combatState || movementCooldown > 0}
|
||||||
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.down')}\n${t('game.stamina')}: ${getStaminaCost('down')}`}
|
|
||||||
>
|
>
|
||||||
⬇️ {t('directions.down')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('down')}`}</span>
|
⬇️ {t('directions.down')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('down')}`}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</GameTooltip>
|
||||||
)}
|
)}
|
||||||
{location.directions.includes('enter') && (
|
{location.directions.includes('enter') && (
|
||||||
|
<GameTooltip content={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : (
|
||||||
|
<div className="movement-tooltip">
|
||||||
|
<div className="tooltip-title">{t('directions.enter')}</div>
|
||||||
|
<div className="tooltip-stat">⚡ {t('game.stamina')}: {getStaminaCost('enter')}</div>
|
||||||
|
</div>
|
||||||
|
)}>
|
||||||
<button
|
<button
|
||||||
onClick={() => onMove('enter')}
|
onClick={() => onMove('enter')}
|
||||||
className="special-btn"
|
className="special-btn"
|
||||||
disabled={!!combatState || movementCooldown > 0}
|
disabled={!!combatState || movementCooldown > 0}
|
||||||
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.enter')}\n${t('game.stamina')}: ${getStaminaCost('enter')}`}
|
|
||||||
>
|
>
|
||||||
🚪 {t('directions.enter')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('enter')}`}</span>
|
🚪 {t('directions.enter')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('enter')}`}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</GameTooltip>
|
||||||
)}
|
)}
|
||||||
{location.directions.includes('inside') && (
|
{location.directions.includes('inside') && (
|
||||||
|
<GameTooltip content={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : (
|
||||||
|
<div className="movement-tooltip">
|
||||||
|
<div className="tooltip-title">{t('directions.inside')}</div>
|
||||||
|
<div className="tooltip-stat">⚡ {t('game.stamina')}: {getStaminaCost('inside')}</div>
|
||||||
|
</div>
|
||||||
|
)}>
|
||||||
<button
|
<button
|
||||||
onClick={() => onMove('inside')}
|
onClick={() => onMove('inside')}
|
||||||
className="special-btn"
|
className="special-btn"
|
||||||
disabled={!!combatState || movementCooldown > 0}
|
disabled={!!combatState || movementCooldown > 0}
|
||||||
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.inside')}\n${t('game.stamina')}: ${getStaminaCost('inside')}`}
|
|
||||||
>
|
>
|
||||||
🚪 {t('directions.inside')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('inside')}`}</span>
|
🚪 {t('directions.inside')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('inside')}`}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</GameTooltip>
|
||||||
)}
|
)}
|
||||||
{location.directions.includes('exit') && (
|
{location.directions.includes('exit') && (
|
||||||
|
<GameTooltip content={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : t('directions.exit')}>
|
||||||
<button
|
<button
|
||||||
onClick={() => onMove('exit')}
|
onClick={() => onMove('exit')}
|
||||||
className="special-btn"
|
className="special-btn"
|
||||||
disabled={!!combatState || movementCooldown > 0}
|
disabled={!!combatState || movementCooldown > 0}
|
||||||
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : t('directions.exit')}
|
|
||||||
>
|
>
|
||||||
🚪 {t('directions.exit')}
|
🚪 {t('directions.exit')}
|
||||||
</button>
|
</button>
|
||||||
|
</GameTooltip>
|
||||||
)}
|
)}
|
||||||
{location.directions.includes('outside') && (
|
{location.directions.includes('outside') && (
|
||||||
|
<GameTooltip content={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : (
|
||||||
|
<div className="movement-tooltip">
|
||||||
|
<div className="tooltip-title">{t('directions.outside')}</div>
|
||||||
|
<div className="tooltip-stat">⚡ {t('game.stamina')}: {getStaminaCost('outside')}</div>
|
||||||
|
</div>
|
||||||
|
)}>
|
||||||
<button
|
<button
|
||||||
onClick={() => onMove('outside')}
|
onClick={() => onMove('outside')}
|
||||||
className="special-btn"
|
className="special-btn"
|
||||||
disabled={!!combatState || movementCooldown > 0}
|
disabled={!!combatState || movementCooldown > 0}
|
||||||
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.outside')}\n${t('game.stamina')}: ${getStaminaCost('outside')}`}
|
|
||||||
>
|
>
|
||||||
🚪 {t('directions.outside')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('outside')}`}</span>
|
🚪 {t('directions.outside')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('outside')}`}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</GameTooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,12 +265,7 @@ function MovementControls({
|
|||||||
const insufficientStamina = profile ? profile.stamina < staminaCost : false
|
const insufficientStamina = profile ? profile.stamina < staminaCost : false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<GameTooltip key={action.id} content={
|
||||||
key={action.id}
|
|
||||||
className={`interact-btn ${insufficientStamina ? 'disabled' : ''}`}
|
|
||||||
disabled={!!combatState || cooldownRemaining > 0 || insufficientStamina || (profile?.is_dead ?? false)}
|
|
||||||
onClick={() => onInteract && onInteract(interactable.instance_id, action.id)}
|
|
||||||
title={
|
|
||||||
profile?.is_dead
|
profile?.is_dead
|
||||||
? t('messages.youAreDead')
|
? t('messages.youAreDead')
|
||||||
: combatState
|
: combatState
|
||||||
@@ -243,13 +275,18 @@ function MovementControls({
|
|||||||
: cooldownRemaining > 0
|
: cooldownRemaining > 0
|
||||||
? t('messages.interactionCooldown', { seconds: cooldownRemaining })
|
? t('messages.interactionCooldown', { seconds: cooldownRemaining })
|
||||||
: getTranslatedText(action.description)
|
: getTranslatedText(action.description)
|
||||||
}
|
}>
|
||||||
|
<button
|
||||||
|
className={`interact-btn ${insufficientStamina ? 'disabled' : ''}`}
|
||||||
|
disabled={!!combatState || cooldownRemaining > 0 || insufficientStamina || (profile?.is_dead ?? false)}
|
||||||
|
onClick={() => onInteract && onInteract(interactable.instance_id, action.id)}
|
||||||
>
|
>
|
||||||
{getTranslatedText(action.name)}
|
{getTranslatedText(action.name)}
|
||||||
<span className="stamina-cost">
|
<span className="stamina-cost">
|
||||||
{cooldownRemaining > 0 ? `⏳${cooldownRemaining}s` : `⚡${staminaCost}`}
|
{cooldownRemaining > 0 ? `⏳${cooldownRemaining}s` : `⚡${staminaCost}`}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</GameTooltip>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getAssetPath } from '../../utils/assetPath'
|
|||||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||||
import InventoryModal from './InventoryModal'
|
import InventoryModal from './InventoryModal'
|
||||||
import { GameProgressBar } from '../common/GameProgressBar'
|
import { GameProgressBar } from '../common/GameProgressBar'
|
||||||
|
import { GameTooltip } from '../common/GameTooltip'
|
||||||
|
|
||||||
interface PlayerSidebarProps {
|
interface PlayerSidebarProps {
|
||||||
playerState: PlayerState
|
playerState: PlayerState
|
||||||
@@ -40,11 +41,80 @@ function PlayerSidebar({
|
|||||||
const [showInventory, setShowInventory] = useState(false)
|
const [showInventory, setShowInventory] = useState(false)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const renderEquipmentSlot = (slot: string, item: any, label: string) => (
|
const renderEquipmentSlot = (slot: string, item: any, label: string) => {
|
||||||
<div className={`equipment-slot ${item ? 'filled' : 'empty'}`} title={!item ? label : ''}>
|
// Construct the tooltip content if item exists
|
||||||
|
const tooltipContent = item ? (
|
||||||
|
<div className="game-tooltip-stats">
|
||||||
|
<div className="item-tooltip-name" style={{ color: 'var(--game-text-highlight)', fontWeight: 'bold' }}>
|
||||||
|
{getTranslatedText(item.name)}
|
||||||
|
</div>
|
||||||
|
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
|
||||||
|
<div className="item-tooltip-stat" style={{ color: '#fbbf24' }}>
|
||||||
|
⭐ Tier: {item.tier}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.description && <div className="item-tooltip-desc" style={{ color: 'var(--game-text-secondary)', fontStyle: 'italic', marginBottom: '0.5rem' }}>{getTranslatedText(item.description)}</div>}
|
||||||
|
{(item.unique_stats || item.stats) && Object.keys(item.unique_stats || item.stats).length > 0 && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'auto auto', gap: '0.25rem 1rem' }}>
|
||||||
|
{(item.unique_stats?.armor || item.stats?.armor) && (
|
||||||
|
<div className="item-tooltip-stat">
|
||||||
|
{t('stats.armor')}: <span style={{ color: 'var(--game-color-success)' }}>+{item.unique_stats?.armor || item.stats?.armor}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(item.unique_stats?.hp_max || item.stats?.hp_max) && (
|
||||||
|
<div className="item-tooltip-stat">
|
||||||
|
{t('stats.hp')}: <span style={{ color: 'var(--game-color-success)' }}>+{item.unique_stats?.hp_max || item.stats?.hp_max}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(item.unique_stats?.stamina_max || item.stats?.stamina_max) && (
|
||||||
|
<div className="item-tooltip-stat">
|
||||||
|
{t('stats.stamina')}: <span style={{ color: 'var(--game-color-stamina)' }}>+{item.unique_stats?.stamina_max || item.stats?.stamina_max}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(item.unique_stats?.damage_min !== undefined || item.stats?.damage_min !== undefined) &&
|
||||||
|
(item.unique_stats?.damage_max !== undefined || item.stats?.damage_max !== undefined) && (
|
||||||
|
<div className="item-tooltip-stat">
|
||||||
|
{t('stats.damage')}: <span style={{ color: 'var(--game-color-primary)' }}>{item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && (
|
||||||
|
<div className="item-tooltip-stat">
|
||||||
|
{t('stats.weight')}: +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && (
|
||||||
|
<div className="item-tooltip-stat">
|
||||||
|
{t('stats.volume')}: +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.durability !== undefined && item.durability !== null && (
|
||||||
|
<div className="item-tooltip-stat" style={{ marginTop: '0.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
|
||||||
|
<span>{t('stats.durability')}:</span>
|
||||||
|
<span>{item.durability}/{item.max_durability}</span>
|
||||||
|
</div>
|
||||||
|
<GameProgressBar
|
||||||
|
value={item.durability}
|
||||||
|
max={item.max_durability}
|
||||||
|
type="durability"
|
||||||
|
height="6px"
|
||||||
|
showText={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : label; // Show label if no item
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GameTooltip content={tooltipContent}>
|
||||||
|
<div className={`equipment-slot game-slot ${item ? 'filled' : 'empty'}`}>
|
||||||
{item ? (
|
{item ? (
|
||||||
<>
|
<>
|
||||||
<button className="equipment-unequip-btn" onClick={() => onUnequipItem(slot)} title={t('game.unequip')}>✕</button>
|
<GameTooltip content={t('game.unequip')}>
|
||||||
|
<button className="equipment-unequip-btn game-btn game-btn-icon" onClick={(e) => { e.stopPropagation(); onUnequipItem(slot); }}>✕</button>
|
||||||
|
</GameTooltip>
|
||||||
<div className="equipment-item-content">
|
<div className="equipment-item-content">
|
||||||
{item.image_path ? (
|
{item.image_path ? (
|
||||||
<img
|
<img
|
||||||
@@ -68,65 +138,6 @@ function PlayerSidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="equipment-tooltip">
|
|
||||||
<div className="item-tooltip-name">{getTranslatedText(item.name)}</div>
|
|
||||||
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
|
|
||||||
<div className="item-tooltip-stat" style={{ color: '#fbbf24' }}>
|
|
||||||
⭐ Tier: {item.tier}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{item.description && <div className="item-tooltip-desc">{getTranslatedText(item.description)}</div>}
|
|
||||||
{(item.unique_stats || item.stats) && Object.keys(item.unique_stats || item.stats).length > 0 && (
|
|
||||||
<>
|
|
||||||
{(item.unique_stats?.armor || item.stats?.armor) && (
|
|
||||||
<div className="item-tooltip-stat">
|
|
||||||
{t('stats.armor')}: +{item.unique_stats?.armor || item.stats?.armor}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(item.unique_stats?.hp_max || item.stats?.hp_max) && (
|
|
||||||
<div className="item-tooltip-stat">
|
|
||||||
{t('stats.hp')}: +{item.unique_stats?.hp_max || item.stats?.hp_max}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(item.unique_stats?.stamina_max || item.stats?.stamina_max) && (
|
|
||||||
<div className="item-tooltip-stat">
|
|
||||||
{t('stats.stamina')}: +{item.unique_stats?.stamina_max || item.stats?.stamina_max}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(item.unique_stats?.damage_min !== undefined || item.stats?.damage_min !== undefined) &&
|
|
||||||
(item.unique_stats?.damage_max !== undefined || item.stats?.damage_max !== undefined) && (
|
|
||||||
<div className="item-tooltip-stat">
|
|
||||||
{t('stats.damage')}: {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && (
|
|
||||||
<div className="item-tooltip-stat">
|
|
||||||
{t('stats.weight')}: +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && (
|
|
||||||
<div className="item-tooltip-stat">
|
|
||||||
{t('stats.volume')}: +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{item.durability !== undefined && item.durability !== null && (
|
|
||||||
<div className="item-tooltip-stat">
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
|
|
||||||
<span>{t('stats.durability')}:</span>
|
|
||||||
<span>{item.durability}/{item.max_durability}</span>
|
|
||||||
</div>
|
|
||||||
<GameProgressBar
|
|
||||||
value={item.durability}
|
|
||||||
max={item.max_durability}
|
|
||||||
type="durability"
|
|
||||||
height="6px"
|
|
||||||
showText={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -139,7 +150,9 @@ function PlayerSidebar({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</GameTooltip>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`right-sidebar mobile-menu-panel ${mobileMenuOpen === 'right' ? 'open' : ''}`}>
|
<div className={`right-sidebar mobile-menu-panel ${mobileMenuOpen === 'right' ? 'open' : ''}`}>
|
||||||
|
|||||||
@@ -17,13 +17,15 @@
|
|||||||
width: 95vw;
|
width: 95vw;
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
height: 85vh;
|
height: 85vh;
|
||||||
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
|
background: var(--game-bg-modal);
|
||||||
border: 1px solid #4a5568;
|
border: 1px solid var(--game-border-color);
|
||||||
border-radius: 12px;
|
border-radius: var(--game-radius-lg);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
|
box-shadow: var(--game-shadow-modal);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
color: var(--game-text-primary);
|
||||||
|
font-family: var(--game-font-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-header {
|
.workbench-header {
|
||||||
@@ -31,14 +33,14 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: var(--game-bg-panel);
|
||||||
border-bottom: 1px solid #4a5568;
|
border-bottom: 1px solid var(--game-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-header h3 {
|
.workbench-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #e2e8f0;
|
color: var(--game-text-highlight);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -98,8 +100,8 @@
|
|||||||
|
|
||||||
/* Column 1: Sidebar */
|
/* Column 1: Sidebar */
|
||||||
.workbench-sidebar {
|
.workbench-sidebar {
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: var(--game-bg-panel);
|
||||||
border-right: 1px solid #3a4b5c;
|
border-right: 1px solid var(--game-border-color);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -142,9 +144,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workbench-sidebar .category-btn.active {
|
.workbench-sidebar .category-btn.active {
|
||||||
background: rgba(66, 153, 225, 0.15);
|
background: rgba(234, 113, 66, 0.15);
|
||||||
border-color: #4299e1;
|
border-color: var(--game-color-primary);
|
||||||
color: #63b3ed;
|
color: var(--game-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-sidebar .cat-icon {
|
.workbench-sidebar .cat-icon {
|
||||||
@@ -187,9 +189,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--game-bg-card);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--game-border-color);
|
||||||
border-radius: 6px;
|
border-radius: var(--game-radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -201,20 +203,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workbench-item-card.selected {
|
.workbench-item-card.selected {
|
||||||
background: rgba(66, 153, 225, 0.1);
|
background: rgba(234, 113, 66, 0.1);
|
||||||
border-color: #4299e1;
|
border-color: var(--game-color-primary);
|
||||||
|
box-shadow: 0 0 0 1px var(--game-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-item-card.craftable {
|
.workbench-item-card.craftable {
|
||||||
border-left: 3px solid #4caf50;
|
border-left: 3px solid var(--game-color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-item-card.repairable {
|
.workbench-item-card.repairable {
|
||||||
border-left: 3px solid #ff9800;
|
border-left: 3px solid var(--game-color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-item-card.salvageable {
|
.workbench-item-card.salvageable {
|
||||||
border-left: 3px solid #9c27b0;
|
border-left: 3px solid var(--game-color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card-content {
|
.item-card-content {
|
||||||
@@ -446,7 +449,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: var(--game-bg-panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-header {
|
.detail-header {
|
||||||
@@ -460,11 +463,11 @@
|
|||||||
width: 120px;
|
width: 120px;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
margin: 0 auto 1.5rem auto;
|
margin: 0 auto 1.5rem auto;
|
||||||
border-radius: 12px;
|
border-radius: var(--game-radius-md);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 2px solid #4a5568;
|
border: 2px solid var(--game-border-color);
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: var(--game-bg-input);
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
box-shadow: var(--game-shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-image {
|
.detail-image {
|
||||||
|
|||||||
@@ -1,11 +1,64 @@
|
|||||||
:root {
|
: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;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
color: rgba(255, 255, 255, 0.87);
|
color: var(--game-text-primary);
|
||||||
background-color: #1a1a1a;
|
background-color: var(--game-bg-app);
|
||||||
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
@@ -13,58 +66,105 @@
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
/* --- Reusable Game Classes --- */
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
/* Panels */
|
||||||
box-sizing: border-box;
|
.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 {
|
.game-modal-overlay {
|
||||||
margin: 0;
|
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;
|
display: flex;
|
||||||
place-items: center;
|
align-items: center;
|
||||||
min-width: 320px;
|
justify-content: center;
|
||||||
min-height: 100vh;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
/* Buttons */
|
||||||
width: 100%;
|
.game-btn {
|
||||||
min-height: 100vh;
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
border: 1px solid var(--game-border-color);
|
||||||
|
color: var(--game-text-primary);
|
||||||
button {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
padding: 0.6em 1.2em;
|
||||||
font-size: 1em;
|
font-family: var(--game-font-main);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
font-family: inherit;
|
text-transform: uppercase;
|
||||||
background-color: #2a2a2a;
|
letter-spacing: 0.5px;
|
||||||
cursor: pointer;
|
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 {
|
.game-btn:hover {
|
||||||
border-color: #646cff;
|
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,
|
.game-btn:active {
|
||||||
button:focus-visible {
|
transform: translateY(1px);
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
.game-btn:disabled {
|
||||||
:root {
|
opacity: 0.5;
|
||||||
color: #213547;
|
cursor: not-allowed;
|
||||||
background-color: #ffffff;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
.game-btn-primary {
|
||||||
background-color: #f9f9f9;
|
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 */
|
/* Twemoji styles */
|
||||||
img.emoji {
|
img.emoji {
|
||||||
height: 1em;
|
height: 1em;
|
||||||
|
|||||||
121
scripts/convert_to_template_format.py
Normal file
121
scripts/convert_to_template_format.py
Normal file
@@ -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()
|
||||||
165
scripts/export_to_json.py
Normal file
165
scripts/export_to_json.py
Normal file
@@ -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()
|
||||||
79
scripts/migrate_add_wandering_flag.py
Normal file
79
scripts/migrate_add_wandering_flag.py
Normal file
@@ -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())
|
||||||
117
scripts/migrate_combat.py
Normal file
117
scripts/migrate_combat.py
Normal file
@@ -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())
|
||||||
281
scripts/update_locations.py
Normal file
281
scripts/update_locations.py
Normal file
@@ -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")
|
||||||
Reference in New Issue
Block a user