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
|
||||
- **Grid-based world navigation** with coordinates (x, y)
|
||||
- **Stamina-based movement system** - each move costs stamina based on distance
|
||||
- **Multiple biomes and locations** with varying danger levels (0-4)
|
||||
- **Dynamic location discovery** as you explore
|
||||
- **Compass-based directional movement** (North, South, East, West)
|
||||
|
||||
#### ⚔️ Combat System
|
||||
- **Turn-based combat** with real-time intent preview
|
||||
- **NPC enemy encounters** with weighted spawn tables per location
|
||||
- **Status effects system**: Bleeding, Infected, Radiation
|
||||
- **Weapon effects**: Bleeding, Stun, Armor Break
|
||||
- **Flee mechanics** - escape combat with success/failure chance
|
||||
- **XP and leveling system** - gain XP from defeating enemies
|
||||
- **PvP (Player vs Player) combat** - challenge other players
|
||||
- **Death and respawn mechanics**
|
||||
|
||||
#### 🎒 Inventory & Equipment
|
||||
- **Weight and volume-based inventory** system
|
||||
- **Equipment slots**: Weapon, Backpack, Armor, Head, Tool
|
||||
- **Durability system** - items degrade with use
|
||||
- **Item tiers** (1-3) affecting quality and stats
|
||||
- **Encumbrance system** - affects stamina costs
|
||||
- **Ground item drops** - pick up and drop items
|
||||
|
||||
#### 🔨 Crafting & Repair
|
||||
- **Crafting system** with material requirements
|
||||
- **Tool requirements** for certain recipes
|
||||
- **Repair mechanics** - restore item durability
|
||||
- **Uncrafting/Disassembly** - break down items for materials
|
||||
- **Workbench locations** for advanced crafting
|
||||
- **Craft level requirements** - unlocked through progression
|
||||
|
||||
#### 🔍 Scavenging & Interactables
|
||||
- **Searchable objects** in each location (dumpsters, cars, houses, etc.)
|
||||
- **Action-based interaction** system with stamina costs
|
||||
- **Success/failure mechanics** with critical outcomes
|
||||
- **Loot tables** with item drop chances
|
||||
- **One-time and respawning interactables**
|
||||
- **Status tracking** per player (already looted, depleted, etc.)
|
||||
|
||||
#### 📊 Character Progression
|
||||
- **Level system** (1-50+) with XP requirements
|
||||
- **Stat points** - allocate to Strength, Defense, Stamina
|
||||
- **Character customization** on creation
|
||||
- **Skill progression** tied to crafting levels
|
||||
|
||||
#### 🌍 World Features
|
||||
- **Multi-location world** (Downtown, Gas Station, Residential, Clinic, Plaza, Park, Warehouse, Office Buildings, Subway, etc.)
|
||||
- **Location tags** - workbench, repair_station, safe_zone
|
||||
- **Danger zones** with varying encounter rates
|
||||
- **Location-specific loot** and enemy spawns
|
||||
|
||||
#### 💬 Social & Multiplayer
|
||||
- **Online player tracking** via WebSockets
|
||||
- **Real-time player position updates**
|
||||
- **PvP combat system** with challenge mechanics
|
||||
- **Character browsing** - see other players' stats
|
||||
|
||||
#### 🎨 PWA Features
|
||||
- **Progressive Web App** - installable on mobile/desktop
|
||||
- **Multi-language support** (English, Spanish)
|
||||
- **Responsive UI** with mobile-first design
|
||||
- **Real-time updates** via WebSockets
|
||||
- **Offline capabilities** (service worker)
|
||||
Echoes of the Ash is a **browser-based RPG** set in a dark, post-apocalyptic world. Inspired by classic MUD (Multi-User Dungeon) games, it combines text-driven exploration with visual elements, real-time combat, and survival mechanics.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Gamedata Structure
|
||||
## 🌟 Current Game Features
|
||||
|
||||
The game uses JSON files in the `gamedata/` directory to define all game content. This modular approach makes it easy to add new content without code changes.
|
||||
### Core Systems
|
||||
|
||||
### Directory Layout
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| **Character System** | ✅ Complete | Create characters with 4 stats (Strength, Agility, Endurance, Intellect) |
|
||||
| **Health & Stamina** | ✅ Complete | HP/Stamina management with visual progress bars |
|
||||
| **Leveling & XP** | ✅ Complete | XP-based progression with stat point allocation |
|
||||
| **Inventory** | ✅ Complete | Weight/volume-based carrying capacity |
|
||||
| **Equipment** | ✅ Complete | Weapon, armor, and backpack slots |
|
||||
| **Combat (PvE)** | ✅ Complete | Turn-based combat with visual effects |
|
||||
| **Combat (PvP)** | ✅ Complete | Player vs Player combat system |
|
||||
| **Real-time Updates** | ✅ Complete | WebSocket-based live game state |
|
||||
|
||||
```
|
||||
gamedata/
|
||||
├── npcs.json # Enemy NPCs and combat encounters
|
||||
├── items.json # All items, weapons, consumables, and resources
|
||||
├── locations.json # World map locations and interactables
|
||||
└── interactables.json # Interactable object templates
|
||||
```
|
||||
### Exploration & Interaction
|
||||
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| **World Map** | ✅ Complete | Graph-based location system with connections |
|
||||
| **Movement** | ✅ Complete | Navigate between connected locations |
|
||||
| **Interactables** | ✅ Complete | Search containers, objects for loot |
|
||||
| **Enemy Spawning** | ✅ Complete | Static and wandering NPCs |
|
||||
| **Corpse Looting** | ✅ Complete | Loot fallen enemies and players |
|
||||
| **Dropped Items** | ✅ Complete | Pick up items on the ground |
|
||||
|
||||
### Crafting & Economy
|
||||
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| **Workbench** | ✅ Complete | Craft, repair, and salvage items |
|
||||
| **Crafting System** | ✅ Complete | Create items from materials |
|
||||
| **Repair System** | ✅ Complete | Restore durability to equipment |
|
||||
| **Salvage System** | ✅ Complete | Break down items for materials |
|
||||
|
||||
### Social & Multiplayer
|
||||
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| **Accounts** | ✅ Complete | Registration, login, JWT authentication |
|
||||
| **Multiple Characters** | ✅ Complete | Create up to 3 characters per account |
|
||||
| **Leaderboards** | ✅ Complete | Rankings by level, kills, XP |
|
||||
| **Player Profiles** | ✅ Complete | View player stats and equipment |
|
||||
| **Online Players** | ✅ Complete | See who's currently online |
|
||||
|
||||
### Platforms
|
||||
|
||||
| Platform | Status | Description |
|
||||
|----------|--------|-------------|
|
||||
| **Web Browser** | ✅ Complete | Play at any time via modern browser |
|
||||
| **PWA (Mobile)** | ✅ Complete | Install as app on mobile devices |
|
||||
| **Electron Desktop** | ✅ Complete | Standalone Windows/Mac/Linux app |
|
||||
| **Steam Integration** | 🔧 Setup | Steamworks SDK ready for deployment |
|
||||
|
||||
---
|
||||
|
||||
## 📋 `npcs.json` Structure
|
||||
## 🎯 What Can Players Do?
|
||||
|
||||
Defines all enemy NPCs, their stats, loot tables, and spawn locations.
|
||||
### Getting Started
|
||||
1. **Create an Account** - Register with username and password
|
||||
2. **Create a Character** - Name your survivor and choose starting stats
|
||||
3. **Enter the World** - Spawn at the starting location
|
||||
|
||||
### Top-Level Structure
|
||||
```json
|
||||
{
|
||||
"npcs": { ... }, // NPC definitions
|
||||
"danger_levels": { ... }, // Danger settings per location
|
||||
"spawn_tables": { ... } // Enemy spawn weights per location
|
||||
}
|
||||
```
|
||||
### Gameplay Loop
|
||||
1. **Explore** - Move between connected locations to discover new areas
|
||||
2. **Scavenge** - Search containers, corpses, and interactables for supplies
|
||||
3. **Fight** - Engage hostile NPCs in turn-based combat
|
||||
4. **Craft** - Use workbenches to create, repair, or salvage items
|
||||
5. **Level Up** - Gain XP from combat and allocate stat points
|
||||
6. **Survive** - Manage HP, stamina, and inventory weight
|
||||
|
||||
### NPC Definition
|
||||
```json
|
||||
"npc_id": {
|
||||
"npc_id": "unique_npc_identifier",
|
||||
"name": {
|
||||
"en": "English Name",
|
||||
"es": "Spanish Name"
|
||||
},
|
||||
"description": {
|
||||
"en": "English description",
|
||||
"es": "Spanish description"
|
||||
},
|
||||
"emoji": "🐕",
|
||||
"hp_min": 15, // Minimum HP when spawned
|
||||
"hp_max": 25, // Maximum HP when spawned
|
||||
"damage_min": 3, // Minimum attack damage
|
||||
"damage_max": 7, // Maximum attack damage
|
||||
"defense": 0, // Damage reduction
|
||||
"xp_reward": 10, // XP given on defeat
|
||||
"loot_table": [ // Items dropped on death (automatic)
|
||||
{
|
||||
"item_id": "raw_meat",
|
||||
"quantity_min": 1,
|
||||
"quantity_max": 2,
|
||||
"drop_chance": 0.6 // 60% chance to drop
|
||||
}
|
||||
],
|
||||
"corpse_loot": [ // Items harvestable from corpse
|
||||
{
|
||||
"item_id": "animal_hide",
|
||||
"quantity_min": 1,
|
||||
"quantity_max": 1,
|
||||
"required_tool": "knife" // Tool needed to harvest (null = no requirement)
|
||||
}
|
||||
],
|
||||
"flee_chance": 0.3, // Chance NPC flees from combat
|
||||
"status_inflict_chance": 0.15, // Chance to inflict status effect on hit
|
||||
"image_path": "images/npcs/feral_dog.webp",
|
||||
"death_message": "The feral dog whimpers and collapses..."
|
||||
}
|
||||
```
|
||||
### Combat
|
||||
- **Attack** enemies with equipped weapons
|
||||
- **Use Items** during battle (healing, buffs)
|
||||
- **Flee** when outmatched (success based on Agility)
|
||||
- **PvP** - Challenge other players in combat
|
||||
|
||||
### Danger Levels
|
||||
```json
|
||||
"location_id": {
|
||||
"danger_level": 2, // 0-4 scale
|
||||
"encounter_rate": 0.2, // 20% chance per movement
|
||||
"wandering_chance": 0.35 // 35% chance for random encounter while idle
|
||||
}
|
||||
```
|
||||
|
||||
### Spawn Tables
|
||||
```json
|
||||
"location_id": [
|
||||
{
|
||||
"npc_id": "raider_scout",
|
||||
"weight": 50 // Weighted random spawn (higher = more common)
|
||||
},
|
||||
{
|
||||
"npc_id": "infected_human",
|
||||
"weight": 30
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Available NPCs:**
|
||||
- `feral_dog` - Wild, hungry canine (Tier 1)
|
||||
- `mutant_rat` - Radiation-mutated rodent (Tier 1)
|
||||
- `raider_scout` - Hostile human raider (Tier 2)
|
||||
- `scavenger` - Aggressive survivor (Tier 2)
|
||||
- `infected_human` - Virus-infected zombie-like human (Tier 3)
|
||||
### Character Progression
|
||||
- **4 Core Stats**: Strength, Agility, Endurance, Intellect
|
||||
- **Equipment**: Weapons, armor, backpacks
|
||||
- **Stat Points**: Earn 1 per level to customize your build
|
||||
|
||||
---
|
||||
|
||||
## 🎒 `items.json` Structure
|
||||
## 🛠️ Technical Stack
|
||||
|
||||
Defines all items, equipment, weapons, consumables, and crafting materials.
|
||||
### Frontend (PWA)
|
||||
- **Framework**: React 18 + TypeScript
|
||||
- **Build Tool**: Vite
|
||||
- **State Management**: Zustand
|
||||
- **Real-time**: WebSocket connections
|
||||
- **Styling**: Custom CSS with dark theme
|
||||
|
||||
### Item Categories (Types)
|
||||
- `resource` - Raw materials for crafting
|
||||
- `consumable` - Food, medicine, usable items
|
||||
- `weapon` - Melee and ranged weapons
|
||||
- `backpack` - Inventory capacity upgrades
|
||||
- `armor` - Protective equipment
|
||||
- `tool` - Utility items (flashlight, etc.)
|
||||
- `quest` - Story/quest items
|
||||
### Backend (API)
|
||||
- **Framework**: FastAPI (Python)
|
||||
- **Database**: SQLite (development) / PostgreSQL (production)
|
||||
- **Cache**: Redis for real-time state
|
||||
- **Auth**: JWT tokens
|
||||
|
||||
### Basic Item Structure
|
||||
```json
|
||||
"item_id": {
|
||||
"name": {
|
||||
"en": "Item Name",
|
||||
"es": "Spanish Name"
|
||||
},
|
||||
"description": {
|
||||
"en": "Description text",
|
||||
"es": "Spanish description"
|
||||
},
|
||||
"type": "resource",
|
||||
"weight": 0.5, // Kilograms
|
||||
"volume": 0.2, // Liters
|
||||
"emoji": "⚙️",
|
||||
"image_path": "images/items/scrap_metal.webp"
|
||||
}
|
||||
```
|
||||
|
||||
### Consumable Items
|
||||
```json
|
||||
"item_id": {
|
||||
...basic fields...,
|
||||
"type": "consumable",
|
||||
"hp_restore": 20, // Health restored
|
||||
"stamina_restore": 10, // Stamina restored
|
||||
"treats": "Bleeding" // Status effect cured (optional)
|
||||
}
|
||||
```
|
||||
|
||||
### Weapon/Equipment Items
|
||||
```json
|
||||
"item_id": {
|
||||
...basic fields...,
|
||||
"type": "weapon",
|
||||
"equippable": true,
|
||||
"slot": "weapon", // Equipment slot: weapon, backpack, armor, head, tool
|
||||
"durability": 100, // Max durability
|
||||
"tier": 2, // 1-3 quality tier
|
||||
"encumbrance": 2, // Stamina penalty when equipped
|
||||
"stats": {
|
||||
"damage_min": 5,
|
||||
"damage_max": 10,
|
||||
"weight_capacity": 20, // For backpacks
|
||||
"volume_capacity": 20,
|
||||
"defense": 5 // For armor
|
||||
},
|
||||
"weapon_effects": { // Status effects inflicted (optional)
|
||||
"bleeding": {
|
||||
"chance": 0.15, // 15% chance on hit
|
||||
"damage": 2, // Damage per turn
|
||||
"duration": 3 // Turns
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Craftable Items
|
||||
```json
|
||||
"item_id": {
|
||||
...other fields...,
|
||||
"craftable": true,
|
||||
"craft_level": 2, // Required crafting level
|
||||
"craft_materials": [
|
||||
{
|
||||
"item_id": "scrap_metal",
|
||||
"quantity": 3
|
||||
}
|
||||
],
|
||||
"craft_tools": [ // Tools consumed during crafting
|
||||
{
|
||||
"item_id": "hammer",
|
||||
"durability_cost": 3 // Durability consumed
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Repairable Items
|
||||
```json
|
||||
"item_id": {
|
||||
...other fields...,
|
||||
"repairable": true,
|
||||
"repair_materials": [
|
||||
{
|
||||
"item_id": "scrap_metal",
|
||||
"quantity": 2
|
||||
}
|
||||
],
|
||||
"repair_tools": [
|
||||
{
|
||||
"item_id": "hammer",
|
||||
"durability_cost": 2
|
||||
}
|
||||
],
|
||||
"repair_percentage": 30 // % of max durability restored
|
||||
}
|
||||
```
|
||||
|
||||
### Uncraftable Items (Disassembly)
|
||||
```json
|
||||
"item_id": {
|
||||
...other fields...,
|
||||
"uncraftable": true,
|
||||
"uncraft_yield": [ // Materials returned
|
||||
{
|
||||
"item_id": "scrap_metal",
|
||||
"quantity": 2
|
||||
}
|
||||
],
|
||||
"uncraft_loss_chance": 0.25, // 25% chance to lose materials
|
||||
"uncraft_tools": [
|
||||
{
|
||||
"item_id": "hammer",
|
||||
"durability_cost": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Item Examples:**
|
||||
- **Resources:** `scrap_metal`, `cloth_scraps`, `wood_planks`, `bone`, `raw_meat`
|
||||
- **Consumables:** `canned_food`, `water_bottle`, `bandage`, `antibiotics`, `rad_pills`
|
||||
- **Weapons:** `rusty_knife`, `knife`, `tire_iron`, `makeshift_spear`, `reinforced_bat`
|
||||
- **Backpacks:** `tattered_rucksack`, `hiking_backpack`
|
||||
- **Tools:** `flashlight`, `hammer`
|
||||
### Desktop (Electron)
|
||||
- **Framework**: Electron 28
|
||||
- **Steam SDK**: steamworks.js integration
|
||||
- **Builds**: Windows (NSIS, Portable), Linux (AppImage, DEB), macOS
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ `locations.json` Structure
|
||||
## 📊 Asset Summary
|
||||
|
||||
Defines the game world, all locations, coordinates, and interactable objects.
|
||||
|
||||
### Location Definition
|
||||
```json
|
||||
{
|
||||
"id": "location_id",
|
||||
"name": {
|
||||
"en": "🏚️ Location Name",
|
||||
"es": "Spanish Name"
|
||||
},
|
||||
"description": {
|
||||
"en": "Atmospheric description of the location...",
|
||||
"es": "Spanish description"
|
||||
},
|
||||
"image_path": "images/locations/location.webp",
|
||||
"x": 0, // Grid X coordinate
|
||||
"y": 2, // Grid Y coordinate
|
||||
"tags": [ // Optional tags
|
||||
"workbench", // Has crafting bench
|
||||
"repair_station", // Can repair items
|
||||
"safe_zone" // No random encounters
|
||||
],
|
||||
"interactables": { ... } // Interactable objects at this location
|
||||
}
|
||||
```
|
||||
|
||||
### Interactable Object Instance
|
||||
```json
|
||||
"unique_interactable_id": {
|
||||
"template_id": "dumpster", // References interactables.json
|
||||
"outcomes": {
|
||||
"action_id": {
|
||||
"stamina_cost": 2,
|
||||
"success_rate": 0.5, // 50% base success chance
|
||||
"crit_success_chance": 0.1, // 10% chance for critical success
|
||||
"crit_failure_chance": 0.1, // 10% chance for critical failure
|
||||
"rewards": {
|
||||
"damage": 0, // Damage on normal failure
|
||||
"crit_damage": 8, // Damage on critical failure
|
||||
"items": [ // Items on normal success
|
||||
{
|
||||
"item_id": "plastic_bottles",
|
||||
"quantity": 3,
|
||||
"chance": 1.0 // 100% drop rate
|
||||
}
|
||||
],
|
||||
"crit_items": [ // Items on critical success
|
||||
{
|
||||
"item_id": "rare_item",
|
||||
"quantity": 1,
|
||||
"chance": 0.5
|
||||
}
|
||||
]
|
||||
},
|
||||
"text": { // Locale-specific text responses
|
||||
"success": {
|
||||
"en": "You find something useful!",
|
||||
"es": "¡Encuentras algo útil!"
|
||||
},
|
||||
"failure": {
|
||||
"en": "Nothing here.",
|
||||
"es": "Nada aquí."
|
||||
},
|
||||
"crit_success": { ... },
|
||||
"crit_failure": { ... }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Available Locations:**
|
||||
- `start_point` - Ruined Downtown Core (0, 0) - Starting location
|
||||
- `gas_station` - Abandoned Gas Station (0, 2) - Has workbench
|
||||
- `residential` - Residential Street (3, 0)
|
||||
- `clinic` - Old Clinic (2, 3) - Medical supplies
|
||||
- `plaza` - Shopping Plaza (-2.5, 0)
|
||||
- `park` - Suburban Park (-1, -2)
|
||||
- `overpass` - Highway Overpass (1.0, 4.5)
|
||||
- `warehouse` - Warehouse District
|
||||
- `office_building` - Office Tower
|
||||
- `subway` - Subway Station
|
||||
| Category | Count | Size |
|
||||
|----------|-------|------|
|
||||
| Location Images | 14 | - |
|
||||
| Item Images | 40 | - |
|
||||
| NPC Images | 5 | - |
|
||||
| Interactable Images | 8 | - |
|
||||
| Icon Sets | 1 | - |
|
||||
| **Total Images** | **134 files** | **~79 MB** |
|
||||
| Sound Effects | 0 | 0 |
|
||||
| Music | 0 | 0 |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 `interactables.json` Structure
|
||||
## 🗺️ Roadmap
|
||||
|
||||
Defines templates for interactable objects that can be placed in locations.
|
||||
### In Progress
|
||||
- [ ] Sound effects and ambient music
|
||||
- [ ] Quest/mission system
|
||||
- [ ] NPC dialogue trees
|
||||
|
||||
### Interactable Template
|
||||
```json
|
||||
"template_id": {
|
||||
"id": "template_id",
|
||||
"name": {
|
||||
"en": "🗑️ Object Name",
|
||||
"es": "Spanish Name"
|
||||
},
|
||||
"description": {
|
||||
"en": "Object description",
|
||||
"es": "Spanish description"
|
||||
},
|
||||
"image_path": "images/interactables/object.webp",
|
||||
"actions": { // Available actions for this object
|
||||
"action_id": {
|
||||
"id": "action_id",
|
||||
"label": {
|
||||
"en": "🔎 Action Label",
|
||||
"es": "Spanish Label"
|
||||
},
|
||||
"stamina_cost": 2 // Base stamina cost (can be overridden in locations)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Available Interactable Templates:**
|
||||
- `rubble` - Pile of debris (Action: search)
|
||||
- `dumpster` - Trash container (Action: search_dumpster)
|
||||
- `sedan` - Abandoned car (Actions: search_glovebox, pop_trunk)
|
||||
- `house` - Abandoned house (Action: search_house)
|
||||
- `toolshed` - Tool shed (Action: search_shed)
|
||||
- `medkit` - Medical supply cabinet (Action: search_medkit)
|
||||
- `storage_box` - Storage container (Action: search)
|
||||
- `vending_machine` - Vending machine (Actions: break, search)
|
||||
### Planned Features
|
||||
- [ ] Crafting recipes expansion
|
||||
- [ ] Faction/reputation system
|
||||
- [ ] Player trading
|
||||
- [ ] Housing/storage
|
||||
- [ ] Skill tree system
|
||||
- [ ] Status effects (poison, bleeding, etc.)
|
||||
- [ ] Weather/day-night cycle
|
||||
- [ ] Achievements
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Replicating Gamedata
|
||||
|
||||
### Adding a New NPC
|
||||
|
||||
1. **Create NPC definition** in `npcs.json` under `"npcs"`:
|
||||
```json
|
||||
"my_new_npc": {
|
||||
"npc_id": "my_new_npc",
|
||||
"name": { "en": "My NPC", "es": "Mi NPC" },
|
||||
"description": { "en": "Description", "es": "Descripción" },
|
||||
"emoji": "👹",
|
||||
"hp_min": 20, "hp_max": 30,
|
||||
"damage_min": 4, "damage_max": 8,
|
||||
"defense": 1,
|
||||
"xp_reward": 15,
|
||||
"loot_table": [...],
|
||||
"corpse_loot": [...],
|
||||
"flee_chance": 0.2,
|
||||
"status_inflict_chance": 0.1,
|
||||
"image_path": "images/npcs/my_new_npc.webp",
|
||||
"death_message": "The creature falls..."
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add to spawn table** in `npcs.json` under `"spawn_tables"`:
|
||||
```json
|
||||
"location_id": [
|
||||
{ "npc_id": "my_new_npc", "weight": 40 }
|
||||
]
|
||||
```
|
||||
|
||||
3. **Add image** at `images/npcs/my_new_npc.webp`
|
||||
|
||||
### Adding a New Item
|
||||
|
||||
1. **Create item definition** in `items.json`:
|
||||
```json
|
||||
"my_new_item": {
|
||||
"name": { "en": "My Item", "es": "Mi Objeto" },
|
||||
"description": { "en": "Description", "es": "Descripción" },
|
||||
"type": "resource",
|
||||
"weight": 1.0,
|
||||
"volume": 0.5,
|
||||
"emoji": "🔮",
|
||||
"image_path": "images/items/my_new_item.webp"
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add to loot tables** (optional) in locations or NPCs
|
||||
|
||||
3. **Add image** at `images/items/my_new_item.webp`
|
||||
|
||||
### Adding a New Location
|
||||
|
||||
1. **Create location** in `locations.json`:
|
||||
```json
|
||||
{
|
||||
"id": "my_location",
|
||||
"name": { "en": "🏭 My Location", "es": "Mi Ubicación" },
|
||||
"description": { "en": "Description", "es": "Descripción" },
|
||||
"image_path": "images/locations/my_location.webp",
|
||||
"x": 5,
|
||||
"y": 3,
|
||||
"tags": ["workbench"],
|
||||
"interactables": {
|
||||
"my_location_box": {
|
||||
"template_id": "storage_box",
|
||||
"outcomes": {
|
||||
"search": { ...outcome definition... }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add danger level** in `npcs.json`:
|
||||
```json
|
||||
"my_location": {
|
||||
"danger_level": 2,
|
||||
"encounter_rate": 0.15,
|
||||
"wandering_chance": 0.3
|
||||
}
|
||||
```
|
||||
|
||||
3. **Add spawn table** in `npcs.json`:
|
||||
```json
|
||||
"my_location": [
|
||||
{ "npc_id": "raider_scout", "weight": 60 },
|
||||
{ "npc_id": "mutant_rat", "weight": 40 }
|
||||
]
|
||||
```
|
||||
|
||||
4. **Add image** at `images/locations/my_location.webp`
|
||||
|
||||
### Adding a New Interactable Template
|
||||
|
||||
1. **Create template** in `interactables.json`:
|
||||
```json
|
||||
"my_interactable": {
|
||||
"id": "my_interactable",
|
||||
"name": { "en": "🎰 My Object", "es": "Mi Objeto" },
|
||||
"description": { "en": "Description", "es": "Descripción" },
|
||||
"image_path": "images/interactables/my_object.webp",
|
||||
"actions": {
|
||||
"my_action": {
|
||||
"id": "my_action",
|
||||
"label": { "en": "🔨 Do Action", "es": "Hacer Acción" },
|
||||
"stamina_cost": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Use in locations** in `locations.json` interactables
|
||||
|
||||
3. **Add image** at `images/interactables/my_object.webp`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Key Game Mechanics
|
||||
|
||||
### Stamina System
|
||||
- Base stamina pool (increases with Stamina stat)
|
||||
- Regenerates passively over time
|
||||
- Consumed by: Movement, Combat Actions, Interactions, Crafting
|
||||
- Encumbrance from equipment increases stamina costs
|
||||
|
||||
### Combat Flow
|
||||
1. Player or NPC initiates combat
|
||||
2. Turn-based with initiative system
|
||||
3. NPCs show **intent preview** (next planned action)
|
||||
4. Player chooses: Attack, Defend, Use Item, Flee
|
||||
5. Status effects tick each turn
|
||||
6. Combat ends on death or successful flee
|
||||
|
||||
### Loot System
|
||||
- **Immediate drops** from loot_table (on death)
|
||||
- **Corpse harvesting** from corpse_loot (requires tools)
|
||||
- **Interactable loot** with success/failure mechanics
|
||||
- **Respawn timers** for interactables
|
||||
|
||||
### Crafting Requirements
|
||||
- Sufficient materials in inventory
|
||||
- Required tools with durability
|
||||
- Crafting level unlocked
|
||||
- Optional: Workbench location tag
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Documentation
|
||||
|
||||
- **[CLAUDE.md](./CLAUDE.md)** - Project structure and development commands
|
||||
- **[QUICK_REFERENCE.md](./QUICK_REFERENCE.md)** - API endpoints and architecture
|
||||
- **[docker-compose.yml](./docker-compose.yml)** - Infrastructure setup
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
## 🚀 Running the Game
|
||||
|
||||
### Web/PWA (Docker)
|
||||
```bash
|
||||
# Start the game
|
||||
docker compose up -d
|
||||
|
||||
# View API logs
|
||||
docker compose logs -f echoes_of_the_ashes_api
|
||||
|
||||
# Rebuild after changes
|
||||
docker compose build && docker compose up -d
|
||||
docker compose up echoes_of_the_ashes_pwa echoes_of_the_ashes_api
|
||||
```
|
||||
|
||||
Game runs at: `http://localhost` (PWA) and `http://localhost/api` (API)
|
||||
### Electron Development
|
||||
```bash
|
||||
cd pwa
|
||||
npm install
|
||||
npm run electron:dev
|
||||
```
|
||||
|
||||
### Build Electron Apps
|
||||
```bash
|
||||
npm run electron:build:win # Windows
|
||||
npm run electron:build:linux # Linux
|
||||
npm run electron:build:mac # macOS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
## 📝 Additional Documentation
|
||||
|
||||
All rights reserved. Post-apocalyptic survival simulation for educational purposes.
|
||||
- [Game Mechanics](docs/game/MECHANICS.md) - Detailed gameplay systems
|
||||
- [API Documentation](docs/api/) - Backend endpoints reference
|
||||
- [Development Guide](docs/development/) - Contributing and architecture
|
||||
- [Map Editor](web-map/README.md) - World building tools
|
||||
|
||||
---
|
||||
|
||||
**Version**: 1.0.0-alpha
|
||||
**Last Updated**: December 2025
|
||||
|
||||
@@ -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 { Character } from '../services/api'
|
||||
import './CharacterSelection.css'
|
||||
import { GameTooltip } from './common/GameTooltip'
|
||||
|
||||
function CharacterSelection() {
|
||||
const { characters, account, selectCharacter, deleteCharacter, logout } = useAuth()
|
||||
@@ -135,11 +136,21 @@ function CharacterCard({
|
||||
<span className="stat">Level {character.level}</span>
|
||||
<span className="stat">HP: {character.hp}/{character.max_hp}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="character-attributes">
|
||||
<span title="Strength">💪 {character.strength}</span>
|
||||
<span title="Agility">⚡ {character.agility}</span>
|
||||
<span title="Endurance">🛡️ {character.endurance}</span>
|
||||
<span title="Intellect">🧠 {character.intellect}</span>
|
||||
<GameTooltip content="Strength">
|
||||
<span className="stat-icon">💪 {character.strength}</span>
|
||||
</GameTooltip>
|
||||
<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>
|
||||
<p className="character-meta">
|
||||
Last played: {formatDate(character.last_played_at)}
|
||||
|
||||
@@ -7,9 +7,10 @@ html {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #eee;
|
||||
background: var(--game-bg-app);
|
||||
color: var(--game-text-primary);
|
||||
position: relative;
|
||||
font-family: var(--game-font-main);
|
||||
}
|
||||
|
||||
/* Death Overlay */
|
||||
@@ -95,23 +96,23 @@ html {
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
height: 60px;
|
||||
background-color: rgba(20, 20, 25, 0.95);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background-color: var(--game-bg-panel);
|
||||
border-bottom: 1px solid var(--game-border-color);
|
||||
backdrop-filter: blur(10px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: var(--game-shadow-card);
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(45deg, #ff6b6b, #ffa502);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--game-text-highlight);
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
text-shadow: 0 0 10px rgba(234, 113, 66, 0.3);
|
||||
}
|
||||
|
||||
/* Player Count Badge */
|
||||
@@ -172,28 +173,28 @@ html {
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--game-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
transition: all 0.2s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
border-color: rgba(107, 185, 240, 0.5);
|
||||
transform: translateY(-2px);
|
||||
color: var(--game-text-primary);
|
||||
text-shadow: 0 0 8px rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: rgba(107, 185, 240, 0.2);
|
||||
border-color: #6bb9f0;
|
||||
color: #6bb9f0;
|
||||
color: var(--game-color-primary);
|
||||
border-bottom: 2px solid var(--game-color-primary);
|
||||
background: linear-gradient(to top, rgba(234, 113, 66, 0.1), transparent);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
@@ -231,9 +232,10 @@ html {
|
||||
.game-stats-bar {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
padding: 1rem 2rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 0.8rem 2rem;
|
||||
background: var(--game-bg-dark);
|
||||
border-bottom: 1px solid var(--game-border-color);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.stat-bar-container {
|
||||
@@ -263,28 +265,27 @@ html {
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: var(--game-radius-sm);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--game-border-color);
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
transition: width 0.5s ease;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill.health {
|
||||
background: linear-gradient(90deg, #dc3545 0%, #ff6b6b 100%);
|
||||
box-shadow: 0 0 10px rgba(255, 107, 107, 0.5);
|
||||
background: var(--game-gradient-health);
|
||||
box-shadow: 0 0 10px rgba(196, 92, 92, 0.3);
|
||||
}
|
||||
|
||||
.progress-fill.stamina {
|
||||
background: linear-gradient(90deg, #ffc107 0%, #ffeb3b 100%);
|
||||
box-shadow: 0 0 10px rgba(255, 235, 59, 0.5);
|
||||
background: var(--game-gradient-stamina);
|
||||
box-shadow: 0 0 10px rgba(226, 180, 103, 0.3);
|
||||
}
|
||||
|
||||
/* Legacy stat styles for backwards compatibility */
|
||||
@@ -302,9 +303,10 @@ html {
|
||||
|
||||
.game-tabs {
|
||||
display: flex;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 2px solid rgba(255, 107, 107, 0.3);
|
||||
background: var(--game-bg-panel);
|
||||
border-bottom: 2px solid var(--game-border-color);
|
||||
overflow-x: auto;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
@@ -312,22 +314,25 @@ html {
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #aaa;
|
||||
color: var(--game-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--game-text-primary);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: rgba(255, 107, 107, 0.2);
|
||||
color: #ff6b6b;
|
||||
border-bottom: 3px solid #ff6b6b;
|
||||
background: linear-gradient(to top, rgba(234, 113, 66, 0.1), transparent);
|
||||
color: var(--game-color-primary);
|
||||
border-bottom: 3px solid var(--game-color-primary);
|
||||
}
|
||||
|
||||
.game-main {
|
||||
@@ -385,70 +390,76 @@ html {
|
||||
}
|
||||
|
||||
.location-info {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: var(--game-bg-panel);
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--game-radius-md);
|
||||
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 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #ff6b6b;
|
||||
color: var(--game-text-highlight);
|
||||
font-size: 1.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.danger-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1.2rem;
|
||||
border-radius: 24px;
|
||||
font-size: 1rem;
|
||||
padding: 0.3rem 0.8rem;
|
||||
border-radius: var(--game-radius-sm);
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.danger-safe {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
border: 2px solid #4caf50;
|
||||
background: rgba(139, 179, 128, 0.15);
|
||||
color: var(--game-danger-safe);
|
||||
border-color: var(--game-danger-safe);
|
||||
}
|
||||
|
||||
.danger-1 {
|
||||
background: rgba(255, 193, 7, 0.2);
|
||||
color: #ffc107;
|
||||
border: 2px solid #ffc107;
|
||||
background: rgba(226, 180, 103, 0.15);
|
||||
color: var(--game-danger-low);
|
||||
border-color: var(--game-danger-low);
|
||||
}
|
||||
|
||||
.danger-2 {
|
||||
background: rgba(255, 152, 0, 0.2);
|
||||
color: #ff9800;
|
||||
border: 2px solid #ff9800;
|
||||
background: rgba(234, 113, 66, 0.15);
|
||||
color: var(--game-danger-med);
|
||||
border-color: var(--game-danger-med);
|
||||
}
|
||||
|
||||
.danger-3 {
|
||||
background: rgba(255, 87, 34, 0.2);
|
||||
color: #ff5722;
|
||||
border: 2px solid #ff5722;
|
||||
background: rgba(196, 92, 92, 0.15);
|
||||
color: var(--game-danger-high);
|
||||
border-color: var(--game-danger-high);
|
||||
}
|
||||
|
||||
.danger-4 {
|
||||
background: rgba(244, 67, 54, 0.2);
|
||||
color: #f44336;
|
||||
border: 2px solid #f44336;
|
||||
background: rgba(196, 92, 92, 0.25);
|
||||
color: var(--game-danger-high);
|
||||
border-color: var(--game-danger-high);
|
||||
box-shadow: 0 0 8px rgba(196, 92, 92, 0.3);
|
||||
}
|
||||
|
||||
.danger-5 {
|
||||
background: rgba(156, 39, 176, 0.2);
|
||||
color: #9c27b0;
|
||||
border: 2px solid #9c27b0;
|
||||
background: rgba(163, 62, 62, 0.25);
|
||||
color: var(--game-danger-extreme);
|
||||
border-color: var(--game-danger-extreme);
|
||||
box-shadow: 0 0 12px rgba(163, 62, 62, 0.4);
|
||||
}
|
||||
|
||||
.location-tags {
|
||||
@@ -721,16 +732,19 @@ html {
|
||||
}
|
||||
|
||||
.movement-controls {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: var(--game-bg-panel);
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(255, 107, 107, 0.3);
|
||||
border-radius: var(--game-radius-md);
|
||||
border: 1px solid var(--game-border-color);
|
||||
box-shadow: var(--game-shadow-card);
|
||||
}
|
||||
|
||||
.movement-controls h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #ff6b6b;
|
||||
color: var(--game-text-highlight);
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* 8-Direction Compass Grid */
|
||||
@@ -746,18 +760,18 @@ html {
|
||||
.compass-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 2px solid rgba(255, 107, 107, 0.3);
|
||||
background: linear-gradient(135deg, rgba(255, 107, 107, 0.2) 0%, rgba(255, 107, 107, 0.3) 100%);
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--game-border-color);
|
||||
background: linear-gradient(135deg, rgba(80, 80, 90, 0.3) 0%, rgba(40, 40, 50, 0.5) 100%);
|
||||
color: var(--game-text-primary);
|
||||
border-radius: var(--game-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--game-shadow-card);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -769,7 +783,7 @@ html {
|
||||
left: 0;
|
||||
right: 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;
|
||||
}
|
||||
|
||||
@@ -783,7 +797,7 @@ html {
|
||||
.compass-cost {
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: #ffc107;
|
||||
color: var(--game-color-warning);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
display: block;
|
||||
line-height: 1;
|
||||
@@ -791,10 +805,10 @@ html {
|
||||
}
|
||||
|
||||
.compass-btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, rgba(255, 107, 107, 0.4) 0%, rgba(255, 107, 107, 0.5) 100%);
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.4);
|
||||
border-color: rgba(255, 107, 107, 0.6);
|
||||
background: linear-gradient(135deg, rgba(234, 113, 66, 0.2) 0%, rgba(234, 113, 66, 0.3) 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
border-color: var(--game-color-primary);
|
||||
}
|
||||
|
||||
.compass-btn:active:not(:disabled) {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import LanguageSelector from './LanguageSelector'
|
||||
import './Game.css'
|
||||
|
||||
import { GameTooltip } from './common/GameTooltip'
|
||||
|
||||
interface GameHeaderProps {
|
||||
className?: string
|
||||
}
|
||||
@@ -77,10 +79,12 @@ export default function GameHeader({ className = '' }: GameHeaderProps) {
|
||||
</nav>
|
||||
<div className="user-info">
|
||||
<LanguageSelector />
|
||||
<div className="player-count-badge" title={t('game.onlineCount', { count: playerCount })}>
|
||||
<span className="status-dot"></span>
|
||||
<span className="count-text">{t('game.onlineCount', { count: playerCount })}</span>
|
||||
</div>
|
||||
<GameTooltip content={t('game.onlineCount', { count: playerCount })}>
|
||||
<div className="player-count-badge">
|
||||
<span className="status-dot"></span>
|
||||
<span className="count-text">{t('game.onlineCount', { count: playerCount })}</span>
|
||||
</div>
|
||||
</GameTooltip>
|
||||
<button
|
||||
onClick={() => navigate(`/profile/${currentCharacter?.id}`)}
|
||||
className={`username-link ${isOnOwnProfile ? 'active' : ''}`}
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
max-width: 600px;
|
||||
margin: 4rem auto;
|
||||
text-align: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
background: var(--game-bg-panel);
|
||||
padding: 3rem;
|
||||
border-radius: 12px;
|
||||
border: 2px solid rgba(107, 185, 240, 0.3);
|
||||
border-radius: var(--game-radius-md);
|
||||
border: 2px solid var(--game-border-color);
|
||||
box-shadow: var(--game-shadow-card);
|
||||
}
|
||||
|
||||
.game-main .profile-error button {
|
||||
@@ -36,14 +37,15 @@
|
||||
}
|
||||
|
||||
.profile-info-card {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 2px solid rgba(107, 185, 240, 0.3);
|
||||
border-radius: 12px;
|
||||
background: var(--game-bg-panel);
|
||||
border: 2px solid var(--game-border-color);
|
||||
border-radius: var(--game-radius-md);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
height: fit-content;
|
||||
position: sticky;
|
||||
top: 2rem;
|
||||
box-shadow: var(--game-shadow-card);
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
@@ -65,12 +67,12 @@
|
||||
.profile-name {
|
||||
font-size: 1.8rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #6bb9f0;
|
||||
color: var(--game-text-highlight);
|
||||
}
|
||||
|
||||
.profile-username {
|
||||
font-size: 1rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: var(--game-text-secondary);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
@@ -121,17 +123,18 @@
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 2px solid rgba(107, 185, 240, 0.3);
|
||||
border-radius: 12px;
|
||||
background: var(--game-bg-panel);
|
||||
border: 2px solid var(--game-border-color);
|
||||
border-radius: var(--game-radius-md);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--game-shadow-card);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.3rem;
|
||||
margin: 0 0 1rem 0;
|
||||
color: #6bb9f0;
|
||||
border-bottom: 2px solid rgba(107, 185, 240, 0.3);
|
||||
color: var(--game-color-primary);
|
||||
border-bottom: 2px solid var(--game-border-color);
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -148,7 +151,7 @@
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
color: var(--game-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
@@ -156,7 +159,7 @@
|
||||
.stat-value {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: #fff;
|
||||
color: var(--game-text-primary);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
@@ -182,6 +185,7 @@
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
|
||||
/* Remove tab bar spacing for profile page */
|
||||
.game-main {
|
||||
margin-bottom: 0 !important;
|
||||
@@ -190,7 +194,8 @@
|
||||
.game-main .profile-container {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 1rem;
|
||||
padding-top: 4rem; /* Space for hamburger button */
|
||||
padding-top: 4rem;
|
||||
/* Space for hamburger button */
|
||||
max-width: 100vw;
|
||||
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 */
|
||||
.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 {
|
||||
background: linear-gradient(90deg, #9c27b0, #7b1fa2);
|
||||
background: var(--game-gradient-stamina);
|
||||
/* Using stamina/yellow-gold for volume */
|
||||
}
|
||||
|
||||
/* Inventory Tab - Full View */
|
||||
@@ -34,6 +36,7 @@
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* --- Redesigned Inventory Modal --- */
|
||||
/* --- Redesigned Inventory Modal --- */
|
||||
.inventory-modal-redesign {
|
||||
display: flex;
|
||||
@@ -41,14 +44,13 @@
|
||||
height: 85vh;
|
||||
width: 95vw;
|
||||
max-width: 1400px;
|
||||
/* Match Workbench width */
|
||||
background: linear-gradient(135deg, #1e2a38 0%, #121820 100%);
|
||||
border: 1px solid #3a4b5c;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.8);
|
||||
background: var(--game-bg-modal);
|
||||
border: 1px solid var(--game-border-color);
|
||||
border-radius: var(--game-radius-lg);
|
||||
box-shadow: var(--game-shadow-modal);
|
||||
overflow: hidden;
|
||||
color: #e0e6ed;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
color: var(--game-text-primary);
|
||||
font-family: var(--game-font-main);
|
||||
}
|
||||
|
||||
/* Top Bar */
|
||||
@@ -57,8 +59,8 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-bottom: 1px solid #3a4b5c;
|
||||
background: var(--game-bg-panel);
|
||||
border-bottom: 1px solid var(--game-border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -94,23 +96,24 @@
|
||||
|
||||
.metric-bar {
|
||||
height: 8px;
|
||||
background: #2d3748;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: var(--game-radius-sm);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--game-border-color);
|
||||
}
|
||||
|
||||
.metric-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--game-radius-sm);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.metric-fill.weight {
|
||||
background: linear-gradient(90deg, #48bb78, #38a169);
|
||||
background: var(--game-gradient-health);
|
||||
}
|
||||
|
||||
.metric-fill.volume {
|
||||
background: linear-gradient(90deg, #4299e1, #3182ce);
|
||||
background: var(--game-gradient-stamina);
|
||||
}
|
||||
|
||||
.inventory-backpack-info {
|
||||
@@ -168,11 +171,12 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar Filters */
|
||||
/* Sidebar Filters */
|
||||
.inventory-sidebar-filters {
|
||||
width: 220px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-right: 1px solid #3a4b5c;
|
||||
background: var(--game-bg-panel);
|
||||
border-right: 1px solid var(--game-border-color);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -231,10 +235,11 @@
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid #3a4b5c;
|
||||
border-radius: 8px;
|
||||
background: var(--game-bg-input);
|
||||
border: 1px solid var(--game-border-color);
|
||||
border-radius: var(--game-radius-md);
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--game-text-primary);
|
||||
}
|
||||
|
||||
.inventory-search-bar input {
|
||||
@@ -255,19 +260,20 @@
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Compact Item Card */
|
||||
/* Compact Item Card */
|
||||
.inventory-item-card.compact {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: rgba(26, 32, 44, 0.8);
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--game-bg-card);
|
||||
border: 1px solid var(--game-border-color);
|
||||
border-radius: var(--game-radius-md);
|
||||
padding: 0.75rem;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 0.75rem;
|
||||
/* Add separation between cards */
|
||||
box-shadow: var(--game-shadow-sm);
|
||||
}
|
||||
|
||||
.inventory-item-card.compact:hover {
|
||||
@@ -311,13 +317,14 @@
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
right: -5px;
|
||||
background: #2d3748;
|
||||
border: 1px solid #4a5568;
|
||||
color: #fff;
|
||||
background: var(--game-bg-panel);
|
||||
border: 1px solid var(--game-border-color);
|
||||
color: var(--game-text-primary);
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--game-radius-sm);
|
||||
font-weight: bold;
|
||||
box-shadow: var(--game-shadow-sm);
|
||||
}
|
||||
|
||||
.item-info-section {
|
||||
@@ -705,13 +712,13 @@
|
||||
}
|
||||
|
||||
.action-btn.unequip {
|
||||
background: rgba(237, 137, 54, 0.2);
|
||||
color: #ed8936;
|
||||
border: 1px solid rgba(237, 137, 54, 0.4);
|
||||
background: rgba(234, 113, 66, 0.1);
|
||||
color: var(--game-color-primary);
|
||||
border: 1px solid var(--game-color-primary);
|
||||
}
|
||||
|
||||
.action-btn.unequip:hover {
|
||||
background: rgba(237, 137, 54, 0.3);
|
||||
background: rgba(234, 113, 66, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getAssetPath } from '../../utils/assetPath'
|
||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||
import './InventoryModal.css'
|
||||
import { EffectBadge } from './EffectBadge'
|
||||
import { GameTooltip } from '../common/GameTooltip'
|
||||
|
||||
interface InventoryModalProps {
|
||||
playerState: PlayerState
|
||||
@@ -285,19 +286,20 @@ function InventoryModal({
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`action-btn use ${isEffectActive ? 'disabled' : ''}`}
|
||||
disabled={isEffectActive}
|
||||
title={isEffectActive ? t('game.effectAlreadyActive') : ''}
|
||||
onClick={() => {
|
||||
if (!isEffectActive) {
|
||||
playSfx('/audio/sfx/use.wav')
|
||||
onUseItem(item.item_id, item.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('game.use')}
|
||||
</button>
|
||||
<GameTooltip content={isEffectActive ? t('game.effectAlreadyActive') : ''}>
|
||||
<button
|
||||
className={`action-btn use ${isEffectActive ? 'disabled' : ''}`}
|
||||
disabled={isEffectActive}
|
||||
onClick={() => {
|
||||
if (!isEffectActive) {
|
||||
playSfx('/audio/sfx/use.wav')
|
||||
onUseItem(item.item_id, item.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('game.use')}
|
||||
</button>
|
||||
</GameTooltip>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Location, PlayerState, CombatState, Profile, WorkbenchTab } from '
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAudio } from '../../contexts/AudioContext'
|
||||
import Workbench from './Workbench'
|
||||
import { GameTooltip } from '../common/GameTooltip'
|
||||
import { getAssetPath } from '../../utils/assetPath'
|
||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||
|
||||
@@ -91,12 +92,16 @@ function LocationView({
|
||||
<h2 className="centered-heading">
|
||||
{getTranslatedText(location.name)}
|
||||
{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 && (
|
||||
<span className={`danger-badge danger-${location.danger_level}`} title={`Danger Level: ${location.danger_level}`}>
|
||||
⚠️ {location.danger_level}
|
||||
</span>
|
||||
<GameTooltip content={`Danger Level: ${location.danger_level}`}>
|
||||
<span className={`danger-badge danger-${location.danger_level}`}>
|
||||
⚠️ {location.danger_level}
|
||||
</span>
|
||||
</GameTooltip>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
@@ -110,24 +115,24 @@ function LocationView({
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
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}
|
||||
style={isClickable ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
{tag === 'workbench' && t('tags.workbench')}
|
||||
{tag === 'repair_station' && t('tags.repairStation')}
|
||||
{tag === 'safe_zone' && t('tags.safeZone')}
|
||||
{tag === 'shop' && t('tags.shop')}
|
||||
{tag === 'shelter' && t('tags.shelter')}
|
||||
{tag === 'medical' && t('tags.medical')}
|
||||
{tag === 'storage' && t('tags.storage')}
|
||||
{tag === 'water_source' && t('tags.water')}
|
||||
{tag === 'food_source' && t('tags.food')}
|
||||
{tag !== 'workbench' && tag !== 'repair_station' && tag !== 'safe_zone' && tag !== 'shop' && tag !== 'shelter' && tag !== 'medical' && tag !== 'storage' && tag !== 'water_source' && tag !== 'food_source' && `🏷️ ${tag}`}
|
||||
</span>
|
||||
<GameTooltip key={i} content={isClickable ? `Click to ${tag === 'workbench' ? 'craft items' : 'repair items'}` : `This location has: ${tag}`}>
|
||||
<span
|
||||
className={`location-tag tag-${tag} ${isClickable ? 'clickable' : ''}`}
|
||||
onClick={isClickable ? handleClick : undefined}
|
||||
style={isClickable ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
{tag === 'workbench' && t('tags.workbench')}
|
||||
{tag === 'repair_station' && t('tags.repairStation')}
|
||||
{tag === 'safe_zone' && t('tags.safeZone')}
|
||||
{tag === 'shop' && t('tags.shop')}
|
||||
{tag === 'shelter' && t('tags.shelter')}
|
||||
{tag === 'medical' && t('tags.medical')}
|
||||
{tag === 'storage' && t('tags.storage')}
|
||||
{tag === 'water_source' && t('tags.water')}
|
||||
{tag === 'food_source' && t('tags.food')}
|
||||
{tag !== 'workbench' && tag !== 'repair_station' && tag !== 'safe_zone' && tag !== 'shop' && tag !== 'shelter' && tag !== 'medical' && tag !== 'storage' && tag !== 'water_source' && tag !== 'food_source' && `🏷️ ${tag}`}
|
||||
</span>
|
||||
</GameTooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -257,14 +262,15 @@ function LocationView({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="corpse-item-loot-btn"
|
||||
onClick={() => onLootCorpseItem(String(corpse.id), item.index)}
|
||||
disabled={!item.can_loot}
|
||||
title={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'}
|
||||
>
|
||||
{item.can_loot ? `📦 ${t('common.loot')}` : '🔒'}
|
||||
</button>
|
||||
<GameTooltip content={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'}>
|
||||
<button
|
||||
className="corpse-item-loot-btn"
|
||||
onClick={() => onLootCorpseItem(String(corpse.id), item.index)}
|
||||
disabled={!item.can_loot}
|
||||
>
|
||||
{item.can_loot ? `📦 ${t('common.loot')}` : '🔒'}
|
||||
</button>
|
||||
</GameTooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -328,39 +334,42 @@ function LocationView({
|
||||
{item.quantity > 1 && <div className="entity-quantity">×{item.quantity}</div>}
|
||||
</div>
|
||||
<div className="item-info-btn-container">
|
||||
<button className="entity-action-btn info" title="Item Info">{t('common.info')}</button>
|
||||
<div className="item-info-tooltip">
|
||||
{item.description && <div className="item-tooltip-desc">{getTranslatedText(item.description)}</div>}
|
||||
{item.weight !== undefined && item.weight > 0 && (
|
||||
<div className="item-tooltip-stat">
|
||||
⚖️ {t('stats.weight')}: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`}
|
||||
</div>
|
||||
)}
|
||||
{item.volume !== undefined && item.volume > 0 && (
|
||||
<div className="item-tooltip-stat">
|
||||
📦 {t('stats.volume')}: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`}
|
||||
</div>
|
||||
)}
|
||||
{item.hp_restore && item.hp_restore > 0 && (
|
||||
<div className="item-tooltip-stat">❤️ {t('stats.hpRestore')}: +{item.hp_restore}</div>
|
||||
)}
|
||||
{item.stamina_restore && item.stamina_restore > 0 && (
|
||||
<div className="item-tooltip-stat">⚡ {t('stats.staminaRestore')}: +{item.stamina_restore}</div>
|
||||
)}
|
||||
{item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && (
|
||||
<div className="item-tooltip-stat">
|
||||
⚔️ {t('stats.damage')}: {item.damage_min}-{item.damage_max}
|
||||
</div>
|
||||
)}
|
||||
{item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && (
|
||||
<div className="item-tooltip-stat">
|
||||
🔧 {t('stats.durability')}: {item.durability}/{item.max_durability}
|
||||
</div>
|
||||
)}
|
||||
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
|
||||
<div className="item-tooltip-stat">⭐ {t('stats.tier')}: {item.tier}</div>
|
||||
)}
|
||||
</div>
|
||||
<GameTooltip content={
|
||||
<div className="item-info-tooltip-content">
|
||||
{item.description && <div className="item-tooltip-desc">{getTranslatedText(item.description)}</div>}
|
||||
{item.weight !== undefined && item.weight > 0 && (
|
||||
<div className="item-tooltip-stat">
|
||||
⚖️ {t('stats.weight')}: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`}
|
||||
</div>
|
||||
)}
|
||||
{item.volume !== undefined && item.volume > 0 && (
|
||||
<div className="item-tooltip-stat">
|
||||
📦 {t('stats.volume')}: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`}
|
||||
</div>
|
||||
)}
|
||||
{item.hp_restore && item.hp_restore > 0 && (
|
||||
<div className="item-tooltip-stat">❤️ {t('stats.hpRestore')}: +{item.hp_restore}</div>
|
||||
)}
|
||||
{item.stamina_restore && item.stamina_restore > 0 && (
|
||||
<div className="item-tooltip-stat">⚡ {t('stats.staminaRestore')}: +{item.stamina_restore}</div>
|
||||
)}
|
||||
{item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && (
|
||||
<div className="item-tooltip-stat">
|
||||
⚔️ {t('stats.damage')}: {item.damage_min}-{item.damage_max}
|
||||
</div>
|
||||
)}
|
||||
{item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && (
|
||||
<div className="item-tooltip-stat">
|
||||
🔧 {t('stats.durability')}: {item.durability}/{item.max_durability}
|
||||
</div>
|
||||
)}
|
||||
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
|
||||
<div className="item-tooltip-stat">⭐ {t('stats.tier')}: {item.tier}</div>
|
||||
)}
|
||||
</div>
|
||||
}>
|
||||
<button className="entity-action-btn info">{t('common.info')}</button>
|
||||
</GameTooltip>
|
||||
</div>
|
||||
{item.quantity === 1 ? (
|
||||
<button
|
||||
@@ -425,13 +434,14 @@ function LocationView({
|
||||
)}
|
||||
</div>
|
||||
{player.can_pvp && (
|
||||
<button
|
||||
className="pvp-btn"
|
||||
onClick={() => onInitiatePvP(player.id)}
|
||||
title={`Attack ${player.name || player.username}`}
|
||||
>
|
||||
{t('game.attack')}
|
||||
</button>
|
||||
<GameTooltip content={`Attack ${player.name || player.username}`}>
|
||||
<button
|
||||
className="pvp-btn"
|
||||
onClick={() => onInitiatePvP(player.id)}
|
||||
>
|
||||
{t('game.attack')}
|
||||
</button>
|
||||
</GameTooltip>
|
||||
)}
|
||||
{!player.can_pvp && player.level_diff !== undefined && Math.abs(player.level_diff) > 3 && (
|
||||
<div className="pvp-disabled-reason">{t('game.levelDifferenceTooHigh')}</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getAssetPath } from '../../utils/assetPath'
|
||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||
import { GameTooltip } from '../common/GameTooltip'
|
||||
|
||||
interface MovementControlsProps {
|
||||
location: Location
|
||||
@@ -77,24 +78,29 @@ function MovementControls({
|
||||
movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) :
|
||||
combatState ? t('messages.cannotTravelCombat') :
|
||||
insufficientStamina ? t('messages.notEnoughStamina', { need: stamina, have: profile?.stamina ?? 0 }) :
|
||||
available ? `${destination}\n${t('game.distance')}: ${distance}m\n${t('game.stamina')}: ${stamina}` :
|
||||
t('messages.cannotGo', { direction: t('directions.' + direction) })
|
||||
available ? (
|
||||
<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 (
|
||||
<button
|
||||
key={direction}
|
||||
className={`compass-btn ${className} ${disabled ? 'disabled' : ''} ${combatState ? 'in-combat' : ''}`}
|
||||
onClick={() => onMove(direction)}
|
||||
disabled={disabled}
|
||||
title={tooltipText}
|
||||
>
|
||||
<span className="compass-arrow">{arrow}</span>
|
||||
{available && movementCooldown > 0 ? (
|
||||
<span className="compass-cost" title={t('messages.waitBeforeMoving', { seconds: movementCooldown })}>⏳{movementCooldown}s</span>
|
||||
) : available && (
|
||||
<span className="compass-cost">⚡{stamina}</span>
|
||||
)}
|
||||
</button>
|
||||
<GameTooltip key={direction} content={tooltipText}>
|
||||
<button
|
||||
className={`compass-btn ${className} ${disabled ? 'disabled' : ''} ${combatState ? 'in-combat' : ''}`}
|
||||
onClick={() => onMove(direction)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="compass-arrow">{arrow}</span>
|
||||
{available && movementCooldown > 0 ? (
|
||||
<span className="compass-cost">⏳{movementCooldown}s</span>
|
||||
) : available && (
|
||||
<span className="compass-cost">⚡{stamina}</span>
|
||||
)}
|
||||
</button>
|
||||
</GameTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -131,64 +137,95 @@ function MovementControls({
|
||||
{/* Special movements */}
|
||||
<div className="special-moves">
|
||||
{location.directions.includes('up') && (
|
||||
<button
|
||||
onClick={() => onMove('up')}
|
||||
className="special-btn"
|
||||
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>
|
||||
</button>
|
||||
<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
|
||||
onClick={() => onMove('up')}
|
||||
className="special-btn"
|
||||
disabled={!!combatState || movementCooldown > 0}
|
||||
>
|
||||
⬆️ {t('directions.up')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('up')}`}</span>
|
||||
</button>
|
||||
</GameTooltip>
|
||||
)}
|
||||
{location.directions.includes('down') && (
|
||||
<button
|
||||
onClick={() => onMove('down')}
|
||||
className="special-btn"
|
||||
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>
|
||||
</button>
|
||||
<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
|
||||
onClick={() => onMove('down')}
|
||||
className="special-btn"
|
||||
disabled={!!combatState || movementCooldown > 0}
|
||||
>
|
||||
⬇️ {t('directions.down')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('down')}`}</span>
|
||||
</button>
|
||||
</GameTooltip>
|
||||
)}
|
||||
{location.directions.includes('enter') && (
|
||||
<button
|
||||
onClick={() => onMove('enter')}
|
||||
className="special-btn"
|
||||
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>
|
||||
</button>
|
||||
<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
|
||||
onClick={() => onMove('enter')}
|
||||
className="special-btn"
|
||||
disabled={!!combatState || movementCooldown > 0}
|
||||
>
|
||||
🚪 {t('directions.enter')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('enter')}`}</span>
|
||||
</button>
|
||||
</GameTooltip>
|
||||
)}
|
||||
{location.directions.includes('inside') && (
|
||||
<button
|
||||
onClick={() => onMove('inside')}
|
||||
className="special-btn"
|
||||
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>
|
||||
</button>
|
||||
<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
|
||||
onClick={() => onMove('inside')}
|
||||
className="special-btn"
|
||||
disabled={!!combatState || movementCooldown > 0}
|
||||
>
|
||||
🚪 {t('directions.inside')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('inside')}`}</span>
|
||||
</button>
|
||||
</GameTooltip>
|
||||
)}
|
||||
{location.directions.includes('exit') && (
|
||||
<button
|
||||
onClick={() => onMove('exit')}
|
||||
className="special-btn"
|
||||
disabled={!!combatState || movementCooldown > 0}
|
||||
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : t('directions.exit')}
|
||||
>
|
||||
🚪 {t('directions.exit')}
|
||||
</button>
|
||||
<GameTooltip content={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : t('directions.exit')}>
|
||||
<button
|
||||
onClick={() => onMove('exit')}
|
||||
className="special-btn"
|
||||
disabled={!!combatState || movementCooldown > 0}
|
||||
>
|
||||
🚪 {t('directions.exit')}
|
||||
</button>
|
||||
</GameTooltip>
|
||||
)}
|
||||
{location.directions.includes('outside') && (
|
||||
<button
|
||||
onClick={() => onMove('outside')}
|
||||
className="special-btn"
|
||||
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>
|
||||
</button>
|
||||
<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
|
||||
onClick={() => onMove('outside')}
|
||||
className="special-btn"
|
||||
disabled={!!combatState || movementCooldown > 0}
|
||||
>
|
||||
🚪 {t('directions.outside')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('outside')}`}</span>
|
||||
</button>
|
||||
</GameTooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,28 +265,28 @@ function MovementControls({
|
||||
const insufficientStamina = profile ? profile.stamina < staminaCost : false
|
||||
|
||||
return (
|
||||
<button
|
||||
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
|
||||
? t('messages.youAreDead')
|
||||
: combatState
|
||||
? t('messages.cannotInteractInCombat')
|
||||
: insufficientStamina
|
||||
? t('messages.notEnoughStamina', { need: staminaCost, have: profile?.stamina ?? 0 })
|
||||
: cooldownRemaining > 0
|
||||
? t('messages.interactionCooldown', { seconds: cooldownRemaining })
|
||||
: getTranslatedText(action.description)
|
||||
}
|
||||
>
|
||||
{getTranslatedText(action.name)}
|
||||
<span className="stamina-cost">
|
||||
{cooldownRemaining > 0 ? `⏳${cooldownRemaining}s` : `⚡${staminaCost}`}
|
||||
</span>
|
||||
</button>
|
||||
<GameTooltip key={action.id} content={
|
||||
profile?.is_dead
|
||||
? t('messages.youAreDead')
|
||||
: combatState
|
||||
? t('messages.cannotInteractInCombat')
|
||||
: insufficientStamina
|
||||
? t('messages.notEnoughStamina', { need: staminaCost, have: profile?.stamina ?? 0 })
|
||||
: cooldownRemaining > 0
|
||||
? t('messages.interactionCooldown', { seconds: cooldownRemaining })
|
||||
: 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)}
|
||||
<span className="stamina-cost">
|
||||
{cooldownRemaining > 0 ? `⏳${cooldownRemaining}s` : `⚡${staminaCost}`}
|
||||
</span>
|
||||
</button>
|
||||
</GameTooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getAssetPath } from '../../utils/assetPath'
|
||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||
import InventoryModal from './InventoryModal'
|
||||
import { GameProgressBar } from '../common/GameProgressBar'
|
||||
import { GameTooltip } from '../common/GameTooltip'
|
||||
|
||||
interface PlayerSidebarProps {
|
||||
playerState: PlayerState
|
||||
@@ -40,106 +41,118 @@ function PlayerSidebar({
|
||||
const [showInventory, setShowInventory] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const renderEquipmentSlot = (slot: string, item: any, label: string) => (
|
||||
<div className={`equipment-slot ${item ? 'filled' : 'empty'}`} title={!item ? label : ''}>
|
||||
{item ? (
|
||||
<>
|
||||
<button className="equipment-unequip-btn" onClick={() => onUnequipItem(slot)} title={t('game.unequip')}>✕</button>
|
||||
<div className="equipment-item-content">
|
||||
{item.image_path ? (
|
||||
<img
|
||||
src={getAssetPath(item.image_path)}
|
||||
alt={getTranslatedText(item.name)}
|
||||
className="equipment-emoji"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<span className="equipment-emoji" style={{ fontSize: '2rem' }}>{item.emoji}</span>
|
||||
)}
|
||||
{item.durability !== undefined && item.durability !== null && (
|
||||
<div className="equipment-durability-bar-container" style={{ width: '90%', marginTop: 'auto', marginBottom: '4px' }}>
|
||||
<GameProgressBar
|
||||
value={item.durability}
|
||||
max={item.max_durability}
|
||||
type="durability"
|
||||
height="4px"
|
||||
showText={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
const renderEquipmentSlot = (slot: string, item: any, label: string) => {
|
||||
// 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>
|
||||
<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 && (
|
||||
)}
|
||||
{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">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
|
||||
<span>{t('stats.durability')}:</span>
|
||||
<span>{item.durability}/{item.max_durability}</span>
|
||||
{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>
|
||||
<GameProgressBar
|
||||
value={item.durability}
|
||||
max={item.max_durability}
|
||||
type="durability"
|
||||
height="6px"
|
||||
showText={false}
|
||||
/>
|
||||
)}
|
||||
{(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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
src={getAssetPath(`/images/placeholder/${slot}_placeholder.webp`)}
|
||||
alt={label}
|
||||
className="equipment-placeholder-img"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain', opacity: 0.5 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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 ? (
|
||||
<>
|
||||
<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">
|
||||
{item.image_path ? (
|
||||
<img
|
||||
src={getAssetPath(item.image_path)}
|
||||
alt={getTranslatedText(item.name)}
|
||||
className="equipment-emoji"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<span className="equipment-emoji" style={{ fontSize: '2rem' }}>{item.emoji}</span>
|
||||
)}
|
||||
{item.durability !== undefined && item.durability !== null && (
|
||||
<div className="equipment-durability-bar-container" style={{ width: '90%', marginTop: 'auto', marginBottom: '4px' }}>
|
||||
<GameProgressBar
|
||||
value={item.durability}
|
||||
max={item.max_durability}
|
||||
type="durability"
|
||||
height="4px"
|
||||
showText={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
src={getAssetPath(`/images/placeholder/${slot}_placeholder.webp`)}
|
||||
alt={label}
|
||||
className="equipment-placeholder-img"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain', opacity: 0.5 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</GameTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`right-sidebar mobile-menu-panel ${mobileMenuOpen === 'right' ? 'open' : ''}`}>
|
||||
|
||||
@@ -17,13 +17,15 @@
|
||||
width: 95vw;
|
||||
max-width: 1400px;
|
||||
height: 85vh;
|
||||
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
|
||||
border: 1px solid #4a5568;
|
||||
border-radius: 12px;
|
||||
background: var(--game-bg-modal);
|
||||
border: 1px solid var(--game-border-color);
|
||||
border-radius: var(--game-radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: var(--game-shadow-modal);
|
||||
overflow: hidden;
|
||||
color: var(--game-text-primary);
|
||||
font-family: var(--game-font-main);
|
||||
}
|
||||
|
||||
.workbench-header {
|
||||
@@ -31,14 +33,14 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 1px solid #4a5568;
|
||||
background: var(--game-bg-panel);
|
||||
border-bottom: 1px solid var(--game-border-color);
|
||||
}
|
||||
|
||||
.workbench-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #e2e8f0;
|
||||
color: var(--game-text-highlight);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -98,8 +100,8 @@
|
||||
|
||||
/* Column 1: Sidebar */
|
||||
.workbench-sidebar {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-right: 1px solid #3a4b5c;
|
||||
background: var(--game-bg-panel);
|
||||
border-right: 1px solid var(--game-border-color);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -142,9 +144,9 @@
|
||||
}
|
||||
|
||||
.workbench-sidebar .category-btn.active {
|
||||
background: rgba(66, 153, 225, 0.15);
|
||||
border-color: #4299e1;
|
||||
color: #63b3ed;
|
||||
background: rgba(234, 113, 66, 0.15);
|
||||
border-color: var(--game-color-primary);
|
||||
color: var(--game-color-primary);
|
||||
}
|
||||
|
||||
.workbench-sidebar .cat-icon {
|
||||
@@ -187,9 +189,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 6px;
|
||||
background: var(--game-bg-card);
|
||||
border: 1px solid var(--game-border-color);
|
||||
border-radius: var(--game-radius-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
gap: 0.5rem;
|
||||
@@ -201,20 +203,21 @@
|
||||
}
|
||||
|
||||
.workbench-item-card.selected {
|
||||
background: rgba(66, 153, 225, 0.1);
|
||||
border-color: #4299e1;
|
||||
background: rgba(234, 113, 66, 0.1);
|
||||
border-color: var(--game-color-primary);
|
||||
box-shadow: 0 0 0 1px var(--game-color-primary);
|
||||
}
|
||||
|
||||
.workbench-item-card.craftable {
|
||||
border-left: 3px solid #4caf50;
|
||||
border-left: 3px solid var(--game-color-success);
|
||||
}
|
||||
|
||||
.workbench-item-card.repairable {
|
||||
border-left: 3px solid #ff9800;
|
||||
border-left: 3px solid var(--game-color-warning);
|
||||
}
|
||||
|
||||
.workbench-item-card.salvageable {
|
||||
border-left: 3px solid #9c27b0;
|
||||
border-left: 3px solid var(--game-color-danger);
|
||||
}
|
||||
|
||||
.item-card-content {
|
||||
@@ -446,7 +449,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
background: var(--game-bg-panel);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
@@ -460,11 +463,11 @@
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 1.5rem auto;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--game-radius-md);
|
||||
overflow: hidden;
|
||||
border: 2px solid #4a5568;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
border: 2px solid var(--game-border-color);
|
||||
background: var(--game-bg-input);
|
||||
box-shadow: var(--game-shadow-card);
|
||||
}
|
||||
|
||||
.detail-image {
|
||||
|
||||
@@ -1,11 +1,64 @@
|
||||
:root {
|
||||
font-family: 'Saira Condensed', system-ui, sans-serif;
|
||||
/* --- Core Colors (Mature/Industrial) --- */
|
||||
--game-bg-app: #050505;
|
||||
/* Deepest black */
|
||||
--game-bg-panel: rgba(18, 18, 24, 0.98);
|
||||
/* Almost solid panels */
|
||||
--game-bg-glass: rgba(10, 10, 15, 0.9);
|
||||
/* Overlays */
|
||||
--game-bg-slot: rgba(0, 0, 0, 0.6);
|
||||
/* Item slots */
|
||||
--game-bg-slot-hover: rgba(255, 255, 255, 0.1);
|
||||
--game-bg-tooltip: rgba(15, 15, 20, 0.98);
|
||||
|
||||
/* --- Borders & Separators --- */
|
||||
--game-border-color: rgba(255, 255, 255, 0.12);
|
||||
--game-border-active: rgba(255, 255, 255, 0.4);
|
||||
--game-border-highlight: #ff6b6b;
|
||||
/* Red accent border */
|
||||
|
||||
/* --- Dimensions --- */
|
||||
--game-radius-xs: 2px;
|
||||
--game-radius-sm: 4px;
|
||||
--game-radius-md: 6px;
|
||||
|
||||
/* --- Typography --- */
|
||||
--game-font-main: 'Saira Condensed', system-ui, sans-serif;
|
||||
--game-text-primary: #e0e0e0;
|
||||
--game-text-secondary: #94a3b8;
|
||||
--game-text-highlight: #fbbf24;
|
||||
--game-text-danger: #ef4444;
|
||||
|
||||
/* --- Semantic Colors --- */
|
||||
--game-color-primary: #e11d48;
|
||||
/* Blood Red */
|
||||
--game-color-stamina: #d97706;
|
||||
/* Amber */
|
||||
--game-color-magic: #3b82f6;
|
||||
/* Blue */
|
||||
--game-color-success: #10b981;
|
||||
/* Emerald */
|
||||
--game-color-warning: #f59e0b;
|
||||
/* Amber */
|
||||
|
||||
/* --- Rarity --- */
|
||||
--rarity-common: #9ca3af;
|
||||
--rarity-uncommon: #ffffff;
|
||||
--rarity-rare: #34d399;
|
||||
--rarity-epic: #60a5fa;
|
||||
--rarity-legendary: #fbbf24;
|
||||
|
||||
/* --- Effects --- */
|
||||
--game-shadow-panel: 0 8px 32px rgba(0, 0, 0, 0.8);
|
||||
--game-shadow-tooltip: 0 4px 12px rgba(0, 0, 0, 0.8);
|
||||
--game-shadow-glow: 0 0 15px rgba(225, 29, 72, 0.3);
|
||||
|
||||
font-family: var(--game-font-main);
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #1a1a1a;
|
||||
color: var(--game-text-primary);
|
||||
background-color: var(--game-bg-app);
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
@@ -13,58 +66,105 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
/* --- Reusable Game Classes --- */
|
||||
|
||||
/* Panels */
|
||||
.game-panel {
|
||||
background: var(--game-bg-panel);
|
||||
border: 1px solid var(--game-border-color);
|
||||
box-shadow: var(--game-shadow-panel);
|
||||
border-radius: var(--game-radius-sm);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
.game-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background-color: #1a1a1a;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
/* Buttons */
|
||||
.game-btn {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--game-border-color);
|
||||
color: var(--game-text-primary);
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #2a2a2a;
|
||||
font-family: var(--game-font-main);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: var(--game-radius-xs);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
.game-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: var(--game-text-secondary);
|
||||
box-shadow: 0 0 8px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
.game-btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.game-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.game-btn-primary {
|
||||
background: rgba(225, 29, 72, 0.2);
|
||||
border-color: rgba(225, 29, 72, 0.5);
|
||||
color: #ffcccc;
|
||||
}
|
||||
|
||||
.game-btn-primary:hover {
|
||||
background: rgba(225, 29, 72, 0.3);
|
||||
border-color: var(--game-color-primary);
|
||||
box-shadow: var(--game-shadow-glow);
|
||||
}
|
||||
|
||||
.game-btn-icon {
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
/* Or keep square for industrial look */
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Slots */
|
||||
.game-slot {
|
||||
background: var(--game-bg-slot);
|
||||
border: 1px solid var(--game-border-color);
|
||||
border-radius: var(--game-radius-xs);
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.game-slot:hover {
|
||||
background: var(--game-bg-slot-hover);
|
||||
border-color: var(--game-border-active);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Twemoji styles */
|
||||
img.emoji {
|
||||
height: 1em;
|
||||
|
||||
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