From 173d6c91173ea1107c8d47ad3a6b3848f97830d7 Mon Sep 17 00:00:00 2001 From: Joan Date: Thu, 5 Feb 2026 17:53:00 +0100 Subject: [PATCH] UI Refinements Round 2: CSS fixes, Grid Layout, Sidebar Images --- COMPLETE_MIGRATION_SUCCESS.md | 331 ------ Dockerfile | 17 - Dockerfile.pwa.new | 39 - PLAYERS_TAB_SCHEMA_FIX.md | 146 --- REDIS_MONITORING.md | 180 --- REFACTORING_COMPLETE.md | 335 ------ REFACTORING_PLAN.md | 160 --- WEBSOCKET_HANDLER_FIX.md | 181 --- build_log.txt | 115 -- build_log_2.txt | 90 -- new-readme.md | 188 ---- old/ACCOUNT_PLAYER_SEPARATION_PLAN.md | 702 ------------ old/API_SUBDOMAIN_COMPLETE.md | 234 ---- old/API_SUBDOMAIN_MIGRATION.md | 143 --- old/BUGFIXES_2025-11-08.md | 281 ----- old/CHANGELOG_STEAM_ICONS_2025-11-09.md | 348 ------ old/Dockerfile.api.old | 30 - old/EMERGENCY_FIX_2025-11-09.md | 243 ---- old/FRONTEND_IMPLEMENTATION_COMPLETE.md | 409 ------- old/IMPLEMENTATION_COMPLETE_BACKEND.md | 331 ------ old/INTERACTABLE_COOLDOWN_SYSTEM.md | 242 ---- old/OPTIMIZATION_STRATEGY.md | 418 ------- old/README.md | 371 ------- old/REDIS_IMPLEMENTATION_COMPLETE.md | 636 ----------- old/REDIS_INTEGRATION_PLAN.md | 1168 -------------------- old/REDIS_INTEGRATION_QA.md | 765 ------------- old/STEAM_AND_PREMIUM_PLAN.md | 564 ---------- old/TESTING_GUIDE.md | 276 ----- old/WEBSOCKET_DEPLOYMENT.md | 124 --- old/WEBSOCKET_FINAL_RESOLUTION.md | 163 --- old/WEBSOCKET_IMPLEMENTATION_COMPLETE.md | 335 ------ old/WEBSOCKET_MIGRATION_PLAN.md | 608 ---------- old/WEBSOCKET_TESTING.md | 122 -- old/WEBSOCKET_TRAEFIK_FIX.md | 188 ---- old/bot/__init__.py | 0 old/bot/action_handlers.py | 417 ------- old/bot/api_client.old.py | 198 ---- old/bot/api_client.py | 623 ----------- old/bot/background_tasks.py | 201 ---- old/bot/combat.py | 527 --------- old/bot/combat_handlers.py | 165 --- old/bot/commands.py | 109 -- old/bot/corpse_handlers.py | 235 ---- old/bot/database.py | 729 ------------ old/bot/handlers.py | 174 --- old/bot/inventory_handlers.py | 338 ------ old/bot/keyboards.py | 607 ---------- old/bot/logic.py | 119 -- old/bot/message_utils.py | 121 -- old/bot/pickup_handlers.py | 136 --- old/bot/profile_handlers.py | 169 --- old/bot/spawn_manager.py | 119 -- old/bot/status_utils.py | 119 -- old/bot/utils.py | 128 --- old/main.py | 87 -- old/migrate_account_character_split.py | 0 old/migrate_account_player_separation.py | 410 ------- old/migrate_interactable_cooldowns.py | 43 - old/migrate_remove_pvp_hp_fields.py | 49 - old/migrate_steam_support.py | 131 --- pwa/src/components/Game.css | 142 ++- pwa/src/components/common/GameTooltip.tsx | 2 +- pwa/src/components/game/InventoryModal.tsx | 35 +- pwa/src/components/game/Workbench.css | 24 +- pwa/src/index.css | 2 +- refactor_summary.md | 51 - 66 files changed, 172 insertions(+), 16521 deletions(-) delete mode 100644 COMPLETE_MIGRATION_SUCCESS.md delete mode 100644 Dockerfile delete mode 100644 Dockerfile.pwa.new delete mode 100644 PLAYERS_TAB_SCHEMA_FIX.md delete mode 100644 REDIS_MONITORING.md delete mode 100644 REFACTORING_COMPLETE.md delete mode 100644 REFACTORING_PLAN.md delete mode 100644 WEBSOCKET_HANDLER_FIX.md delete mode 100644 build_log.txt delete mode 100644 build_log_2.txt delete mode 100644 new-readme.md delete mode 100644 old/ACCOUNT_PLAYER_SEPARATION_PLAN.md delete mode 100644 old/API_SUBDOMAIN_COMPLETE.md delete mode 100644 old/API_SUBDOMAIN_MIGRATION.md delete mode 100644 old/BUGFIXES_2025-11-08.md delete mode 100644 old/CHANGELOG_STEAM_ICONS_2025-11-09.md delete mode 100644 old/Dockerfile.api.old delete mode 100644 old/EMERGENCY_FIX_2025-11-09.md delete mode 100644 old/FRONTEND_IMPLEMENTATION_COMPLETE.md delete mode 100644 old/IMPLEMENTATION_COMPLETE_BACKEND.md delete mode 100644 old/INTERACTABLE_COOLDOWN_SYSTEM.md delete mode 100644 old/OPTIMIZATION_STRATEGY.md delete mode 100644 old/README.md delete mode 100644 old/REDIS_IMPLEMENTATION_COMPLETE.md delete mode 100644 old/REDIS_INTEGRATION_PLAN.md delete mode 100644 old/REDIS_INTEGRATION_QA.md delete mode 100644 old/STEAM_AND_PREMIUM_PLAN.md delete mode 100644 old/TESTING_GUIDE.md delete mode 100644 old/WEBSOCKET_DEPLOYMENT.md delete mode 100644 old/WEBSOCKET_FINAL_RESOLUTION.md delete mode 100644 old/WEBSOCKET_IMPLEMENTATION_COMPLETE.md delete mode 100644 old/WEBSOCKET_MIGRATION_PLAN.md delete mode 100644 old/WEBSOCKET_TESTING.md delete mode 100644 old/WEBSOCKET_TRAEFIK_FIX.md delete mode 100644 old/bot/__init__.py delete mode 100644 old/bot/action_handlers.py delete mode 100644 old/bot/api_client.old.py delete mode 100644 old/bot/api_client.py delete mode 100644 old/bot/background_tasks.py delete mode 100644 old/bot/combat.py delete mode 100644 old/bot/combat_handlers.py delete mode 100644 old/bot/commands.py delete mode 100644 old/bot/corpse_handlers.py delete mode 100644 old/bot/database.py delete mode 100644 old/bot/handlers.py delete mode 100644 old/bot/inventory_handlers.py delete mode 100644 old/bot/keyboards.py delete mode 100644 old/bot/logic.py delete mode 100644 old/bot/message_utils.py delete mode 100644 old/bot/pickup_handlers.py delete mode 100644 old/bot/profile_handlers.py delete mode 100644 old/bot/spawn_manager.py delete mode 100644 old/bot/status_utils.py delete mode 100644 old/bot/utils.py delete mode 100644 old/main.py delete mode 100644 old/migrate_account_character_split.py delete mode 100644 old/migrate_account_player_separation.py delete mode 100644 old/migrate_interactable_cooldowns.py delete mode 100644 old/migrate_remove_pvp_hp_fields.py delete mode 100644 old/migrate_steam_support.py delete mode 100644 refactor_summary.md diff --git a/COMPLETE_MIGRATION_SUCCESS.md b/COMPLETE_MIGRATION_SUCCESS.md deleted file mode 100644 index 770c43d..0000000 --- a/COMPLETE_MIGRATION_SUCCESS.md +++ /dev/null @@ -1,331 +0,0 @@ -# ๐ŸŽ‰ Complete Backend Migration - SUCCESS - -## Migration Complete - November 12, 2025 - -Successfully completed full backend migration from monolithic main.py to modular router architecture. - ---- - -## ๐Ÿ“Š Results - -### Main.py Transformation -- **Before**: 5,573 lines (monolithic) -- **After**: 236 lines (initialization only) -- **Reduction**: 95.8% (5,337 lines moved to routers) - -### Router Architecture (9 Routers) -``` -api/routers/ -โ”œโ”€โ”€ auth.py - Authentication (3 endpoints) -โ”œโ”€โ”€ characters.py - Character management (4 endpoints) -โ”œโ”€โ”€ game_routes.py - Core game actions (11 endpoints) -โ”œโ”€โ”€ combat.py - Combat system (7 endpoints) -โ”œโ”€โ”€ equipment.py - Equipment management (6 endpoints) -โ”œโ”€โ”€ crafting.py - Crafting system (3 endpoints) -โ”œโ”€โ”€ loot.py - Loot generation (2 endpoints) -โ”œโ”€โ”€ statistics.py - Player statistics (3 endpoints) -โ””โ”€โ”€ admin.py - Internal API (30+ endpoints) -``` - -**Total**: 69+ endpoints extracted and organized - ---- - -## ๐Ÿ”ง Issues Fixed - -### 1. Redis Manager Undefined Error -**Problem**: `redis_manager is not defined` breaking player location features - -**Solution**: -- Added `redis_manager = None` to global scope in `game_routes.py` and `combat.py` -- Updated `init_router_dependencies()` to accept `redis_mgr` parameter -- Main.py now passes `redis_manager` to routers that need it - -**Affected Routers**: game_routes, combat - -### 2. Internal Endpoints Extraction -**Problem**: 30+ internal/admin endpoints still in main.py - -**Solution**: -- Created dedicated `admin.py` router -- Secured with `verify_internal_key` dependency -- Organized into logical sections (player, combat, corpses, etc.) -- Removed all internal endpoint code from main.py - ---- - -## ๐Ÿ“ Final Structure - -### api/main.py (236 lines) -```python -# Application initialization -# Router imports -# Database & Redis setup -# Router registration (9 routers) -# WebSocket endpoint -# Startup message -``` - -### Router Pattern -Each router follows consistent structure: -```python -# Global dependencies -LOCATIONS = None -ITEMS_MANAGER = None -WORLD = None -redis_manager = None # For routers that need Redis - -def init_router_dependencies(locations, items_manager, world, redis_mgr=None): - """Initialize router with shared dependencies""" - global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager - LOCATIONS = locations - ITEMS_MANAGER = items_manager - WORLD = world - redis_manager = redis_mgr - -# Endpoint definitions... -``` - ---- - -## ๐Ÿš€ Deployment Status - -### โœ… API Running Successfully -- All 5 workers started -- 9 routers registered -- 14 locations loaded -- 42 items loaded -- 6 background tasks active -- **Zero errors in logs** - -### โœ… Features Verified Working -- Redis manager integration (player location tracking) -- Combat system (state management) -- Internal API endpoints (admin tools) -- WebSocket connections -- Background tasks (spawn, decay, regeneration, etc.) - ---- - -## ๐Ÿ› ๏ธ Migration Tools Created - -### 1. analyze_endpoints.py -- Analyzes endpoint distribution in main.py -- Categorizes endpoints by domain -- Provides statistics for planning - -### 2. generate_routers.py -- **Automated endpoint extraction** from main.py -- Generated 6 routers automatically (1,900+ lines of code) -- Preserved all logic and function calls -- Maintained docstrings and comments - ---- - -## ๐Ÿ“ Key Achievements - -### Code Organization -- โœ… Endpoints grouped by logical domain -- โœ… Clear separation of concerns -- โœ… Consistent router patterns -- โœ… Proper dependency injection - -### Security Improvements -- โœ… Internal endpoints now secured with `verify_internal_key` -- โœ… Clean separation between public and admin API -- โœ… Router-level security policies - -### Maintainability -- โœ… 95.8% reduction in main.py size -- โœ… Each router focused on single domain -- โœ… Easy to locate and modify features -- โœ… Clear initialization pattern - -### Performance -- โœ… No performance degradation -- โœ… Redis integration working correctly -- โœ… Background tasks stable -- โœ… WebSocket functionality intact - ---- - -## ๐ŸŽฏ Router Breakdown - -### Public API Routers -1. **auth.py** (3 endpoints) - - Login, register, token refresh - - JWT token management - -2. **characters.py** (4 endpoints) - - Character creation, selection, deletion - - Character list retrieval - -3. **game_routes.py** (11 endpoints) - - Movement, inspection, interaction - - Item pickup/drop - - Uses Redis for location tracking - -4. **combat.py** (7 endpoints) - - PvE and PvP combat - - Fleeing, attacking - - Uses Redis for combat state - -5. **equipment.py** (6 endpoints) - - Equip/unequip items - - Equipment inspection - -6. **crafting.py** (3 endpoints) - - Recipe discovery - - Item crafting - -7. **loot.py** (2 endpoints) - - Loot generation - - Corpse looting - -8. **statistics.py** (3 endpoints) - - Player stats - - Leaderboards - -### Internal API Router -9. **admin.py** (30+ endpoints) - - **Player Management**: Get/update player, inventory, status effects - - **Combat Management**: Create/update/delete combat instances - - **Game Actions**: Move, inspect, interact, use item, pickup, drop - - **Equipment**: Equip/unequip operations - - **Dropped Items**: Full CRUD operations - - **Corpses**: Player and NPC corpse management (10 endpoints) - - **Wandering Enemies**: Spawn/delete/query - - **Inventory**: Direct inventory access - - **Cooldowns**: Cooldown management - - **Image Cache**: Image existence checks - ---- - -## ๐Ÿ” Security Model - -### Public Endpoints -- Protected by JWT token authentication -- User can only access own data -- Rate limiting applied - -### Internal Endpoints -- Protected by `verify_internal_key` dependency -- Requires `X-Internal-Key` header -- Only accessible by bot and admin tools -- Full access to all game data - ---- - -## ๐Ÿ“ˆ Statistics - -### Before Migration -- **1 file**: main.py (5,573 lines) -- **69+ endpoints** in single file -- **Mixed concerns**: public + internal API -- **Hard to maintain**: Scrolling through 5,000+ lines - -### After Migration -- **10 files**: main.py (236) + 9 routers (5,337 total) -- **69+ endpoints** organized by domain -- **Clear separation**: public API + admin API -- **Easy to maintain**: Average router ~600 lines - -### Endpoint Distribution -``` -Auth: 3 endpoints ( 5%) -Characters: 4 endpoints ( 6%) -Game: 11 endpoints ( 16%) -Combat: 7 endpoints ( 10%) -Equipment: 6 endpoints ( 9%) -Crafting: 3 endpoints ( 4%) -Loot: 2 endpoints ( 3%) -Statistics: 3 endpoints ( 4%) -Admin: 30 endpoints ( 43%) -``` - ---- - -## ๐ŸŽ“ Lessons Learned - -### What Worked Well -1. **Automated extraction script** saved massive time -2. **Consistent router pattern** made integration smooth -3. **Gradual testing** caught issues early -4. **Dependency injection** pattern scales well - -### Challenges Overcome -1. **Redis manager missing**: Fixed by adding to router globals -2. **Internal endpoints security**: Solved with dedicated admin router -3. **Large file editing**: Used automation instead of manual editing - ---- - -## โœ… Verification Checklist - -- [x] All routers created and organized -- [x] Main.py reduced to initialization only -- [x] Redis manager integrated correctly -- [x] Internal endpoints secured in admin router -- [x] API starts successfully -- [x] Zero errors in logs -- [x] All background tasks running -- [x] WebSocket functionality intact -- [x] 9 routers registered correctly - ---- - -## ๐Ÿš€ Next Steps - -### Backend (Complete โœ…) -- โœ… Router architecture -- โœ… Redis integration -- โœ… Security improvements -- โœ… Code organization - -### Frontend (Recommended) -The frontend could benefit from similar refactoring: -- `Game.tsx` is 3,315 lines (similar to old main.py) -- Could extract: Combat UI, Inventory UI, Map UI, Chat UI, etc. -- Would improve maintainability and code organization - ---- - -## ๐Ÿ“š Documentation - -### Updated Files -- `api/main.py` - Application initialization (236 lines) -- `api/routers/auth.py` - Authentication -- `api/routers/characters.py` - Character management -- `api/routers/game_routes.py` - Game actions (with Redis) -- `api/routers/combat.py` - Combat system (with Redis) -- `api/routers/equipment.py` - Equipment -- `api/routers/crafting.py` - Crafting -- `api/routers/loot.py` - Loot -- `api/routers/statistics.py` - Statistics -- `api/routers/admin.py` - Internal API (NEW) - -### Migration Tools -- `analyze_endpoints.py` - Endpoint analysis tool -- `generate_routers.py` - Automated extraction script -- `main_original_5573_lines.py` - Original backup -- `main_pre_migration_backup.py` - Pre-migration backup - ---- - -## ๐ŸŽ‰ Conclusion - -The backend migration is **COMPLETE and SUCCESSFUL**. The API is now: -- **Modular**: 9 focused routers instead of 1 monolithic file -- **Maintainable**: Average router size ~600 lines -- **Secure**: Internal API properly isolated and secured -- **Stable**: Zero errors, all features working -- **Scalable**: Easy to add new routers and endpoints - -**Main.py reduced from 5,573 lines to 236 lines (95.8% reduction)** - -Migration completed in one session with automated tools and systematic approach. - ---- - -*Generated: November 12, 2025* -*Status: โœ… Production Ready* diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index f927395..0000000 --- a/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -# Use an official Python runtime as a parent image -FROM python:3.11-slim - -# Set the working directory in the container -WORKDIR /app - -# Copy the requirements file into the container at /app -COPY requirements.txt . - -# Install any needed packages specified in requirements.txt -RUN pip install --no-cache-dir -r requirements.txt - -# Copy the rest of the application's code into the container at /app -COPY . . - -# Command to run the application -CMD ["python", "main.py"] diff --git a/Dockerfile.pwa.new b/Dockerfile.pwa.new deleted file mode 100644 index b496cf1..0000000 --- a/Dockerfile.pwa.new +++ /dev/null @@ -1,39 +0,0 @@ -# Build stage for PWA -FROM node:20-alpine AS build - -WORKDIR /app - -# Copy package files -COPY pwa/package*.json ./ - -# Install dependencies -RUN npm install - -# Copy source code -COPY pwa/ ./ - -# Build the application -RUN npm run build - -# Production stage - simple Python server for static files -FROM python:3.11-slim - -WORKDIR /usr/share/app - -# Copy built assets from build stage -COPY --from=build /app/dist ./dist - -# Copy game images -COPY images/ ./dist/images/ - -# Install simple HTTP server -RUN pip install --no-cache-dir aiofiles - -# Copy a simple static file server script -COPY pwa/server.py ./ - -# Expose port -EXPOSE 80 - -# Start the server -CMD ["python", "server.py"] diff --git a/PLAYERS_TAB_SCHEMA_FIX.md b/PLAYERS_TAB_SCHEMA_FIX.md deleted file mode 100644 index ae745c3..0000000 --- a/PLAYERS_TAB_SCHEMA_FIX.md +++ /dev/null @@ -1,146 +0,0 @@ -# Database Schema Migration - Players Tab Fix - -## Summary -Fixed all database queries in the web-map editor to use the correct `accounts` + `characters` schema instead of the deprecated `players` table. - -## Schema Changes - -### Old Schema (Deprecated) -- `players` table with `telegram_id` as primary key -- Columns: `intelligence`, `weight_capacity`, `volume_capacity` -- `accounts` table with `is_banned`, `ban_reason`, `premium_until` - -### New Schema (Current) -- `accounts` table: `id`, `email`, `premium_expires_at`, `created_at` -- `characters` table: `id`, `account_id` (FK), `name`, `level`, `xp`, `hp`, `stamina`, `strength`, `agility`, `endurance`, `intellect`, `unspent_points`, `location_id`, `is_dead` -- `inventory` table: `character_id` (FK), `item_id`, `quantity`, `is_equipped`, `unique_item_id` (FK to unique_items) -- `unique_items` table: `id`, `item_id`, `durability`, `max_durability`, `tier`, `unique_stats` - -## Files Modified - -### 1. `/opt/dockers/echoes_of_the_ashes/web-map/server.py` - -**Changes:** -- โœ… Changed import from `bot.database` to `api.database` -- โœ… Updated all SQL queries to use `characters` and `accounts` tables -- โœ… Changed column names: - - `telegram_id` โ†’ `id` (character ID) - - `intelligence` โ†’ `intellect` - - `premium_until` โ†’ `premium_expires_at` - - `character_name` โ†’ `name` -- โœ… Updated API endpoints: - - `/api/editor/player/` โ†’ `/api/editor/player/` - - `/api/editor/account/` โ†’ `/api/editor/account/` -- โœ… Fixed inventory queries to use `character_id` and join with `unique_items` table -- โœ… Updated player count query for live stats (line 1080) -- โœ… Fixed delete account to use CASCADE (accounts โ†’ characters โ†’ inventory) -- โœ… Updated reset player to use correct default values - -**Endpoints Fixed:** -1. `GET /api/editor/players` - List all characters with account info -2. `GET /api/editor/player/` - Get character details + inventory -3. `POST /api/editor/player/` - Update character stats -4. `POST /api/editor/player//inventory` - Update inventory -5. `POST /api/editor/player//equipment` - Update equipment -6. `DELETE /api/editor/account//delete` - Delete account -7. `POST /api/editor/player//reset` - Reset character - -### 2. `/opt/dockers/echoes_of_the_ashes/web-map/editor_enhanced.js` - -**Changes:** -- โœ… Updated `renderPlayerList()` to use `player.id` instead of `player.telegram_id` -- โœ… Changed dataset attribute: `dataset.telegramId` โ†’ `dataset.characterId` -- โœ… Updated `selectPlayer()` function parameter and API call -- โœ… Fixed player editor display to show: - - Character ID instead of Telegram ID - - Account email - - Correct timestamp handling (character_created_at * 1000) -- โœ… Updated action buttons to use correct IDs: - - Ban/Unban: uses `account_id` - - Reset: uses character `id` - - Delete: uses `account_id` -- โœ… Fixed `deletePlayer()` to find player by `account_id` -- โœ… Updated status badge logic to use `is_premium` boolean - -## Testing Checklist - -### Backend Tests -- [ ] Start containers: `docker compose up -d` -- [ ] Check logs: `docker logs echoes_of_the_ashes_map` -- [ ] Test API endpoints: - ```bash - # Login first - curl -X POST http://localhost:8080/api/login \ - -H "Content-Type: application/json" \ - -d '{"password":"admin123"}' \ - -c cookies.txt - - # Get players list - curl http://localhost:8080/api/editor/players -b cookies.txt - - # Get specific player (replace 1 with actual character ID) - curl http://localhost:8080/api/editor/player/1 -b cookies.txt - ``` - -### Frontend Tests -1. Navigate to `http://localhost:8080/editor` -2. Login with password (default: `admin123`) -3. Click "๐Ÿ‘ฅ Players" tab -4. Verify: - - [ ] Player list loads correctly - - [ ] Search by name works - - [ ] Filter by status (All/Active/Banned/Premium) works - - [ ] Clicking a player loads their details - - [ ] Character stats display correctly - - [ ] Inventory shows (read-only) - - [ ] Equipment shows (read-only) - - [ ] Account info displays (email, premium status) -5. Test actions: - - [ ] Edit character stats and save - - [ ] Reset player (confirm it clears inventory) - - [ ] Delete account (confirm double-confirmation) - -## Known Limitations - -1. **Ban functionality**: Accounts table doesn't have `is_banned` or `ban_reason` columns in new schema - - Ban/Unban buttons will return "not implemented" message - - Need to add these columns to accounts table if ban feature is needed - -2. **Inventory editing**: Currently read-only display - - Full CRUD for inventory would require more complex UI - - Unique items support needs proper unique_items table integration - -3. **Equipment slots**: New schema uses `is_equipped` flag in inventory - - No separate `equipped_items` table - - Equipment is just inventory items with `is_equipped=true` - -## Rebuild Instructions - -```bash -# Rebuild map container with fixes -docker compose build echoes_of_the_ashes_map - -# Restart container -docker compose up -d echoes_of_the_ashes_map - -# Check logs -docker logs -f echoes_of_the_ashes_map -``` - -## Rollback Plan - -If issues occur: -```bash -# Restore from container (files are already synced) -./sync_from_containers.sh - -# Or restore from git -git checkout web-map/server.py web-map/editor_enhanced.js -``` - -## Additional Notes - -- All changes are backward compatible with existing data -- No database migrations needed (schema already exists) -- Frontend gracefully handles missing data (email, premium status) -- Timestamps are handled correctly (Unix timestamps in DB, converted to Date objects in JS) diff --git a/REDIS_MONITORING.md b/REDIS_MONITORING.md deleted file mode 100644 index 44b2c03..0000000 --- a/REDIS_MONITORING.md +++ /dev/null @@ -1,180 +0,0 @@ -# Redis Cache Monitoring Guide - -## Quick Methods to Monitor Redis Cache - -### 1. **API Endpoint (Easiest)** - -Access the cache stats endpoint: - -```bash -# Using curl (replace with your auth token) -curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8000/api/cache/stats -``` - -**Response:** -```json -{ - "enabled": true, - "redis_stats": { - "total_commands_processed": 15234, - "ops_per_second": 12, - "connected_clients": 8 - }, - "cache_performance": { - "hits": 8542, - "misses": 1234, - "total_requests": 9776, - "hit_rate_percent": 87.38 - }, - "current_user": { - "inventory_cached": true, - "player_id": 1 - } -} -``` - -**What to look for:** -- `hit_rate_percent`: Should be 70-90% for good cache performance -- `inventory_cached`: Shows if your inventory is currently in cache -- `ops_per_second`: Redis operations per second - ---- - -### 2. **Redis CLI - Real-time Monitoring** - -```bash -# Connect to Redis container -docker exec -it echoes_of_the_ashes_redis redis-cli - -# View detailed statistics -INFO stats - -# Monitor all commands in real-time (shows every cache hit/miss) -MONITOR - -# View all inventory cache keys -KEYS player:*:inventory - -# Check if specific player's inventory is cached -EXISTS player:1:inventory - -# Get TTL (time to live) of a cached inventory -TTL player:1:inventory - -# View cached inventory data -GET player:1:inventory -``` - ---- - -### 3. **Application Logs** - -```bash -# View all cache-related logs -docker logs echoes_of_the_ashes_api -f | grep -i "redis\|cache" - -# View only cache failures -docker logs echoes_of_the_ashes_api -f | grep "cache.*failed" -``` - ---- - -### 4. **Redis Commander (Web UI)** - -Add Redis Commander to your docker-compose.yml for a web-based UI: - -```yaml -redis-commander: - image: rediscommander/redis-commander:latest - environment: - - REDIS_HOSTS=local:echoes_of_the_ashes_redis:6379 - ports: - - "8081:8081" - depends_on: - - echoes_of_the_ashes_redis -``` - -Then access: http://localhost:8081 - ---- - -## Understanding Cache Metrics - -### Hit Rate -- **90%+**: Excellent - Most requests served from cache -- **70-90%**: Good - Cache is working well -- **50-70%**: Fair - Consider increasing TTL or investigating invalidation -- **<50%**: Poor - Cache may not be effective - -### Inventory Cache Keys -- Format: `player:{player_id}:inventory` -- TTL: 600 seconds (10 minutes) -- Invalidated on: add/remove items, equip/unequip, property updates - -### Expected Behavior -1. **First inventory load**: Cache MISS โ†’ Database query โ†’ Cache write -2. **Subsequent loads**: Cache HIT โ†’ Fast response (~1-3ms) -3. **After mutation** (pickup item): Cache invalidated โ†’ Next load is MISS -4. **After 10 minutes**: Cache expires โ†’ Next load is MISS - ---- - -## Testing Cache Performance - -### Test 1: Verify Caching Works -```bash -# 1. Load inventory (should be cache MISS) -curl -H "Authorization: Bearer TOKEN" http://localhost:8000/api/game/state - -# 2. Load again immediately (should be cache HIT - much faster) -curl -H "Authorization: Bearer TOKEN" http://localhost:8000/api/game/state - -# 3. Check stats -curl -H "Authorization: Bearer TOKEN" http://localhost:8000/api/cache/stats -``` - -### Test 2: Verify Invalidation Works -```bash -# 1. Load inventory (cache HIT if already loaded) -curl -H "Authorization: Bearer TOKEN" http://localhost:8000/api/game/state - -# 2. Pick up an item (invalidates cache) -curl -X POST -H "Authorization: Bearer TOKEN" http://localhost:8000/api/game/pickup_item - -# 3. Load inventory again (should be cache MISS) -curl -H "Authorization: Bearer TOKEN" http://localhost:8000/api/game/state -``` - ---- - -## Troubleshooting - -### Cache Not Working -```bash -# Check if Redis is running -docker ps | grep redis - -# Check Redis connectivity -docker exec -it echoes_of_the_ashes_redis redis-cli PING -# Should return: PONG - -# Check application logs for errors -docker logs echoes_of_the_ashes_api | grep -i "redis" -``` - -### Low Hit Rate -- Check if cache TTL is too short (currently 10 minutes) -- Verify invalidation isn't too aggressive -- Monitor which operations are causing cache misses - -### High Memory Usage -```bash -# Check Redis memory usage -docker exec -it echoes_of_the_ashes_redis redis-cli INFO memory - -# View all cached keys -docker exec -it echoes_of_the_ashes_redis redis-cli KEYS "*" - -# Clear all cache (use with caution!) -docker exec -it echoes_of_the_ashes_redis redis-cli FLUSHALL -``` diff --git a/REFACTORING_COMPLETE.md b/REFACTORING_COMPLETE.md deleted file mode 100644 index a18cd89..0000000 --- a/REFACTORING_COMPLETE.md +++ /dev/null @@ -1,335 +0,0 @@ -# Backend Refactoring - Complete Summary - -## ๐ŸŽ‰ What We've Accomplished - -### โœ… Project Cleanup -**Moved to `old/` folder:** -- `bot/` - Unused Telegram bot code -- `web-map/` - Old map editor -- All `.md` documentation files -- Old migration scripts (`migrate_*.py`) -- Legacy Dockerfiles - -**Result:** Clean, organized project root - ---- - -### โœ… New Module Structure Created - -``` -api/ -โ”œโ”€โ”€ core/ # Core functionality -โ”‚ โ”œโ”€โ”€ __init__.py -โ”‚ โ”œโ”€โ”€ config.py # โœ… All configuration & constants -โ”‚ โ”œโ”€โ”€ security.py # โœ… JWT, auth, password hashing -โ”‚ โ””โ”€โ”€ websockets.py # โœ… ConnectionManager -โ”‚ -โ”œโ”€โ”€ services/ # Business logic & utilities -โ”‚ โ”œโ”€โ”€ __init__.py -โ”‚ โ”œโ”€โ”€ models.py # โœ… All Pydantic request/response models (17 models) -โ”‚ โ””โ”€โ”€ helpers.py # โœ… Utility functions (distance, stamina, armor, tools) -โ”‚ -โ”œโ”€โ”€ routers/ # API route handlers -โ”‚ โ”œโ”€โ”€ __init__.py -โ”‚ โ””โ”€โ”€ auth.py # โœ… Auth router (register, login, me) -โ”‚ -โ””โ”€โ”€ main.py # Main application file (currently 5574 lines) -``` - ---- - -## ๐Ÿ“‹ What's in Each Module - -### `api/core/config.py` -```python -- SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES -- API_INTERNAL_KEY -- CORS_ORIGINS list -- IMAGES_DIR path -- Game constants (MOVEMENT_COOLDOWN, capacities) -``` - -### `api/core/security.py` -```python -- create_access_token(data: dict) -> str -- decode_token(token: str) -> dict -- hash_password(password: str) -> str -- verify_password(password: str, hash: str) -> bool -- get_current_user(credentials) -> Dict[str, Any] # Main auth dependency -- verify_internal_key(credentials) -> bool -``` - -### `api/core/websockets.py` -```python -class ConnectionManager: - - connect(websocket, player_id, username) - - disconnect(player_id) - - send_personal_message(player_id, message) - - send_to_location(location_id, message, exclude_player_id) - - broadcast(message, exclude_player_id) - - handle_redis_message(channel, data) -``` - -### `api/services/models.py` -**All Pydantic Models (17 total):** -- Auth: `UserRegister`, `UserLogin` -- Characters: `CharacterCreate`, `CharacterSelect` -- Game: `MoveRequest`, `InteractRequest`, `UseItemRequest`, `PickupItemRequest` -- Combat: `InitiateCombatRequest`, `CombatActionRequest`, `PvPCombatInitiateRequest`, `PvPAcknowledgeRequest`, `PvPCombatActionRequest` -- Equipment: `EquipItemRequest`, `UnequipItemRequest`, `RepairItemRequest` -- Crafting: `CraftItemRequest`, `UncraftItemRequest` -- Loot: `LootCorpseRequest` - -### `api/services/helpers.py` -**Utility Functions:** -- `calculate_distance(x1, y1, x2, y2) -> float` -- `calculate_stamina_cost(...) -> int` -- `calculate_player_capacity(player_id) -> Tuple[float, float, float, float]` -- `reduce_armor_durability(player_id, damage_taken) -> Tuple[int, List]` -- `consume_tool_durability(user_id, tools, inventory) -> Tuple[bool, str, list]` - -### `api/routers/auth.py` -**Endpoints (3):** -- `POST /api/auth/register` - Register new account -- `POST /api/auth/login` - Login with email/password -- `GET /api/auth/me` - Get current user profile - ---- - -## ๐ŸŽฏ How to Use the New Structure - -### Example: Using Security Module -```python -# OLD (in main.py): -from fastapi.security import HTTPBearer -security = HTTPBearer() -# ... 100+ lines of JWT code ... - -# NEW (anywhere): -from api.core.security import get_current_user, create_access_token, hash_password - -@router.post("/some-endpoint") -async def my_endpoint(current_user = Depends(get_current_user)): - # current_user is automatically validated and loaded - pass -``` - -### Example: Using Config -```python -# OLD: -SECRET_KEY = os.getenv("JWT_SECRET_KEY", "...") -CORS_ORIGINS = ["https://...", "http://..."] - -# NEW: -from api.core.config import SECRET_KEY, CORS_ORIGINS -``` - -### Example: Using Models -```python -# OLD (in main.py): -class MoveRequest(BaseModel): - direction: str - -# NEW (anywhere): -from api.services.models import MoveRequest -``` - -### Example: Using Helpers -```python -# OLD: -# Copy-paste helper function or import from main - -# NEW: -from api.services.helpers import calculate_distance, calculate_stamina_cost -``` - ---- - -## ๐Ÿ“Š Current State of main.py - -**Status:** Still 5574 lines (unchanged) -**Why:** We created the foundation but didn't migrate endpoints yet - -**What main.py currently contains:** -1. โœ… Clean imports (can now use new modules) -2. โŒ All 50+ endpoints still in the file -3. โŒ Helper functions still duplicated -4. โŒ Pydantic models still defined here - ---- - -## ๐Ÿš€ Migration Path Forward - -### Option 1: Gradual Migration (Recommended) -**Time:** 30 min - 2 hours per router -**Risk:** Low (test each router individually) - -**Steps for each router:** -1. Create router file (e.g., `routers/characters.py`) -2. Copy endpoint functions from main.py -3. Update imports to use new modules -4. Add router to main.py: `app.include_router(characters.router)` -5. Remove old endpoint code from main.py -6. Test the endpoints -7. Repeat for next router - -**Suggested Order:** -1. Characters (4 endpoints) - ~30 min -2. Game Actions (9 endpoints) - ~1 hour -3. Equipment (4 endpoints) - ~30 min -4. Crafting (3 endpoints) - ~30 min -5. Combat (3 PvE + 4 PvP = 7 endpoints) - ~1 hour -6. WebSocket (1 endpoint) - ~30 min - -**Total:** ~4-5 hours for complete migration - -### Option 2: Use Current Structure As-Is -**Time:** 0 hours -**Benefit:** Everything still works, new code uses clean modules - -**When creating new features:** -- Use the new modules (config, security, models, helpers) -- Create new routers instead of adding to main.py -- Gradually extract old code when you touch it - ---- - -## ๐Ÿ’ก Immediate Benefits (Already Achieved) - -Even without migrating endpoints, you already have: - -### 1. Clean Imports -```python -# Instead of scrolling through 5574 lines: -from api.core.security import get_current_user -from api.services.models import MoveRequest -from api.services.helpers import calculate_distance -``` - -### 2. Reusable Auth -```python -# Any new router can use: -@router.get("/new-endpoint") -async def my_new_endpoint(user = Depends(get_current_user)): - # Automatic auth! - pass -``` - -### 3. Centralized Config -```python -# Change CORS_ORIGINS in one place -# All routers automatically use it -from api.core.config import CORS_ORIGINS -``` - -### 4. Type Safety -```python -# All models in one place -# Easy to find, easy to reuse -from api.services.models import * -``` - ---- - -## ๐Ÿ“ File Sizes Comparison - -### Before Refactoring: -- `main.py`: **5,574 lines** ๐Ÿ˜ฑ -- Everything in one file - -### After Refactoring: -- `main.py`: 5,574 lines (unchanged, but ready for migration) -- `core/config.py`: 32 lines -- `core/security.py`: 128 lines -- `core/websockets.py`: 203 lines -- `services/models.py`: 122 lines -- `services/helpers.py`: 189 lines -- `routers/auth.py`: 152 lines - -**Total new code:** ~826 lines across 6 well-organized files - -### After Full Migration (Projected): -- `main.py`: ~150 lines (just app setup) -- 6 core/service files: ~826 lines -- 6-7 router files: ~1,200 lines -- **Total:** ~2,176 lines (vs 5,574 original) -- **Reduction:** 60% less code through deduplication and organization - ---- - -## ๐ŸŽ“ For Future Development - -### Creating a New Feature: -```python -# 1. Create router file -# api/routers/my_feature.py -from fastapi import APIRouter, Depends -from ..core.security import get_current_user -from ..services.models import MyRequest -from .. import database as db - -router = APIRouter(prefix="/api/my-feature", tags=["my-feature"]) - -@router.post("/action") -async def do_something( - request: MyRequest, - current_user = Depends(get_current_user) -): - # Your logic here - return {"success": True} - -# 2. Register in main.py -from .routers import my_feature -app.include_router(my_feature.router) -``` - -### Adding a New Model: -```python -# Just add to services/models.py -class MyNewRequest(BaseModel): - field1: str - field2: int -``` - -### Adding a Helper Function: -```python -# Just add to services/helpers.py -def my_helper_function(param1, param2): - # Your logic - return result -``` - ---- - -## โœ… Summary - -### What Works Now: -- โœ… All existing endpoints still work -- โœ… Clean module structure ready -- โœ… Auth router fully functional -- โœ… Logging properly configured -- โœ… Project root cleaned up - -### What's Ready: -- โœ… Foundation for gradual migration -- โœ… New features can use clean structure immediately -- โœ… No breaking changes -- โœ… Easy to understand and maintain - -### What's Next (Optional): -- Migrate remaining endpoints to routers -- Delete old code from main.py -- End result: ~150 line main.py instead of 5,574 - ---- - -## ๐ŸŽ‰ Conclusion - -**You now have a solid foundation for maintainable code!** - -The refactoring can be completed gradually, or you can use the new structure as-is for new features. Either way, the hardest part (creating the clean architecture) is done. - -**Time invested:** ~2 hours -**Value delivered:** Clean structure that will save hours in future development -**Breaking changes:** None -**Risk:** Zero diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md deleted file mode 100644 index 8d7e904..0000000 --- a/REFACTORING_PLAN.md +++ /dev/null @@ -1,160 +0,0 @@ -# Project Refactoring Plan - -## Current Status - -### โœ… Completed -1. **Moved unused files to `old/` folder**: - - `bot/` - Old Telegram bot code (no longer used) - - `web-map/` - Old map editor - - All `.md` documentation files - - Old migration scripts - - Old Dockerfiles - -2. **Created new API module structure**: - ``` - api/ - โ”œโ”€โ”€ core/ # Core functionality (config, security, websockets) - โ”œโ”€โ”€ routers/ # API route handlers - โ”œโ”€โ”€ services/ # Business logic services - โ””โ”€โ”€ ...existing files... - ``` - -3. **Created core modules**: - - โœ… `api/core/config.py` - All configuration and constants - - โœ… `api/core/security.py` - JWT, auth, password hashing - - โœ… `api/core/websockets.py` - WebSocket ConnectionManager - -### ๐Ÿ”„ Next Steps - -#### Backend API Refactoring - -**Router Files to Create** (in `api/routers/`): -1. `auth.py` - `/api/auth/*` endpoints (register, login, me) -2. `characters.py` - `/api/characters/*` endpoints (list, create, select, delete) -3. `game.py` - `/api/game/*` endpoints (state, location, profile, move, inspect, interact, pickup, use_item) -4. `combat.py` - `/api/game/combat/*` endpoints (initiate, action) + PvP combat -5. `equipment.py` - `/api/game/equip/*` endpoints (equip, unequip, repair) -6. `crafting.py` - `/api/game/craft/*` endpoints (craftable, craft_item) -7. `corpses.py` - `/api/game/corpses/*` and `/api/internal/corpses/*` endpoints -8. `websocket.py` - `/ws/game/*` WebSocket endpoint - -**Helper Files to Create** (in `api/services/`): -1. `helpers.py` - Utility functions (distance calculation, stamina cost, armor durability, etc.) -2. `models.py` - Pydantic models (all request/response models) - -**Final `api/main.py`** will contain ONLY: -- FastAPI app initialization -- Middleware setup (CORS) -- Static file mounting -- Router registration -- Lifespan context (startup/shutdown) -- ~100 lines instead of 5500+ - -#### Frontend Refactoring - -**Components to Extract from Game.tsx**: - -In `pwa/src/components/game/`: -1. `Compass.tsx` - Navigation compass with stamina costs -2. `LocationView.tsx` - Location description and image -3. `Surroundings.tsx` - NPCs, players, items, corpses, interactables -4. `InventoryPanel.tsx` - Inventory management -5. `EquipmentPanel.tsx` - Equipment slots -6. `CombatView.tsx` - Combat interface (PvE and PvP) -7. `ProfilePanel.tsx` - Player stats and info -8. `CraftingPanel.tsx` - Crafting interface -9. `DeathOverlay.tsx` - Death screen - -**Shared hooks** (in `pwa/src/hooks/`): -1. `useWebSocket.ts` - WebSocket connection and message handling -2. `useGameState.ts` - Game state management -3. `useCombat.ts` - Combat state and actions - -**Type definitions** (in `pwa/src/types/`): -1. `game.ts` - Game entities (Player, Location, Item, NPC, etc.) -2. `combat.ts` - Combat-related types -3. `websocket.ts` - WebSocket message types - -**Final `Game.tsx`** will contain ONLY: -- Component composition -- State management coordination -- WebSocket message routing -- ~300-400 lines instead of 3300+ - -### ๐Ÿ“‹ Estimated File Count - -**Before**: -- Backend: 1 massive file (5574 lines) -- Frontend: 1 massive file (3315 lines) -- Total: 2 files, ~9000 lines - -**After**: -- Backend: ~15 files, average ~200-400 lines each -- Frontend: ~15 files, average ~100-300 lines each -- Total: ~30 files, all maintainable and focused - -### ๐ŸŽฏ Benefits - -1. **Easier to navigate** - Each file has a single responsibility -2. **Easier to test** - Isolated components and functions -3. **Easier to maintain** - Changes don't affect unrelated code -4. **Easier to understand** - Clear module boundaries -5. **Better IDE support** - Faster autocomplete, better error detection -6. **Team-friendly** - Multiple developers can work without conflicts - -## Implementation Strategy - -### Phase 1: Backend (4-5 hours) -1. Create all router files with endpoints -2. Create service/helper files -3. Extract Pydantic models -4. Refactor main.py to just registration -5. Test all endpoints still work - -### Phase 2: Frontend (3-4 hours) -1. Create type definitions -2. Extract hooks -3. Create component files -4. Refactor Game.tsx to use components -5. Test all functionality still works - -### Phase 3: TypeScript Configuration (30 minutes) -1. Create/update `tsconfig.json` -2. Add proper type definitions -3. Fix VSCode errors - -### Phase 4: Testing & Documentation (1 hour) -1. Verify all features work -2. Update README with new structure -3. Create architecture diagram - -## Questions Before Proceeding - -1. **Should I continue with the full refactoring now?** - - This will take significant time (8-10 hours of work) - - Will create 30+ new files - - Will require thorough testing - -2. **Do you want me to do it all at once or in phases?** - - All at once: Complete transformation - - Phases: Backend first, then frontend, then testing - -3. **Any specific preferences for file organization?** - - Current plan follows standard FastAPI/React best practices - - Open to adjustments - -## Recommendation - -I recommend doing this in **phases with testing after each**: -1. **Phase 1**: Backend refactoring (today) - Most critical, easier to test -2. **Phase 2**: Frontend refactoring (next session) - Can verify backend works first -3. **Phase 3**: TypeScript fixes (quick win) -4. **Phase 4**: Final testing and documentation - -This approach: -- Allows for testing and validation at each step -- Reduces risk of breaking everything at once -- Gives you time to review and provide feedback -- Easier to roll back if issues arise - -Would you like me to proceed with **Phase 1: Backend Refactoring** now? diff --git a/WEBSOCKET_HANDLER_FIX.md b/WEBSOCKET_HANDLER_FIX.md deleted file mode 100644 index b7f06d9..0000000 --- a/WEBSOCKET_HANDLER_FIX.md +++ /dev/null @@ -1,181 +0,0 @@ -# WebSocket Message Handler Implementation - -## Date: 2025-11-17 - -## Problem -WebSocket was receiving `location_update` messages but not processing them correctly: -- Console showed: "Unknown WebSocket message type: location_update" -- All WebSocket messages triggered full `fetchGameData()` API call (inefficient) -- Players entering/leaving zones not visible until page refresh -- Real-time multiplayer updates broken - -## Solution Implemented - -### 1. Added Comprehensive WebSocket Message Handlers (Game.tsx) - -Replaced simple `fetchGameData()` calls with intelligent, granular state updates: - -#### Message Types Now Handled: - -**location_update** (NEW): -- Handles: player_arrived, player_left, corpse_looted, enemy_despawned -- Action: Calls `refreshLocation()` to update only location data -- Enables real-time multiplayer visibility - -**state_update**: -- Checks message.data for player, location, or encounter updates -- Updates only relevant state slices -- No full game state refresh needed - -**combat_started/combat_update/combat_ended**: -- Updates combat state directly from message.data -- Updates player HP/XP/level in real-time during combat -- Refreshes location after combat ends (for corpses/loot) - -**item_picked_up/item_dropped**: -- Refreshes location items only -- Shows real-time item changes for all players in zone - -**interactable_cooldown** (NEW): -- Updates cooldown state directly -- No API call needed - -### 2. Added WebSocket Helper Functions (useGameEngine.ts) - -Created 5 new helper functions exported via actions: - -```typescript -// Refresh only location data (efficient) -refreshLocation: () => Promise - -// Refresh only combat data (efficient) -refreshCombat: () => Promise - -// Update player state directly (HP/XP/level) -updatePlayerState: (playerData: any) => void - -// Update combat state directly -updateCombatState: (combatData: any) => void - -// Update interactable cooldowns directly -updateCooldowns: (cooldowns: Record) => void -``` - -### 3. Updated Type Definitions - -**vite-env.d.ts**: -- Added `VITE_WS_URL` to ImportMetaEnv interface -- Fixes TypeScript error for WebSocket URL env var - -**GameEngineActions interface**: -- Added 5 new WebSocket helper functions -- Maintains type safety throughout - -## Backend Message Structure - -### location_update Messages: -```json -{ - "type": "location_update", - "data": { - "message": "PlayerName arrived", - "action": "player_arrived", - "player_id": 123, - "player_name": "PlayerName", - "player_level": 5, - "can_pvp": true - }, - "timestamp": "2025-11-17T14:23:37.000Z" -} -``` - -**Actions**: player_arrived, player_left, corpse_looted, enemy_despawned - -### state_update Messages: -```json -{ - "type": "state_update", - "data": { - "player": { "stamina": 95, "location_id": "location_001" }, - "location": { "id": "location_001", "name": "The Ruins" }, - "encounter": { ... } - }, - "timestamp": "..." -} -``` - -### combat_update Messages: -```json -{ - "type": "combat_update", - "data": { - "message": "You dealt 15 damage!", - "log_entry": "You dealt 15 damage!", - "combat_over": false, - "combat": { ... }, - "player": { "hp": 85, "xp": 1250, "level": 5 } - }, - "timestamp": "..." -} -``` - -## Performance Impact - -### Before: -- Every WebSocket message โ†’ Full `fetchGameData()` API call -- Fetches: player state, location, profile, combat, equipment, PvP -- ~5-10 API calls for every WebSocket message -- High server load, slow UI updates - -### After: -- `location_update` โ†’ Only location data refresh (1 API call) -- `combat_update` โ†’ Direct state update (0 API calls if data provided) -- `state_update` โ†’ Targeted updates (0-2 API calls) -- 80-90% reduction in unnecessary API calls - -## User Experience Improvements - -1. **Real-time Multiplayer**: Players see others enter/leave zones immediately -2. **Combat Updates**: HP changes visible during combat, not after -3. **Item Changes**: Loot/drops visible to all players instantly -4. **Reduced Lag**: Fewer API calls = faster UI response -5. **Better Feedback**: Specific console logs for debugging - -## Files Modified - -1. **pwa/src/components/Game.tsx**: - - handleWebSocketMessage function (lines 16-118) - - Added all message type handlers with granular updates - -2. **pwa/src/components/game/hooks/useGameEngine.ts**: - - Added 5 WebSocket helper functions (lines 916-962) - - Updated GameEngineActions interface (lines 64-131) - - Updated actions export (lines 970-1013) - -3. **pwa/src/vite-env.d.ts**: - - Added VITE_WS_URL to ImportMetaEnv interface - -## Testing Recommendations - -1. Open game in two browser windows -2. Move one player between locations -3. Verify other window shows "PlayerName arrived" immediately -4. Test combat - HP should update in real-time -5. Test looting - other players should see corpse disappear -6. Check console for message type logs - -## Next Steps (Optional Improvements) - -1. Add typing for message.data structures -2. Implement retry logic for failed WebSocket messages -3. Add message queue for offline message buffering -4. Consider adding WebSocket message acknowledgments -5. Implement heartbeat/keepalive mechanism - -## Conclusion - -WebSocket message handling is now efficient and complete. All message types from backend are properly handled, state updates are granular, and unnecessary API calls are eliminated. Real-time multiplayer features now work as expected. - -**Build Status**: โœ… Successful -**Deployment Status**: โœ… Deployed -**TypeScript Errors**: โœ… None diff --git a/build_log.txt b/build_log.txt deleted file mode 100644 index f2db43f..0000000 --- a/build_log.txt +++ /dev/null @@ -1,115 +0,0 @@ ---progress is a global compose flag, better use `docker compose --progress xx build ... - Image echoes_of_the_ashes-echoes_of_the_ashes_pwa Building - Image echoes_of_the_ashes-echoes_of_the_ashes_api Building -#1 [internal] load local bake definitions -#1 reading from stdin 1.25kB done -#1 DONE 0.0s - -#2 [internal] load build definition from Dockerfile.pwa -#2 transferring dockerfile: 810B done -#2 DONE 0.0s - -#3 [internal] load metadata for docker.io/library/nginx:alpine -#3 DONE 0.0s - -#4 [internal] load metadata for docker.io/library/node:20-alpine -#4 DONE 0.4s - -#5 [internal] load .dockerignore -#5 transferring context: 2B done -#5 DONE 0.0s - -#6 [build 1/6] FROM docker.io/library/node:20-alpine@sha256:09e2b3d9726018aecf269bd35325f46bf75046a643a66d28360ec71132750ec8 -#6 DONE 0.0s - -#7 [stage-1 1/4] FROM docker.io/library/nginx:alpine -#7 DONE 0.0s - -#8 [internal] load build context -#8 transferring context: 589.63MB 3.7s done -#8 DONE 3.8s - -#9 [build 2/6] WORKDIR /app -#9 CACHED - -#10 [build 3/6] COPY pwa/package*.json ./ -#10 CACHED - -#11 [build 4/6] RUN npm install -#11 CACHED - -#12 [build 5/6] COPY pwa/ ./ -#12 CACHED - -#13 [build 6/6] RUN npm run build -#13 0.305 -#13 0.305 > echoes-of-the-ashes-pwa@1.0.0 build -#13 0.305 > tsc && vite build -#13 0.305 -#13 4.381 vite v5.4.21 building for production... -#13 4.436 transforming... -#13 5.170 โœ“ 111 modules transformed. -#13 5.474 [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D` -#13 7.266 -#13 7.266 PWA v0.17.5 -#13 7.266 mode generateSW -#13 7.266 precache 3 entries (0.00 KiB) -#13 7.266 files generated -#13 7.266 dist/sw.js -#13 7.266 dist/workbox-4b126c97.js -#13 7.267 warnings -#13 7.267 One of the glob patterns doesn't match any files. Please remove or fix the following: { -#13 7.267 "globDirectory": "/app/dist", -#13 7.267 "globPattern": "**/*.{js,css,html,ico,svg,woff,woff2}", -#13 7.267 "globIgnores": [ -#13 7.267 "**/node_modules/**/*", -#13 7.267 "sw.js", -#13 7.267 "workbox-*.js" -#13 7.267 ] -#13 7.267 } -#13 7.267 -#13 7.273 x Build failed in 2.87s -#13 7.273 error during build: -#13 7.273 [vite-plugin-pwa:build] [plugin vite-plugin-pwa:build] src/components/common/GameProgressBar.tsx: There was an error during the build: -#13 7.273 Could not resolve "./InventoryModal.css" from "src/components/common/GameProgressBar.tsx" -#13 7.273 Additionally, handling the error in the 'buildEnd' hook caused the following error: -#13 7.273 Could not resolve "./InventoryModal.css" from "src/components/common/GameProgressBar.tsx" -#13 7.273 file: /app/src/components/common/GameProgressBar.tsx -#13 7.273 at getRollupError (file:///app/node_modules/rollup/dist/es/shared/parseAst.js:401:41) -#13 7.273 at file:///app/node_modules/rollup/dist/es/shared/node-entry.js:23347:39 -#13 7.273 at async catchUnfinishedHookActions (file:///app/node_modules/rollup/dist/es/shared/node-entry.js:22805:16) -#13 7.273 at async rollupInternal (file:///app/node_modules/rollup/dist/es/shared/node-entry.js:23330:5) -#13 7.273 at async build (file:///app/node_modules/vite/dist/node/chunks/dep-BK3b2jBa.js:65709:14) -#13 7.273 at async CAC. (file:///app/node_modules/vite/dist/node/cli.js:829:5) -#13 ERROR: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1 ------- - > [build 6/6] RUN npm run build: -7.273 Could not resolve "./InventoryModal.css" from "src/components/common/GameProgressBar.tsx" -7.273 Additionally, handling the error in the 'buildEnd' hook caused the following error: -7.273 Could not resolve "./InventoryModal.css" from "src/components/common/GameProgressBar.tsx" -7.273 file: /app/src/components/common/GameProgressBar.tsx -7.273 at getRollupError (file:///app/node_modules/rollup/dist/es/shared/parseAst.js:401:41) -7.273 at file:///app/node_modules/rollup/dist/es/shared/node-entry.js:23347:39 -7.273 at async catchUnfinishedHookActions (file:///app/node_modules/rollup/dist/es/shared/node-entry.js:22805:16) -7.273 at async rollupInternal (file:///app/node_modules/rollup/dist/es/shared/node-entry.js:23330:5) -7.273 at async build (file:///app/node_modules/vite/dist/node/chunks/dep-BK3b2jBa.js:65709:14) -7.273 at async CAC. (file:///app/node_modules/vite/dist/node/cli.js:829:5) ------- -Dockerfile.pwa:22 - --------------------- - - 20 | - - 21 | # Build the application - - 22 | >>> RUN npm run build - - 23 | - - 24 | # Production stage - --------------------- - -failed to solve: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1 - diff --git a/build_log_2.txt b/build_log_2.txt deleted file mode 100644 index 8eacc92..0000000 --- a/build_log_2.txt +++ /dev/null @@ -1,90 +0,0 @@ ---progress is a global compose flag, better use `docker compose --progress xx build ... - Image echoes_of_the_ashes-echoes_of_the_ashes_pwa Building - Image echoes_of_the_ashes-echoes_of_the_ashes_api Building -#1 [internal] load local bake definitions -#1 reading from stdin 1.25kB done -#1 DONE 0.0s - -#2 [internal] load build definition from Dockerfile.pwa -#2 transferring dockerfile: 810B done -#2 DONE 0.0s - -#3 [internal] load metadata for docker.io/library/nginx:alpine -#3 DONE 0.0s - -#4 [internal] load metadata for docker.io/library/node:20-alpine -#4 DONE 0.7s - -#5 [internal] load .dockerignore -#5 transferring context: 2B done -#5 DONE 0.0s - -#6 [build 1/6] FROM docker.io/library/node:20-alpine@sha256:09e2b3d9726018aecf269bd35325f46bf75046a643a66d28360ec71132750ec8 -#6 DONE 0.0s - -#7 [stage-1 1/4] FROM docker.io/library/nginx:alpine -#7 DONE 0.0s - -#8 [internal] load build context -#8 transferring context: 2.35MB 0.6s done -#8 DONE 0.6s - -#9 [build 2/6] WORKDIR /app -#9 CACHED - -#10 [build 3/6] COPY pwa/package*.json ./ -#10 CACHED - -#11 [build 4/6] RUN npm install -#11 CACHED - -#12 [build 5/6] COPY pwa/ ./ -#12 DONE 2.9s - -#13 [build 6/6] RUN npm run build -#13 0.256 -#13 0.256 > echoes-of-the-ashes-pwa@1.0.0 build -#13 0.256 > tsc && vite build -#13 0.256 -#13 4.328 vite v5.4.21 building for production... -#13 4.379 transforming... -#13 5.865 โœ“ 160 modules transformed. -#13 5.995 rendering chunks... -#13 6.118 computing gzip size... -#13 6.129 dist/manifest.webmanifest 0.46 kB -#13 6.129 dist/index.html 1.00 kB โ”‚ gzip: 0.50 kB -#13 6.129 dist/assets/index-DvVzkIfD.css 111.05 kB โ”‚ gzip: 19.61 kB -#13 6.129 dist/assets/workbox-window.prod.es5-vqzQaGvo.js 5.72 kB โ”‚ gzip: 2.35 kB -#13 6.129 dist/assets/index-RV0Szog0.js 454.56 kB โ”‚ gzip: 135.53 kB -#13 6.130 โœ“ built in 1.78s -#13 6.404 [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D` -#13 8.218 -#13 8.218 PWA v0.17.5 -#13 8.218 mode generateSW -#13 8.218 precache 7 entries (559.91 KiB) -#13 8.218 files generated -#13 8.218 dist/sw.js -#13 8.218 dist/workbox-4b126c97.js -#13 DONE 8.3s - -#7 [stage-1 1/4] FROM docker.io/library/nginx:alpine -#7 CACHED - -#14 [stage-1 2/4] COPY --from=build /app/dist /usr/share/nginx/html -#14 DONE 0.0s - -#15 [stage-1 3/4] COPY images/ /usr/share/nginx/html/images/ -#15 DONE 0.0s - -#16 [stage-1 4/4] COPY nginx.conf /etc/nginx/conf.d/default.conf -#16 DONE 0.0s - -#17 exporting to image -#17 exporting layers 0.1s done -#17 writing image sha256:04e99cea3a418401aff49901e27724e5132a721881a9c01bda68dd302a496587 done -#17 naming to docker.io/library/echoes_of_the_ashes-echoes_of_the_ashes_pwa done -#17 DONE 0.1s - -#18 resolving provenance for metadata file -#18 DONE 0.0s - Image echoes_of_the_ashes-echoes_of_the_ashes_pwa Built diff --git a/new-readme.md b/new-readme.md deleted file mode 100644 index 21aef83..0000000 --- a/new-readme.md +++ /dev/null @@ -1,188 +0,0 @@ -# Echoes of the Ash - -> A post-apocalyptic survival RPG - Browser-based MUD-style game - -![Status](https://img.shields.io/badge/Status-In%20Development-yellow) -![Platform](https://img.shields.io/badge/Platform-Web%20%7C%20PWA%20%7C%20Electron%20%7C%20Steam-blue) -![License](https://img.shields.io/badge/License-Proprietary-red) - -## ๐ŸŽฎ What is Echoes of the Ash? - -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. - ---- - -## ๐ŸŒŸ Current Game Features - -### Core Systems - -| Feature | Status | Description | -|---------|--------|-------------| -| **Character System** | โœ… Complete | Create characters with 4 stats (Strength, Agility, Endurance, Intellect) | -| **Health & Stamina** | โœ… Complete | HP/Stamina management with visual progress bars | -| **Leveling & XP** | โœ… Complete | XP-based progression with stat point allocation | -| **Inventory** | โœ… Complete | Weight/volume-based carrying capacity | -| **Equipment** | โœ… Complete | Weapon, armor, and backpack slots | -| **Combat (PvE)** | โœ… Complete | Turn-based combat with visual effects | -| **Combat (PvP)** | โœ… Complete | Player vs Player combat system | -| **Real-time Updates** | โœ… Complete | WebSocket-based live game state | - -### Exploration & Interaction - -| 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 | - ---- - -## ๐ŸŽฏ What Can Players Do? - -### 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 - -### 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 - -### 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 - -### Character Progression -- **4 Core Stats**: Strength, Agility, Endurance, Intellect -- **Equipment**: Weapons, armor, backpacks -- **Stat Points**: Earn 1 per level to customize your build - ---- - -## ๐Ÿ› ๏ธ Technical Stack - -### Frontend (PWA) -- **Framework**: React 18 + TypeScript -- **Build Tool**: Vite -- **State Management**: Zustand -- **Real-time**: WebSocket connections -- **Styling**: Custom CSS with dark theme - -### Backend (API) -- **Framework**: FastAPI (Python) -- **Database**: SQLite (development) / PostgreSQL (production) -- **Cache**: Redis for real-time state -- **Auth**: JWT tokens - -### Desktop (Electron) -- **Framework**: Electron 28 -- **Steam SDK**: steamworks.js integration -- **Builds**: Windows (NSIS, Portable), Linux (AppImage, DEB), macOS - ---- - -## ๐Ÿ“Š Asset Summary - -| 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 | - ---- - -## ๐Ÿ—บ๏ธ Roadmap - -### In Progress -- [ ] Sound effects and ambient music -- [ ] Quest/mission system -- [ ] NPC dialogue trees - -### 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 - ---- - -## ๐Ÿš€ Running the Game - -### Web/PWA (Docker) -```bash -docker compose up echoes_of_the_ashes_pwa echoes_of_the_ashes_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 -``` - ---- - -## ๐Ÿ“ Additional Documentation - -- [Game Mechanics](docs/game/MECHANICS.md) - Detailed gameplay systems -- [API Documentation](docs/api/) - Backend endpoints reference -- [Development Guide](docs/development/) - Contributing and architecture -- [Map Editor](web-map/README.md) - World building tools - ---- - -**Version**: 1.0.0-alpha -**Last Updated**: December 2025 diff --git a/old/ACCOUNT_PLAYER_SEPARATION_PLAN.md b/old/ACCOUNT_PLAYER_SEPARATION_PLAN.md deleted file mode 100644 index 8398183..0000000 --- a/old/ACCOUNT_PLAYER_SEPARATION_PLAN.md +++ /dev/null @@ -1,702 +0,0 @@ -# Account & Player Separation - Major Refactor Plan - -## Overview -Separate authentication (accounts) from gameplay (characters/players) to support: -- Multiple characters per account -- Free tier: 1 character -- Premium tier: Up to 10 characters -- Character customization at creation -- Email-based login (no username) - ---- - -## 1. New Database Schema - -### Accounts Table (Authentication) -```sql -CREATE TABLE accounts ( - id SERIAL PRIMARY KEY, - email VARCHAR(255) UNIQUE NOT NULL, - password_hash VARCHAR(255), -- NULL for Steam/OAuth - steam_id VARCHAR(255) UNIQUE, -- Steam integration - account_type VARCHAR(20) DEFAULT 'web', -- 'web', 'steam' - premium_expires_at TIMESTAMP, -- NULL = lifetime premium - email_verified BOOLEAN DEFAULT FALSE, - email_verification_token VARCHAR(255), - password_reset_token VARCHAR(255), - password_reset_expires TIMESTAMP, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_login_at TIMESTAMP, - CONSTRAINT check_account_type CHECK (account_type IN ('web', 'steam')) -); - -CREATE INDEX idx_accounts_email ON accounts(email); -CREATE INDEX idx_accounts_steam_id ON accounts(steam_id); -``` - -### Characters Table (Gameplay) -```sql -CREATE TABLE characters ( - id SERIAL PRIMARY KEY, - account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, - name VARCHAR(100) UNIQUE NOT NULL, -- Character name (unique across all players) - avatar_data TEXT, -- JSON for avatar customization - - -- RPG Stats - level INTEGER DEFAULT 1, - xp INTEGER DEFAULT 0, - hp INTEGER DEFAULT 100, - max_hp INTEGER DEFAULT 100, - stamina INTEGER DEFAULT 100, - max_stamina INTEGER DEFAULT 100, - - -- Base Attributes (start with 0, player allocates 20 points) - strength INTEGER DEFAULT 0, - agility INTEGER DEFAULT 0, - endurance INTEGER DEFAULT 0, - intellect INTEGER DEFAULT 0, - unspent_points INTEGER DEFAULT 20, -- Initial stat points to allocate - - -- Game State - location_id VARCHAR(255) DEFAULT 'cabin', - is_dead BOOLEAN DEFAULT FALSE, - last_movement_time REAL DEFAULT 0, - - -- Timestamps - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT check_unspent_points CHECK (unspent_points >= 0) -); - -CREATE INDEX idx_characters_account_id ON characters(account_id); -CREATE INDEX idx_characters_name ON characters(name); -CREATE INDEX idx_characters_location_id ON characters(location_id); -``` - -### Character Limits -```sql --- Enforce character limits via application logic: --- Free accounts: MAX 1 character --- Premium accounts: MAX 10 characters -``` - ---- - -## 2. Avatar System - -### Avatar Data Structure (JSON) -```json -{ - "preset": "warrior", // Optional preset - "body": { - "skin_tone": "#f5d6c6", - "build": "athletic" // slim, athletic, heavy - }, - "hair": { - "style": "short", - "color": "#3d2817" - }, - "face": { - "eyes": "blue", - "facial_hair": "none" - }, - "equipped_display": { - "helmet": "iron_helmet", // Shows equipped items on avatar - "armor": "leather_chest", - "weapon": "iron_sword" - } -} -``` - -### Avatar Options -**Phase 1 (MVP):** Simple presets -- 10 preset avatars (warrior, mage, rogue, etc.) -- Color variations - -**Phase 2 (Future):** Dynamic avatar -- Shows equipped armor/weapons -- Customizable features -- Level-based cosmetic unlocks - ---- - -## 3. Migration Script - -### `migrate_account_player_separation.py` -```python -""" -Major migration: Separate accounts from characters -1. Create accounts table -2. Create characters table -3. Migrate existing players to new structure -4. Update all foreign keys -5. Drop old players table (after backup) -""" - -Steps: -1. Backup current players table -2. Create accounts table -3. For each existing player: - - Create account with email (generate if missing) - - Create character from player data - - Migrate inventory, equipment, stats, etc. -4. Update all foreign key references: - - inventory.player_id -> character_id - - equipment.player_id -> character_id - - dropped_items references - - combat references - - etc. -5. Test thoroughly -6. Drop old players table -``` - ---- - -## 4. Authentication Flow Changes - -### Registration (Email-based) -``` -POST /api/auth/register -{ - "email": "player@example.com", - "password": "securepass" -} - -Response: -{ - "access_token": "...", - "account": { - "id": 1, - "email": "player@example.com", - "account_type": "web", - "is_premium": false, - "characters": [] // Empty on first register - }, - "needs_character_creation": true -} -``` - -### Login (Email-based) -``` -POST /api/auth/login -{ - "email": "player@example.com", - "password": "securepass" -} - -Response: -{ - "access_token": "...", - "account": {...}, - "characters": [ - { - "id": 1, - "name": "Aragorn", - "level": 15, - "avatar_data": {...}, - "last_played_at": "2025-11-09T..." - } - ] -} -``` - -### Character Selection -``` -POST /api/character/select -{ - "character_id": 1 -} - -Response: -{ - "character": {...full character data...}, - "location": {...}, - "inventory": [...], - "equipment": {...} -} -``` - -### Character Creation -``` -POST /api/character/create -{ - "name": "Aragorn", - "avatar": { - "preset": "warrior", - ... - }, - "stats": { - "strength": 8, - "agility": 5, - "endurance": 4, - "intellect": 3 - } // Must total 20 points -} - -Validation: -- Free users: Check character count < 1 -- Premium users: Check character count < 10 -- Name must be unique -- Stats must total exactly 20 -``` - ---- - -## 5. JWT Token Structure - -### Old (Current) -```json -{ - "player_id": 1, - "exp": 1699564800 -} -``` - -### New -```json -{ - "account_id": 1, - "character_id": 5, // Set after character selection - "account_type": "web", - "is_premium": false, - "exp": 1699564800 -} -``` - -**Flow:** -1. Login โ†’ Get token with `account_id`, no `character_id` -2. Select character โ†’ New token with `character_id` -3. All game endpoints require `character_id` in token - ---- - -## 6. UI Changes Required - -### A. Login/Register Screen Redesign - -**Current:** Simple form -**New:** Modern authentication UI - -```tsx - - - - - - - Forgot Password? - - - - - - I agree to Terms - - - - - // Future - -``` - -**Design:** -- Dark fantasy theme -- Animated background (subtle fire/ash effects) -- Elden Ring / Dark Souls inspired -- Responsive (mobile-first) - -### B. Character Selection Screen - -```tsx - -
- - -
- - - {characters.map(char => ( - selectCharacter(char.id)} - /> - ))} - - {canCreateMore && ( - setShowCreation(true)} - /> - )} - - - {!isPremium && characters.length >= 1 && ( - - Upgrade to Premium for 9 more character slots! - - )} -
-``` - -### C. Character Creation Screen - -```tsx - - - - - - - - - {presets.map(preset => ( - setAvatar(preset)} - /> - ))} - - - - - allocateStat(stat, amount)} - /> - - - - - - - {pointsRemaining} / 20 - - - - - - - -``` - ---- - -## 7. Steam Integration Specifics - -### Do You Need Two Executables? - -**Answer: NO, one executable with runtime detection** - -```typescript -// At app startup -const config = { - isSteam: checkSteamRuntime(), // Detect Steam overlay - apiUrl: process.env.API_URL || 'https://api.game.com', - steamAppId: process.env.STEAM_APP_ID -}; - -if (config.isSteam) { - // Initialize Steamworks - await initSteamworks(); - - // Auto-login with Steam - const steamTicket = await getSteamAuthTicket(); - const authResponse = await api.post('/api/auth/steam/login', { - steam_ticket: steamTicket - }); - - // Skip email/password login, go straight to character selection -} else { - // Show email/password login -} -``` - -**Build Configuration:** -```json -{ - "builds": { - "web": { - "platform": "web", - "steamworks": false - }, - "steam-windows": { - "platform": "windows", - "steamworks": true, - "steam_app_id": "1000000" - }, - "steam-linux": { - "platform": "linux", - "steamworks": true - }, - "standalone-windows": { - "platform": "windows", - "steamworks": false - } - } -} -``` - ---- - -## 8. Tauri Build Setup - -### Project Structure -``` -echoes-desktop/ -โ”œโ”€โ”€ src-tauri/ -โ”‚ โ”œโ”€โ”€ src/ -โ”‚ โ”‚ โ”œโ”€โ”€ main.rs -โ”‚ โ”‚ โ”œโ”€โ”€ steam.rs # Steamworks integration -โ”‚ โ”‚ โ”œโ”€โ”€ auth.rs # Authentication logic -โ”‚ โ”‚ โ””โ”€โ”€ storage.rs # Local storage/cache -โ”‚ โ”œโ”€โ”€ icons/ -โ”‚ โ”œโ”€โ”€ Cargo.toml -โ”‚ โ””โ”€โ”€ tauri.conf.json -โ”œโ”€โ”€ src/ # Frontend (React) -โ”‚ โ”œโ”€โ”€ components/ -โ”‚ โ”œโ”€โ”€ screens/ -โ”‚ โ”‚ โ”œโ”€โ”€ Auth.tsx # Login/Register -โ”‚ โ”‚ โ”œโ”€โ”€ CharacterSelect.tsx -โ”‚ โ”‚ โ”œโ”€โ”€ CharacterCreate.tsx -โ”‚ โ”‚ โ””โ”€โ”€ Game.tsx -โ”‚ โ””โ”€โ”€ main.tsx -โ”œโ”€โ”€ assets/ # Bundled assets -โ””โ”€โ”€ package.json -``` - -### Installation Steps -```bash -# 1. Install Tauri CLI -cargo install tauri-cli - -# 2. Create Tauri project -npm create tauri-app - -# 3. Configure build -``` - -### tauri.conf.json -```json -{ - "build": { - "beforeDevCommand": "npm run dev", - "beforeBuildCommand": "npm run build", - "devPath": "http://localhost:5173", - "distDir": "../dist" - }, - "package": { - "productName": "Echoes of the Ashes", - "version": "1.0.0" - }, - "tauri": { - "allowlist": { - "all": false, - "fs": { - "scope": ["$APPDATA/echoes-of-ashes/*"] - }, - "http": { - "scope": ["https://api.echoesoftheash.com/*"] - } - }, - "bundle": { - "active": true, - "targets": ["msi", "app", "deb"], // Windows, Mac, Linux - "identifier": "com.echoesoftheash.game", - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/icon.icns", - "icons/icon.ico" - ], - "resources": ["assets/*"], // Bundle game assets - "externalBin": ["bin/steamworks"], // Steam DLL - "windows": { - "certificateThumbprint": null, - "digestAlgorithm": "sha256", - "timestampUrl": "" - } - }, - "security": { - "csp": "default-src 'self'; connect-src 'self' https://api.echoesoftheash.com" - }, - "updater": { - "active": true, - "endpoints": [ - "https://releases.echoesoftheash.com/{{target}}/{{current_version}}" - ], - "dialog": true, - "pubkey": "YOUR_PUBLIC_KEY" - } - } -} -``` - -### Build Commands -```json -{ - "scripts": { - "dev": "vite", - "build": "vite build", - "tauri:dev": "tauri dev", - "tauri:build": "tauri build", - "tauri:build:steam": "STEAM_ENABLED=true tauri build", - "tauri:build:standalone": "STEAM_ENABLED=false tauri build" - } -} -``` - -### Steamworks Integration (Rust) -```rust -// src-tauri/src/steam.rs -use steamworks::Client; - -pub struct SteamManager { - client: Option, -} - -impl SteamManager { - pub fn new(app_id: u32) -> Result { - match Client::init_app(app_id) { - Ok((client, _single)) => { - Ok(Self { client: Some(client) }) - } - Err(e) => Err(format!("Failed to init Steam: {:?}", e)) - } - } - - pub fn get_steam_id(&self) -> Option { - self.client.as_ref().map(|c| { - c.user().steam_id().raw() - }) - } - - pub fn get_auth_session_ticket(&self) -> Option> { - // Implementation - None - } -} -``` - ---- - -## 9. Implementation Phases - -### Phase 1: Database Refactor (Week 1) -- [ ] Create migration script -- [ ] Test migration on dev database -- [ ] Create accounts + characters tables -- [ ] Migrate existing data -- [ ] Update all FK references -- [ ] Test thoroughly - -### Phase 2: Auth System (Week 1-2) -- [ ] Email-based login/register -- [ ] JWT with account_id + character_id -- [ ] Character selection endpoint -- [ ] Character creation endpoint -- [ ] Character limit enforcement - -### Phase 3: UI Redesign (Week 2-3) -- [ ] New login/register screen -- [ ] Character selection screen -- [ ] Character creation screen -- [ ] Avatar system (presets) -- [ ] Stat allocation UI - -### Phase 4: Steam Integration (Week 3-4) -- [ ] Set up Steamworks SDK -- [ ] Steam authentication backend -- [ ] Steam auto-login flow -- [ ] Test on Steam - -### Phase 5: Tauri Desktop (Week 4-5) -- [ ] Set up Tauri project -- [ ] Asset bundling -- [ ] Build pipeline -- [ ] Steam runtime detection -- [ ] Auto-updater -- [ ] Test builds (Win/Mac/Linux) - -### Phase 6: Testing & Polish (Week 5-6) -- [ ] End-to-end testing -- [ ] Performance optimization -- [ ] Bug fixes -- [ ] Documentation -- [ ] Beta release - ---- - -## 10. Breaking Changes & Risks - -### Database -- **MAJOR:** Complete schema change -- **Risk:** Data loss if migration fails -- **Mitigation:** Full backup before migration, rollback plan - -### Authentication -- **MAJOR:** Login now uses email, not username -- **Risk:** Existing users can't login -- **Mitigation:** Send email to all users about change - -### API -- **MAJOR:** Most endpoints change from player_id to character_id -- **Risk:** All API clients break -- **Mitigation:** Version API (v2), deprecate v1 - -### Frontend -- **MAJOR:** Complete auth flow redesign -- **Risk:** UX confusion -- **Mitigation:** Tutorial on first login after update - ---- - -## 11. Rollback Plan - -If migration fails: -1. Restore database from backup -2. Revert code changes -3. Restart containers with old version -4. Investigate issue -5. Fix and retry - -**Backup Strategy:** -```bash -# Before migration -docker exec echoes_of_the_ashes_db pg_dump -U postgres gamedb > backup_$(date +%Y%m%d).sql - -# Restore if needed -docker exec -i echoes_of_the_ashes_db psql -U postgres gamedb < backup_20251109.sql -``` - ---- - -## Next Steps - -1. **Review this plan** - Confirm approach -2. **Create detailed migration script** - Handle all edge cases -3. **Set up dev environment** - Test migration there first -4. **Implement Phase 1** - Database refactor -5. **Update authentication** - Email-based login -6. **Build UI screens** - Character selection/creation -7. **Integrate Steam** - Steamworks SDK -8. **Create Tauri build** - Desktop client - -**Estimated Timeline:** 6 weeks full-time - -**Do you want me to start implementing Phase 1 (database refactor)?** diff --git a/old/API_SUBDOMAIN_COMPLETE.md b/old/API_SUBDOMAIN_COMPLETE.md deleted file mode 100644 index 46baf0b..0000000 --- a/old/API_SUBDOMAIN_COMPLETE.md +++ /dev/null @@ -1,234 +0,0 @@ -# โœ… API Subdomain - COMPLETE & WORKING! - -## What Changed - -### Old Architecture (REMOVED) -``` -Browser โ†’ Traefik โ†’ PWA nginx โ†’ API (internal proxy) - โ†“ - /api/ โ†’ http://api:8000/ - /ws/ โ†’ http://api:8000/ws/ -``` - -### New Architecture (CURRENT) -``` -Browser โ†’ Traefik โ†’ API (api.echoesoftheashgame.patacuack.net:8000) -Browser โ†’ Traefik โ†’ PWA (echoesoftheashgame.patacuack.net:80) -``` - -## Why This Is Better - -1. **Cleaner Separation**: Frontend and backend are completely separate services -2. **Better Performance**: No nginx proxy hop - direct Traefik โ†’ API -3. **Easier Debugging**: Clear separation in logs and network requests -4. **Independent Scaling**: Can scale PWA and API separately -5. **Standard Architecture**: Industry standard microservices pattern - -## Verified Working โœ… - -### API Subdomain Tests -```bash -# Health endpoint -$ curl https://api.echoesoftheashgame.patacuack.net/health -{"status":"healthy","version":"2.0.0","locations_loaded":14,"items_loaded":42} - -# Login endpoint (with wrong password) -$ curl -X POST https://api.echoesoftheashgame.patacuack.net/api/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username":"test","password":"wrong"}' -{"detail":"Invalid username or password"} - -# โœ… Both returning proper responses! -``` - -### PWA Configuration -```bash -# PWA loads correctly -$ curl -I https://echoesoftheashgame.patacuack.net -HTTP/2 200 โœ… - -# API URL is baked into build -$ docker exec echoes_of_the_ashes_pwa sh -c 'grep -o "api\.echoesoftheashgame\.patacuack\.net" /usr/share/nginx/html/assets/index-*.js' -api.echoesoftheashgame.patacuack.net โœ… -``` - -### nginx Configuration -```bash -# No more /api/ or /ws/ proxy routes -$ docker exec echoes_of_the_ashes_pwa cat /etc/nginx/conf.d/default.conf | grep location - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { - location /sw.js { - location /workbox-*.js { - location /manifest.webmanifest { - location / { - -# โœ… Only static file serving! -``` - -## API Endpoint Reference - -### Base URLs -- **API**: `https://api.echoesoftheashgame.patacuack.net` -- **PWA**: `https://echoesoftheashgame.patacuack.net` - -### REST Endpoints (all have /api prefix) -``` -POST https://api.echoesoftheashgame.patacuack.net/api/auth/register -POST https://api.echoesoftheashgame.patacuack.net/api/auth/login -GET https://api.echoesoftheashgame.patacuack.net/api/auth/me -GET https://api.echoesoftheashgame.patacuack.net/api/game/state -GET https://api.echoesoftheashgame.patacuack.net/api/game/profile -POST https://api.echoesoftheashgame.patacuack.net/api/game/move -POST https://api.echoesoftheashgame.patacuack.net/api/game/interact -... etc -``` - -### WebSocket Endpoint (no /api prefix) -``` -wss://api.echoesoftheashgame.patacuack.net/ws/game/{token} -``` - -## PWA Configuration - -### Environment Variable -```typescript -// pwa/src/services/api.ts -const API_URL = import.meta.env.VITE_API_URL || ( - import.meta.env.PROD - ? 'https://api.echoesoftheashgame.patacuack.net/api' // โ† /api suffix for REST - : 'http://localhost:8000/api' -) -``` - -### WebSocket Configuration -```typescript -// pwa/src/hooks/useGameWebSocket.ts -const API_BASE = import.meta.env.VITE_API_URL || ( - import.meta.env.PROD - ? 'https://api.echoesoftheashgame.patacuack.net' // โ† no /api for WebSocket - : 'http://localhost:8000' -); -const wsBase = API_BASE.replace(/\/api$/, '').replace(/^http/, 'ws'); -// Results in: wss://api.echoesoftheashgame.patacuack.net/ws/game/{token} -``` - -## Docker Configuration - -### docker-compose.yml -```yaml -echoes_of_the_ashes_api: - networks: - - default_docker - - traefik # โ† Now exposed via Traefik! - labels: - - traefik.enable=true - - traefik.http.routers.echoesoftheashapi.rule=Host(`api.echoesoftheashgame.patacuack.net`) - - traefik.http.services.echoesoftheashapi.loadbalancer.server.port=8000 - -echoes_of_the_ashes_pwa: - build: - args: - VITE_API_URL: https://api.echoesoftheashgame.patacuack.net/api # โ† Baked into build - networks: - - default_docker - - traefik -``` - -### Dockerfile.pwa -```dockerfile -ARG VITE_API_URL=https://api.echoesoftheashgame.patacuack.net/api -ENV VITE_API_URL=$VITE_API_URL -``` - -### nginx.conf -```nginx -# REMOVED: -# location /api/ { proxy_pass http://api:8000/; } -# location /ws/ { proxy_pass http://api:8000/ws/; } - -# NOW ONLY: -location / { - try_files $uri $uri/ /index.html; -} -``` - -## DNS Configuration - -**Required DNS Records:** -``` -A api.echoesoftheashgame.patacuack.net โ†’ -A echoesoftheashgame.patacuack.net โ†’ -``` - -**TLS Certificates:** -- Both subdomains get Let's Encrypt certificates from Traefik -- Auto-renewal configured -- Both use certResolver=production - -## Testing Checklist โœ… - -- [x] API health endpoint returns 200 -- [x] API login endpoint returns proper error for invalid credentials -- [x] PWA loads and serves static files -- [x] API URL is embedded in PWA build (not runtime fallback) -- [x] nginx config simplified (no proxy routes) -- [x] Both domains have valid TLS certificates -- [x] WebSocket endpoint exists (returns 404 for invalid token as expected) -- [x] Traefik routes both services correctly - -## What to Test in Browser - -1. **Open PWA**: https://echoesoftheashgame.patacuack.net -2. **Check Network Tab**: - - API calls should go to `api.echoesoftheashgame.patacuack.net/api/*` - - WebSocket should connect to `wss://api.echoesoftheashgame.patacuack.net/ws/game/*` -3. **Login/Register**: Should work normally -4. **Game Actions**: All should work (move, combat, inventory, etc.) -5. **WebSocket**: Should connect and show real-time updates - -## Troubleshooting - -### If API calls fail -```bash -# Check API is running -docker compose logs echoes_of_the_ashes_api - -# Test health endpoint -curl https://api.echoesoftheashgame.patacuack.net/health - -# Check Traefik routing -docker compose logs | grep api.echoesoftheashgame -``` - -### If WebSocket fails -```bash -# Check logs for WebSocket connections -docker compose logs echoes_of_the_ashes_api | grep -i websocket - -# Verify token is valid (login to get fresh token) -# Old tokens won't work after rebuild -``` - -### If PWA loads but can't connect to API -```bash -# Verify API URL is in build -docker exec echoes_of_the_ashes_pwa sh -c 'grep -o "api\.echoesoftheashgame\.patacuack\.net" /usr/share/nginx/html/assets/index-*.js' - -# If not found, rebuild PWA -docker compose build echoes_of_the_ashes_pwa -docker compose up -d echoes_of_the_ashes_pwa -``` - -## Summary - -โœ… **API subdomain deployed and working** -โœ… **PWA simplified (static files only)** -โœ… **Direct Traefik routing (no nginx proxy)** -โœ… **Both services have valid TLS** -โœ… **Configuration verified in build** - -**The architecture is now cleaner, faster, and easier to maintain!** ๐Ÿš€ - ---- - -**Note:** Users need to **logout and login again** after this deployment to get fresh JWT tokens. Old tokens from the previous architecture won't work because the issuer URL changed. diff --git a/old/API_SUBDOMAIN_MIGRATION.md b/old/API_SUBDOMAIN_MIGRATION.md deleted file mode 100644 index e04e6a7..0000000 --- a/old/API_SUBDOMAIN_MIGRATION.md +++ /dev/null @@ -1,143 +0,0 @@ -# API Subdomain Migration - -## Overview -Migrated from proxying API requests through PWA nginx to a dedicated API subdomain. This is a cleaner architecture with better separation of concerns. - -## Changes Made - -### 1. **Architecture Change** -**Old:** -``` -Browser โ†’ Traefik โ†’ PWA nginx โ†’ API (internal proxy) -``` - -**New:** -``` -Browser โ†’ Traefik โ†’ API (direct) -Browser โ†’ Traefik โ†’ PWA (static files only) -``` - -### 2. **docker-compose.yml** -- Added Traefik labels to `echoes_of_the_ashes_api` service -- Exposed API on subdomain: `api.echoesoftheashgame.patacuack.net` -- Added `traefik` network to API service -- Added build args to PWA service for `VITE_API_URL` - -### 3. **nginx.conf** -- Removed `/api/` proxy location block -- Removed `/ws/` proxy location block -- nginx now only serves static PWA files - -### 4. **Dockerfile.pwa** -- Added `ARG VITE_API_URL` build argument -- Default value: `https://api.echoesoftheashgame.patacuack.net` -- Sets environment variable during build - -### 5. **pwa/src/services/api.ts** -- Changed baseURL to use `VITE_API_URL` environment variable -- Falls back to `https://api.echoesoftheashgame.patacuack.net` in production -- Falls back to `http://localhost:8000` in development - -### 6. **pwa/src/hooks/useGameWebSocket.ts** -- Updated WebSocket URL to use same API subdomain -- Converts `https://api.echoesoftheashgame.patacuack.net` to `wss://api.echoesoftheashgame.patacuack.net` - -### 7. **pwa/src/vite-env.d.ts** -- Added `VITE_API_URL` to TypeScript environment types - -## DNS Configuration Required - -โš ๏ธ **IMPORTANT:** You need to add a DNS A record: - -``` -Host: api.echoesoftheashgame -Points to: -``` - -Or if using CNAME: -``` -Host: api.echoesoftheashgame -CNAME: echoesoftheashgame.patacuack.net -``` - -## Benefits - -1. **Cleaner Architecture** - - Separation of concerns (PWA vs API) - - No nginx proxy complexity - - Direct Traefik routing to API - -2. **Better Performance** - - One less hop (no nginx proxy) - - Direct TLS termination at Traefik - - WebSocket connections more stable - -3. **Easier Debugging** - - Clear separation in logs - - Distinct URLs for frontend vs backend - - Better CORS visibility - -4. **Scalability** - - Can scale API and PWA independently - - Can add load balancing per service - - Can deploy to different servers if needed - -## Deployment Steps - -```bash -# Build with new configuration -docker compose build echoes_of_the_ashes_api echoes_of_the_ashes_pwa - -# Deploy both services -docker compose up -d echoes_of_the_ashes_api echoes_of_the_ashes_pwa - -# Check Traefik picked up the new routes -docker compose logs echoes_of_the_ashes_api | grep -i traefik - -# Wait for TLS certificate generation (30-60 seconds) -# Test API endpoint -curl https://api.echoesoftheashgame.patacuack.net/health - -# Test PWA loads -curl -I https://echoesoftheashgame.patacuack.net -``` - -## API Endpoints - -All API endpoints are now at: -- `https://api.echoesoftheashgame.patacuack.net/api/auth/login` -- `https://api.echoesoftheashgame.patacuack.net/api/auth/register` -- `https://api.echoesoftheashgame.patacuack.net/api/game/state` -- `https://api.echoesoftheashgame.patacuack.net/api/game/profile` -- etc. - -**Note:** API routes have `/api/` prefix in FastAPI - -WebSocket endpoint: -- `wss://api.echoesoftheashgame.patacuack.net/ws/game/{token}` - -**Note:** WebSocket routes do NOT have `/api/` prefix - -## Rollback Plan - -If needed, revert by: -1. Remove Traefik labels from API service -2. Restore nginx proxy locations for `/api/` and `/ws/` -3. Change `VITE_API_URL` back to PWA domain -4. Rebuild PWA - -## Testing Checklist - -- [ ] DNS resolves for `api.echoesoftheashgame.patacuack.net` -- [ ] API health endpoint returns 200 -- [ ] Login works from PWA -- [ ] WebSocket connects successfully -- [ ] Game functionality works end-to-end -- [ ] TLS certificate valid on API subdomain - -## Notes - -- PWA now ONLY serves static files (much simpler) -- API container directly exposed through Traefik -- Both services use Let's Encrypt certificates -- WebSocket timeout handled by Traefik (default 90s, configurable) diff --git a/old/BUGFIXES_2025-11-08.md b/old/BUGFIXES_2025-11-08.md deleted file mode 100644 index 3b64c04..0000000 --- a/old/BUGFIXES_2025-11-08.md +++ /dev/null @@ -1,281 +0,0 @@ -# Bug Fixes - November 8, 2025 - -## Overview -Fixed multiple issues with interactable cooldowns, combat flee mechanics, and performance optimizations. - -## Issues Fixed - -### 1. โœ… Cooldown Display Not Visible on Location Entry -**Problem**: When entering a location with active cooldowns, the UI didn't show them visually. Clicking the action would show "Wait X seconds" but the timer wasn't displayed. - -**Root Cause**: The frontend wasn't parsing the location API response to initialize the cooldown state. - -**Solution**: Added cooldown initialization in `fetchGameData()` (Game.tsx, lines 475-490): -- Parses `location.interactables` from API response -- Checks each action's `on_cooldown` and `cooldown_remaining` fields -- Converts remaining seconds to expiry timestamp: `Date.now() / 1000 + action.cooldown_remaining` -- Populates `interactableCooldowns` state with composite keys: `${instance_id}:${action_id}` -- Merges with existing cooldowns to avoid race conditions - -### 2. โœ… Unnecessary Background Task -**Problem**: Background task `cleanup_interactable_cooldowns()` was redundant since client-side timer already handles cooldown expiry. - -**User Suggestion**: "is it really necessary to have a background task checking for interactables cooldowns if we already send the time when someone arrives at a location or when someone in that location interacts with something and the client already keeps track of the time left?" - -**Solution**: Removed task from startup (background_tasks.py, line 586-598): -- Removed `cleanup_interactable_cooldowns(manager, world_locations)` from task list -- Added comment explaining client-side handling with server validation -- **Task count reduced from 7 to 6** -- Server still validates cooldowns when user attempts interaction (no exploit risk) - -### 3. โœ… Duplicate Flee Success Message -**Problem**: When successfully fleeing from combat, the message appeared twice in the combat log. - -**Root Cause**: The `combat_update` WebSocket handler was adding the message to combat log, AND `handleCombatAction` was also processing the response message. - -**Solution**: Removed duplicate message handling in WebSocket handler (Game.tsx, lines 177-201): -- Removed code that added `message.data.message` to combat log -- Added comment explaining why: "We don't add messages to combat log here since handleCombatAction already processes the response and adds messages. This prevents duplicates." -- WebSocket handler now only updates state (combat status, player HP/XP/level) - -### 4. โœ… Combat Log Cleanup on Failed Flee -**Problem**: When flee failed, combat log was being cleared and enemy HP flickered. - -**Root Cause**: The flee failure message `"Failed to flee! NPC_NAME attacks for X damage!"` contains the word "attacks", so it was incorrectly classified as an enemy message instead of a player message. This caused: -- The message to be delayed with "Enemy's turn..." animation -- Player HP to be updated via `fetchGameData()` instead of response data -- Race conditions causing combat log issues - -**Solution**: Updated message parsing to specifically handle flee messages (Game.tsx, lines 984-997): -- Check for "Failed to flee" first before other classifications -- Treat flee messages as player messages (shown immediately) -- Exclude flee messages from enemy message list -- This prevents the 2-second delay and keeps combat log intact - -### 5. โœ… HP Flickering on Failed Flee -**Problem**: Player HP would flicker when flee failed and enemy attacked. - -**Root Cause**: Same as #4 - incorrect message classification plus multiple `fetchGameData()` calls causing state updates in unpredictable order. - -**Solution**: Combined fix from #4 (proper message classification) with direct response data usage (Game.tsx, lines 1032-1043): -```typescript -// NOW update player HP directly from response data instead of fetching -if (data.player) { - setProfile(prev => prev ? { - ...prev, - hp: data.player.hp, - xp: data.player.xp ?? prev.xp, - level: data.player.level ?? prev.level - } : null) -} -``` - -### 6. โœ… Other Players Seeing 120 Seconds Cooldown -**Problem**: When a player interacted with an object, they saw the correct 60s cooldown, but other players in the same location saw 120 seconds. Reloading or changing location fixed it. - -**Root Cause**: Race condition between WebSocket message and `fetchGameData()` call: -1. User interacts, backend sets cooldown expiry to T+60 -2. Backend broadcasts WebSocket with `cooldown_expiry: T+60` (correct) -3. User's browser receives WebSocket, sets state to T+60 โœ“ -4. User's `handleInteract` calls `fetchGameData()` -5. `fetchGameData()` completes and calls `setInteractableCooldowns(cooldowns)` which REPLACES the entire object -6. This overwrites the WebSocket's correct value with recalculated value -7. Due to timing differences, this could be off by a few seconds or more - -**Solution**: Changed cooldown initialization to merge instead of replace (Game.tsx, line 489): -```typescript -// Merge with existing cooldowns instead of replacing to avoid race conditions -setInteractableCooldowns(prev => ({ ...prev, ...cooldowns })) -``` -Now the WebSocket's value takes precedence and isn't overwritten. - -### 7. โœ… Removed Unused WebSocket Handler -**Problem**: `interactable_ready` WebSocket case existed but was never sent (background task removed). - -**Solution**: Removed entire case block from WebSocket handler (Game.tsx): -- Handler deleted since server no longer sends these messages -- Cleaner code, less confusion -**Problem**: `interactable_ready` WebSocket case existed but was never sent (background task removed). - -**Solution**: Removed entire case block from WebSocket handler (Game.tsx): -- Handler deleted since server no longer sends these messages -- Cleaner code, less confusion - -## Technical Changes - -### Frontend (pwa/src/components/Game.tsx) - -**Cooldown Initialization with Race Condition Fix** (lines 475-490): -```typescript -// Initialize interactable cooldowns from location data -if (locationRes.data.interactables) { - const cooldowns: Record = {} - for (const interactable of locationRes.data.interactables) { - if (interactable.actions) { - for (const action of interactable.actions) { - if (action.on_cooldown && action.cooldown_remaining > 0) { - const cooldownKey = `${interactable.instance_id}:${action.id}` - cooldowns[cooldownKey] = Date.now() / 1000 + action.cooldown_remaining - } - } - } - } - // Merge with existing cooldowns instead of replacing to avoid race conditions - setInteractableCooldowns(prev => ({ ...prev, ...cooldowns })) -} -``` - -**Flee Message Classification Fix** (lines 984-997): -```typescript -// Parse the message to separate player and enemy actions -const messages = data.message.split('\n').filter((m: string) => m.trim()) - -// Find player action and enemy action -// Failed flee contains both, so check for "Failed to flee" first -const playerMessages = messages.filter((msg: string) => - msg.includes('You ') || msg.includes('Your ') || msg.includes('Failed to flee') -) -const enemyMessages = messages.filter((msg: string) => - !msg.includes('Failed to flee') && // Exclude "Failed to flee" from enemy messages - (msg.includes('attacks') || msg.includes('hits') || msg.includes('misses') || msg.includes('The ')) -) -``` - -**WebSocket Handler Cleanup** (lines 177-201): -```typescript -case 'combat_update': - // Update combat state from WebSocket (both PvE and PvP) - // Note: We don't add messages to combat log here since handleCombatAction - // already processes the response and adds messages. This prevents duplicates. - if (message.data) { - // Handle both PvE combat and PvP combat - if (message.data.combat) { - setCombatState(message.data.combat) - } else if (message.data.combat_over) { - setCombatState(null) - } - - // Update player HP/XP/Level - if (message.data.player) { - const player = message.data.player - setProfile(prev => prev ? { - ...prev, - hp: player.hp ?? prev.hp, - xp: player.xp ?? prev.xp, - level: player.level ?? prev.level - } : null) - } - - // Always fetch fresh game data to update PvP combat state - fetchGameData() - } - break -``` - -**Combat Action Handler - Direct HP Update** (lines 1032-1043 and 1069-1076): -```typescript -// Update player HP directly from response instead of fetching -if (data.player) { - setProfile(prev => prev ? { - ...prev, - hp: data.player.hp, - xp: data.player.xp ?? prev.xp, - level: data.player.level ?? prev.level - } : null) -} -``` - -### Backend (api/background_tasks.py) - -**Task Removal** (lines 586-598): -```python -async def start_background_tasks(manager, world_locations): - """Start all background tasks.""" - asyncio.create_task(cleanup_dead_players(manager)) - asyncio.create_task(regenerate_stamina(manager)) - asyncio.create_task(regenerate_hp(manager)) - asyncio.create_task(update_movement_cooldowns(manager)) - asyncio.create_task(cleanup_wandering_enemies(world_locations)) - asyncio.create_task(pvp_cooldown_cleanup(manager)) - # Interactable cooldowns are handled client-side with server validation - # asyncio.create_task(cleanup_interactable_cooldowns(manager, world_locations)) - - logger.info(f"โœ… Started 6 background tasks in this worker") -``` - -## Testing Verification - -### Before Deployment -- โœ… All containers built successfully -- โœ… No TypeScript compilation errors (only pre-existing lint warnings) -- โœ… Database schema unchanged (no migration needed) - -### After Deployment -- โœ… All 3 containers running (db, api, pwa) -- โœ… 6 background tasks started successfully -- โœ… WebSocket connections working -- โœ… No errors in logs -- โœ… API endpoints responding correctly - -### Test Scenarios - -1. **Cooldown Visibility**: - - Enter location with cooldown โ†’ Timer shows on button โœ“ - - Wait for expiry โ†’ Button becomes available โœ“ - - Interact with action โ†’ User sees 60s, other players also see 60s โœ“ - - Other players reload page โ†’ Still see correct remaining time โœ“ - -2. **Background Tasks**: - - Check logs โ†’ "Started 6 background tasks" โœ“ - - No interactable_cooldown task running โœ“ - -3. **Flee from Combat**: - - Flee successfully โ†’ Message appears once โœ“ - - Flee fails โ†’ Message shows immediately as player action โœ“ - - Flee fails โ†’ Combat log preserved โœ“ - - Flee fails โ†’ HP updates smoothly without flickering โœ“ - - Flee fails โ†’ No "Enemy's turn..." message (correct behavior) โœ“ - -## Performance Impact - -### API Call Reduction -- **Before**: Combat actions triggered `fetchGameData()` (5 API calls) -- **After**: Uses response data directly (0 extra API calls) -- **Improvement**: 5 fewer API calls per combat action - -### Background Task Reduction -- **Before**: 7 background tasks per worker -- **After**: 6 background tasks per worker -- **Improvement**: ~14% reduction in background processing - -### WebSocket Efficiency -- **Before**: WebSocket handler could trigger multiple state updates -- **After**: Minimal state updates, no duplicate messages -- **Improvement**: Cleaner state management, less re-rendering - -## Known Issues Status - -### โœ… Resolved -1. Cooldown display not visible on location entry -2. Unnecessary background task -3. Duplicate flee success messages -4. Combat log cleanup on failed flee -5. HP flickering on failed flee -6. Other players seeing 120 seconds cooldown -7. Removed unused WebSocket handler - -### ๐Ÿ” All Known Issues Fixed -- All reported bugs have been addressed and deployed - -## Deployment Information - -**Date**: November 8, 2025 -**Containers**: All 3 rebuilt and deployed -**Database**: No migration required -**Downtime**: ~10 seconds (rolling restart) -**Status**: โœ… Successful - -## Related Documents -- `JSON_PROGRESS_REPORT.md` - Per-action cooldown implementation -- `BUGFIXES_2025-10-17.md` - Previous bug fixes -- `ENHANCED_EDITOR_GUIDE.md` - Map editor updates diff --git a/old/CHANGELOG_STEAM_ICONS_2025-11-09.md b/old/CHANGELOG_STEAM_ICONS_2025-11-09.md deleted file mode 100644 index 5bea722..0000000 --- a/old/CHANGELOG_STEAM_ICONS_2025-11-09.md +++ /dev/null @@ -1,348 +0,0 @@ -# Summary of Changes - Steam Integration & Icon System - -## Date: November 9, 2025 - ---- - -## 1. Icon System Implementation - -### Created Icon Directory Structure -``` -images/icons/ -โ”œโ”€โ”€ items/ # Item icons (weapons, armor, consumables) -โ”œโ”€โ”€ ui/ # UI elements (buttons, menus) -โ”œโ”€โ”€ status/ # Status indicators (HP, stamina) -โ””โ”€โ”€ actions/ # Action icons (attack, defend, flee) -``` - -### Icon Specifications -- **Format:** SVG (recommended) or PNG -- **Size:** 64x64px standard, 128x64px large, 32x32px small -- **Naming:** kebab-case matching item IDs (e.g., `iron-sword.svg`) -- **Usage:** Icons referenced via `icon_path` field, emojis kept as fallback - -### Documentation -- Created `/images/icons/README.md` with full guidelines - ---- - -## 2. Database Migration - Steam Support - -### Migration Script: `migrate_steam_support.py` - -**Added Columns:** -```sql -ALTER TABLE players ADD COLUMN: -- steam_id VARCHAR(255) UNIQUE -- Steam user ID -- email VARCHAR(255) -- Required for web users -- premium_expires_at TIMESTAMP -- NULL = premium, timestamp = trial end -- account_type VARCHAR(20) -- 'web', 'steam', 'telegram' -``` - -**Removed:** -- `telegram_id` column (deprecated, no longer supporting Telegram) - -**Indexes Created:** -- `idx_players_steam_id` - Fast Steam ID lookups -- `idx_players_email` - Email verification/login - -**Constraints:** -- `CHECK (account_type IN ('web', 'steam', 'telegram'))` -- Steam ID must be unique -- Email used for password reset and communications - -### Migration Results -``` -โœ… Added columns successfully -โœ… Created indexes -โœ… Updated 5 existing users to 'web' account type -โœ… Dropped telegram_id column (no legacy users found) -โœ… Added account_type constraint -``` - ---- - -## 3. Premium System Design - -### Account Types - -**Web Users:** -- Email/password registration -- Free trial: Level 1-10 -- Premium: Full access after payment - -**Steam Users:** -- Auto-authenticated via Steam -- Always premium (owns game on Steam) -- No email/password needed - -### Premium Logic - -**Premium Status:** -```python -premium_expires_at == NULL # Lifetime premium (purchased or Steam) -premium_expires_at > now() # Active trial/subscription -premium_expires_at < now() # Expired, back to free tier -``` - -**Restrictions for Non-Premium (Level 10+):** -- โŒ No XP gain after level 10 -- โœ… Full map access (naturally gated by difficulty) -- โœ… Can party with premium players -- โœ… All items/crafting/combat features - ---- - -## 4. UI Fixes - Equipment Slots - -### Problem -Equipment slots changed size when items equipped due to emoji + button content. - -### Solution -**Fixed dimensions in `Game.css`:** -```css -.equipment-slot { - min-height: 100px; - max-height: 100px; - height: 100px; - overflow: hidden; /* Prevent content overflow */ -} -``` - -**Reduced button sizes:** -```css -.equipment-action-btn { - padding: 0.2rem 0.4rem; - font-size: 0.75rem; - white-space: nowrap; -} - -.equipment-emoji { - font-size: 1.2rem; /* Reduced from 1.5rem */ -} -``` - -**Result:** All equipment slots now same size, whether empty or filled. - ---- - -## 5. Comprehensive Planning Document - -### Created: `STEAM_AND_PREMIUM_PLAN.md` - -**Contents:** -1. **Account System** - Database schema, account types -2. **Distribution Channels** - Web, Steam, Standalone -3. **Asset Bundling Strategy** - Hybrid approach for desktop -4. **Steam Integration** - Steamworks SDK, authentication flow -5. **Build Variants** - Web vs Steam vs Standalone configs -6. **Premium Enforcement** - XP restrictions, helper functions -7. **Implementation Roadmap** - 5-phase plan -8. **Tech Stack Recommendations** - Tauri for desktop client -9. **Cost Estimates** - $100 Steam fee + payment processing -10. **Monetization Strategy** - Pricing options - -**Key Decisions:** -- **Desktop Framework:** Tauri (Rust + WebView) - - Smaller than Electron (~5MB vs 100MB+) - - Better security - - Native performance - -- **Asset Strategy:** Hybrid bundling - - Bundle core assets (~50MB) - - Lazy load rare content - - Cache everything locally - -- **Pricing Model:** Steam-paid, web-freemium - - Steam: $14.99 (full game) - - Web: Free trial to level 10, $4.99 upgrade - ---- - -## 6. Next Steps (Prioritized) - -### Immediate (This Week) -1. โœ… Database migration complete -2. โœ… Icon folders created -3. [ ] Update `/api/auth/register` to require email -4. [ ] Add premium check functions (`is_player_premium()`) -5. [ ] Implement XP restriction for non-premium level 10+ - -### Short Term (Next 2 Weeks) -6. [ ] Design and create icon set (replace emojis) -7. [ ] Payment integration (Stripe) -8. [ ] Email verification system -9. [ ] Premium upgrade endpoint -10. [ ] Premium status UI indicators - -### Medium Term (Next Month) -11. [ ] Set up Steamworks partner account -12. [ ] Prototype Tauri desktop app -13. [ ] Steam authentication flow -14. [ ] Asset bundling system - -### Long Term (2-3 Months) -15. [ ] Complete Steam integration -16. [ ] Desktop client with auto-updater -17. [ ] Beta testing -18. [ ] Official launch - ---- - -## 7. Required External Setup - -### Steamworks Partner -1. Sign up at: https://partner.steamgames.com/ -2. Pay $100 app submission fee (one-time) -3. Create app entry -4. Get Steam App ID and Web API Key -5. Download Steamworks SDK - -### Payment Processing -1. **Stripe Account** - - Sign up: https://stripe.com/ - - Get API keys - - Set up webhook for payment events - -2. **PayPal Business** (optional) - - Alternative payment method - - Popular in some regions - -### CDN for Assets -1. **CloudFlare** (recommended, free tier) - - Fast global delivery - - Free SSL - - DDoS protection - -2. **AWS CloudFront** (alternative) - - More control - - Pay per use (~$0.085/GB) - ---- - -## 8. Files Modified - -### New Files -- `/images/icons/README.md` - Icon system documentation -- `/migrate_steam_support.py` - Database migration script -- `/STEAM_AND_PREMIUM_PLAN.md` - Complete implementation plan -- THIS FILE - Change summary - -### Modified Files -- `/pwa/src/components/Game.css` - Fixed equipment slot sizing -- Database: `players` table structure updated - -### New Directories -``` -/images/icons/ -โ”œโ”€โ”€ items/ -โ”œโ”€โ”€ ui/ -โ”œโ”€โ”€ status/ -โ””โ”€โ”€ actions/ -``` - ---- - -## 9. Breaking Changes - -### For Existing Users -- โš ๏ธ `telegram_id` removed (no Telegram users in database) -- โœ… Existing users marked as `account_type='web'` -- โœ… All existing users start with free tier (can be upgraded) - -### For API Clients -- โš ๏ธ `/api/auth/register` will soon require `email` field (not yet enforced) -- โœ… All existing endpoints remain compatible -- โœ… New optional fields in player responses - ---- - -## 10. Testing Checklist - -### Database Migration -- [x] Migration runs without errors -- [x] Existing users preserved -- [x] Indexes created successfully -- [x] Constraints applied - -### UI Changes -- [ ] Equipment slots same size empty/filled -- [ ] Button text doesn't wrap -- [ ] Icons fit within slots -- [ ] Mobile responsive - -### Premium System (To Test) -- [ ] XP gain stops at level 10 for non-premium -- [ ] Premium status displayed correctly -- [ ] Steam users auto-premium -- [ ] Payment flow works - ---- - -## 11. Security Considerations - -### Added -- Email field for account recovery -- Steam ID validation (external verification) -- Account type constraints - -### Todo -- Email verification before account activation -- Steam ticket validation on server -- Rate limiting on premium upgrade attempts -- Secure payment token handling - ---- - -## 12. Performance Impact - -### Database -- **Indexes added:** +2 (steam_id, email) - Minimal impact -- **Column additions:** +4 per player - ~100 bytes per row -- **Query performance:** Improved (indexed lookups) - -### UI -- **Fixed slot heights:** Prevents layout shifts (better CLS) -- **Smaller buttons:** Reduced DOM size -- **Icon system:** No impact yet (emojis still used) - ---- - -## Questions for Product Decision - -1. **Free Tier Level Cap:** - - Current: Level 10 - - Alternative: Level 5? Level 15? - - Recommendation: Level 10 (enough to hook players) - -2. **Premium Pricing:** - - Web upgrade: $4.99? $9.99? - - Steam full game: $14.99? $19.99? - - Recommendation: $4.99 web, $14.99 Steam - -3. **Email Verification:** - - Required immediately on registration? - - Or allow play, verify for premium upgrade? - - Recommendation: Optional initially, required for premium - -4. **Steam-Exclusive Features:** - - Any features only for Steam users? - - Or complete parity with web premium? - - Recommendation: Complete parity (fair for web buyers) - ---- - -## Contact & Support - -For questions about this implementation: -- Steam Integration: See `STEAM_AND_PREMIUM_PLAN.md` Section 4 -- Database Schema: See `migrate_steam_support.py` -- Icon System: See `/images/icons/README.md` -- UI Changes: See `pwa/src/components/Game.css` lines 1955-2050 - ---- - -**Migration Status:** โœ… Complete and Deployed -**UI Fixes:** โœ… Complete (Needs Testing) -**Planning:** โœ… Complete -**Implementation:** ๐Ÿ”„ Ready to Begin diff --git a/old/Dockerfile.api.old b/old/Dockerfile.api.old deleted file mode 100644 index 0705a7e..0000000 --- a/old/Dockerfile.api.old +++ /dev/null @@ -1,30 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - postgresql-client \ - && rm -rf /var/lib/apt/lists/* - -# Copy requirements -COPY requirements.txt ./ -COPY api/requirements.txt ./api-requirements.txt - -# Install Python dependencies -RUN pip install --no-cache-dir -r requirements.txt && \ - pip install --no-cache-dir -r api-requirements.txt - -# Copy application code -COPY bot/ ./bot/ -COPY data/ ./data/ -COPY api/ ./api/ -COPY gamedata/ ./gamedata/ -COPY migrate_*.py ./ - -# Expose port -EXPOSE 8000 - -# Run the API server -CMD ["python", "-m", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/old/EMERGENCY_FIX_2025-11-09.md b/old/EMERGENCY_FIX_2025-11-09.md deleted file mode 100644 index 09a42a5..0000000 --- a/old/EMERGENCY_FIX_2025-11-09.md +++ /dev/null @@ -1,243 +0,0 @@ -# Emergency Fix: Telegram ID References Removed - -**Date:** 2025-11-09 -**Status:** โœ… FIXED -**Severity:** CRITICAL (Production login broken) - ---- - -## Problem - -After migrating database to remove `telegram_id` column, login was completely broken: -- SQL queries still referenced `telegram_id` column -- Column physically dropped but code not updated -- Users unable to login/register - -**Error:** `column "telegram_id" does not exist` - ---- - -## Root Cause - -Database migration script (`migrate_steam_support.py`) successfully: -- โœ… Added new columns (steam_id, email, premium_expires_at, account_type) -- โœ… Dropped telegram_id column - -But code was not updated: -- โŒ `database.py` still defined telegram_id in schema -- โŒ Functions still queried telegram_id -- โŒ API endpoints still returned telegram_id - ---- - -## Files Changed - -### 1. `/api/database.py` - -**Removed:** -- Line 41: `Column("telegram_id", Integer, unique=True, nullable=True)` from table definition -- Lines 315-320: `get_player_by_telegram_id()` function (deleted entirely) -- Line 338: `telegram_id` parameter from `create_player()` function - -**Before:** -```python -players = Table( - "players", - metadata, - Column("id", Integer, primary_key=True, autoincrement=True), - Column("telegram_id", Integer, unique=True, nullable=True), # โŒ REMOVED - Column("username", String(50), unique=True, nullable=True), - ... -) - -async def get_player_by_telegram_id(telegram_id: int): # โŒ REMOVED - async with DatabaseSession() as session: - result = await session.execute( - select(players).where(players.c.telegram_id == telegram_id) - ) - ... - -async def create_player( - username: Optional[str] = None, - password_hash: Optional[str] = None, - telegram_id: Optional[int] = None, # โŒ REMOVED - name: str = "Survivor" -): - ... -``` - -**After:** -```python -players = Table( - "players", - metadata, - Column("id", Integer, primary_key=True, autoincrement=True), - Column("username", String(50), unique=True, nullable=True), # โœ… Clean - ... -) - -# get_player_by_telegram_id() deleted โœ… - -async def create_player( - username: Optional[str] = None, - password_hash: Optional[str] = None, - name: str = "Survivor" # โœ… No telegram_id -): - ... -``` - -### 2. `/api/main.py` - -**Removed:** -- Line 426: `telegram_id` from `get_me()` response -- Lines 4123-4127: `/api/internal/player/{telegram_id}` endpoint (deleted) -- Lines 4188-4192: `create_telegram_player()` function (deleted) - -**Before:** -```python -@app.get("/api/auth/me") -async def get_me(current_user: dict = Depends(get_current_user)): - return { - "id": current_user["id"], - "username": current_user.get("username"), - "telegram_id": current_user.get("telegram_id"), # โŒ REMOVED - ... - } - -@app.get("/api/internal/player/{telegram_id}") # โŒ REMOVED -async def get_player_by_telegram(telegram_id: int): - player = await db.get_player_by_telegram_id(telegram_id) - ... - -@app.post("/api/internal/player") # โŒ REMOVED -async def create_telegram_player(telegram_id: int, name: str = "Survivor"): - player = await db.create_player(telegram_id=telegram_id, name=name) - ... -``` - -**After:** -```python -@app.get("/api/auth/me") -async def get_me(current_user: dict = Depends(get_current_user)): - return { - "id": current_user["id"], - "username": current_user.get("username"), # โœ… No telegram_id - ... - } - -# Telegram endpoints deleted โœ… -``` - ---- - -## Deployment Steps - -1. **Code Changes:** - ```bash - # Edited api/database.py - # Edited api/main.py - ``` - -2. **Container Rebuild:** - ```bash - docker compose up -d --build echoes_of_the_ashes_api - ``` - -3. **Verification:** - ```bash - docker logs echoes_of_the_ashes_api --tail 30 - ``` - - Result: โœ… Login working - ``` - 192.168.32.2 - "POST /api/auth/login HTTP/1.1" 200 - 192.168.32.2 - "GET /api/auth/me HTTP/1.1" 200 - ``` - ---- - -## Testing Checklist - -- [x] Login with existing user works -- [x] Register new user works -- [x] `/api/auth/me` returns user data (without telegram_id) -- [x] Game loads after login -- [x] WebSocket connection established -- [x] No SQL errors in logs - ---- - -## Impact Analysis - -### โœ… Fixed -- Login functionality restored -- User registration working -- User profile endpoint clean -- No more SQL column errors - -### โš ๏ธ Breaking Changes -- **Removed endpoints** (no longer needed): - - `GET /api/internal/player/{telegram_id}` - - `POST /api/internal/player` (create_telegram_player) - -- **Removed function:** - - `get_player_by_telegram_id()` in database.py - -### ๐Ÿ”„ Still TODO (Future Work) -- Complete account/player separation (see ACCOUNT_PLAYER_SEPARATION_PLAN.md) -- Multi-character support -- Email-based authentication -- Steam integration -- Tauri desktop build - ---- - -## Prevention Measures - -**Lesson Learned:** When dropping database columns, must also: -1. Update SQLAlchemy table definitions -2. Remove/update all functions that query the column -3. Remove/update all API endpoints that return the column -4. Remove parameters from functions that accept the column -5. Test thoroughly before deployment - -**Better Process:** -1. Create migration script -2. Search codebase for all references: `grep -r "column_name"` -3. Update all code references -4. Run migration -5. Rebuild containers -6. Test - ---- - -## Related Documents - -- `STEAM_AND_PREMIUM_PLAN.md` - Full Steam/premium implementation plan -- `ACCOUNT_PLAYER_SEPARATION_PLAN.md` - Major refactor plan (accounts + characters) -- `migrate_steam_support.py` - Original migration that dropped telegram_id -- `CHANGELOG_STEAM_ICONS_2025-11-09.md` - Previous session changes - ---- - -## Next Steps - -**IMMEDIATE (This is done โœ…):** -- [x] Remove telegram_id references -- [x] Test login -- [x] Deploy fix - -**SHORT TERM (Next sprint):** -- [ ] Implement email field usage in registration -- [ ] Add email validation -- [ ] Start account/player separation - -**LONG TERM:** -- [ ] Multi-character system -- [ ] Steam authentication -- [ ] Tauri desktop app -- [ ] Avatar creator - ---- - -**Status:** Production is stable. Login working. Emergency resolved. โœ… diff --git a/old/FRONTEND_IMPLEMENTATION_COMPLETE.md b/old/FRONTEND_IMPLEMENTATION_COMPLETE.md deleted file mode 100644 index 937c667..0000000 --- a/old/FRONTEND_IMPLEMENTATION_COMPLETE.md +++ /dev/null @@ -1,409 +0,0 @@ -# Frontend Implementation Complete - -**Date:** November 9, 2025 -**Status:** โœ… COMPLETE - Frontend Updated and Deployed - ---- - -## ๐ŸŽ‰ Summary - -All frontend changes have been successfully implemented to support the new account/character separation system. The web app now uses email-based authentication and includes full character management UI. - ---- - -## โœ… Completed Frontend Changes - -### 1. API Service Layer (`api.ts`) -**File:** `/pwa/src/services/api.ts` - -**Added:** -- Type definitions for `Account`, `Character`, `LoginResponse`, `RegisterResponse`, `CharacterSelectResponse` -- `authApi` object with: - - `register(email, password)` - Creates new account - - `login(email, password)` - Authenticates with email -- `characterApi` object with: - - `list()` - Gets all characters for account - - `create(data)` - Creates new character with stats - - `select(characterId)` - Selects character to play - - `delete(characterId)` - Deletes character - -### 2. Authentication Context (`AuthContext.tsx`) -**File:** `/pwa/src/contexts/AuthContext.tsx` - -**Updated State:** -- `account` - Current account info -- `characters` - Array of user's characters -- `currentCharacter` - Selected character -- `needsCharacterCreation` - Flag for new users - -**New Functions:** -- `refreshCharacters()` - Reloads character list -- `selectCharacter(id)` - Switches active character -- `createCharacter(data)` - Creates new character -- `deleteCharacter(id)` - Removes character - -**Changes:** -- Login/register now use email instead of username -- Token includes both account_id and character_id -- Character selection persisted in localStorage - -### 3. Character Selection Screen -**Files:** -- `/pwa/src/components/CharacterSelection.tsx` -- `/pwa/src/components/CharacterSelection.css` - -**Features:** -- โœ… Character card grid display -- โœ… Shows character stats (level, HP, attributes) -- โœ… "Create New Character" button -- โœ… "Play" button for each character -- โœ… "Delete" button with confirmation -- โœ… Premium banner when at character limit -- โœ… Character limit display (1 for free, 10 for premium) -- โœ… Responsive mobile design - -### 4. Character Creation Screen -**Files:** -- `/pwa/src/components/CharacterCreation.tsx` -- `/pwa/src/components/CharacterCreation.css` - -**Features:** -- โœ… Name input with validation (3-20 chars, unique) -- โœ… Stat allocator with 20 points to distribute -- โœ… Four stats: Strength, Agility, Endurance, Intellect -- โœ… +/- buttons for stat adjustment -- โœ… Real-time stat preview (HP/Stamina calculations) -- โœ… Points remaining counter -- โœ… Form validation before submit -- โœ… Error handling with user-friendly messages -- โœ… Responsive design - -**Stat Descriptions:** -- ๐Ÿ’ช **Strength:** Increases melee damage and carry capacity -- โšก **Agility:** Improves dodge chance and critical hits -- ๐Ÿ›ก๏ธ **Endurance:** Increases HP and stamina -- ๐Ÿง  **Intellect:** Enhances crafting and resource gathering - -### 5. Login/Register Screen (`Login.tsx`) -**File:** `/pwa/src/components/Login.tsx` - -**Changes:** -- โœ… Username field โ†’ Email field -- โœ… Email validation with regex -- โœ… Password minimum 6 characters -- โœ… Placeholder hints for users -- โœ… Redirects to `/characters` after auth -- โœ… Error state cleared when toggling login/register -- โœ… Modern email input with autocomplete - -### 6. Application Routing (`App.tsx`) -**File:** `/pwa/src/App.tsx` - -**New Routes:** -- `/login` - Email-based authentication -- `/characters` - Character selection (requires auth) -- `/create-character` - Character creation (requires auth) -- `/game` - Main game (requires auth + selected character) -- `/profile/:playerId` - Player profile (requires auth) -- `/leaderboards` - Leaderboards (requires auth) - -**Route Guards:** -- `PrivateRoute` - Requires authentication -- `CharacterRoute` - Requires auth + selected character -- Default route (`/`) redirects to `/characters` - -### 7. Game Header Updates (`GameHeader.tsx`) -**File:** `/pwa/src/components/GameHeader.tsx` - -**Changes:** -- โœ… Uses `currentCharacter` instead of `user` -- โœ… Displays character name in header -- โœ… Links to character profile instead of player profile - ---- - -## ๐ŸŽจ UI/UX Features - -### Character Selection -- **Grid Layout:** Responsive grid (auto-fills based on screen size) -- **Character Cards:** Shows avatar, name, level, HP, stats, last played date -- **Create Card:** Dashed border with prominent + icon -- **Premium Banner:** Gradient background with upgrade call-to-action -- **Empty State:** Friendly message when no characters exist -- **Hover Effects:** Cards lift on hover with shadow - -### Character Creation -- **Step-by-Step Feel:** Clear sections (name โ†’ stats โ†’ preview) -- **Visual Feedback:** - - Green text when exactly 20 points allocated - - Red text when over 20 points - - Disabled buttons when invalid -- **Stat Controls:** Large +/- buttons with number input -- **Preview Section:** Shows calculated HP/Stamina in real-time -- **Stat Icons:** Emojis for quick visual identification - -### Login/Register -- **Clean Modern Card:** Centered with gradient background -- **Tabbed Toggle:** Single form that switches between login/register -- **Email Placeholder:** Shows example format -- **Password Hint:** Reminds minimum length requirement -- **Loading State:** Button text changes, fields disabled - ---- - -## ๐Ÿ“ Files Changed - -### Created Files (6) -1. `/pwa/src/components/CharacterSelection.tsx` - Character selection screen -2. `/pwa/src/components/CharacterSelection.css` - Character selection styles -3. `/pwa/src/components/CharacterCreation.tsx` - Character creation form -4. `/pwa/src/components/CharacterCreation.css` - Character creation styles - -### Modified Files (5) -1. `/pwa/src/services/api.ts` - Added character API endpoints -2. `/pwa/src/contexts/AuthContext.tsx` - Account/character state management -3. `/pwa/src/components/Login.tsx` - Email-based authentication -4. `/pwa/src/App.tsx` - New routes and guards -5. `/pwa/src/components/GameHeader.tsx` - Character-aware header - ---- - -## ๐Ÿ”„ User Flow - -### New User Registration -1. Visit site โ†’ Redirected to `/login` -2. Click "Don't have an account? Register" -3. Enter email + password (min 6 chars) -4. Submit โ†’ Account created -5. Redirected to `/characters` (empty character list) -6. Click "Create New Character" -7. Enter name + allocate 20 stat points -8. Click "Create Character" -9. Redirected back to `/characters` with new character -10. Click "Play" on character -11. Character selected โ†’ Token updated โ†’ Redirected to `/game` - -### Returning User Login -1. Visit site โ†’ Redirected to `/login` -2. Enter email + password -3. Submit โ†’ Authenticated -4. Redirected to `/characters` (shows existing characters) -5. Click "Play" on desired character -6. Character selected โ†’ Redirected to `/game` - -### Creating Additional Characters -1. From game, click character name โ†’ Navigate to `/characters` -2. Click "Create New Character" (if under limit) -3. Follow creation flow -4. Return to character selection -5. Switch between characters as needed - -### Character Deletion -1. Navigate to `/characters` -2. Click "Delete" on unwanted character -3. Confirm deletion prompt -4. Character removed from list -5. If was current character, cleared from context - ---- - -## ๐Ÿš€ Deployment - -### Build Process -```bash -cd /opt/dockers/echoes_of_the_ashes -docker compose build echoes_of_the_ashes_pwa -docker compose up -d echoes_of_the_ashes_pwa -``` - -**Build Details:** -- โœ… TypeScript compilation successful -- โœ… Vite production build: 293 KB JavaScript, 79 KB CSS -- โœ… PWA service worker generated -- โœ… All assets minified and optimized -- โœ… Container deployed and running - -**Production URLs:** -- Frontend: https://echoesoftheashgame.patacuack.net -- API: https://api.echoesoftheashgame.patacuack.net -- Map Editor: https://echoesoftheash.patacuack.net - ---- - -## ๐Ÿงช Testing Checklist - -### Authentication Flow -- [ ] Register new account with email -- [ ] Verify email validation (reject invalid emails) -- [ ] Verify password validation (min 6 chars) -- [ ] Login with existing credentials -- [ ] Verify logout clears session -- [ ] Check token persistence (refresh page) - -### Character Management -- [ ] Create first character (free account) -- [ ] Verify character appears in selection screen -- [ ] Verify cannot create 2nd character (free limit) -- [ ] Allocate stats correctly (exactly 20 points) -- [ ] Verify name uniqueness validation -- [ ] Select character and enter game -- [ ] Delete character successfully -- [ ] Verify character removed from list - -### Character Creation Validation -- [ ] Name too short (< 3 chars) - shows error -- [ ] Name too long (> 20 chars) - shows error -- [ ] Duplicate name - shows error -- [ ] Stats not 20 - button disabled -- [ ] Negative stats - shows error -- [ ] Over 20 points - button disabled, red text - -### UI/UX -- [ ] Character cards display correctly -- [ ] Stats show with proper icons -- [ ] HP/Stamina calculated correctly -- [ ] Premium banner shows for free users -- [ ] Mobile responsive design works -- [ ] Hover effects work on cards -- [ ] Loading states display properly -- [ ] Error messages are clear - -### Integration -- [ ] Game loads after character selection -- [ ] Game header shows character name -- [ ] Profile link goes to character profile -- [ ] Can switch characters from game -- [ ] WebSocket reconnects with new token - ---- - -## ๐Ÿ”ง Configuration - -### Environment Variables -No new environment variables needed. Uses existing: -- `VITE_API_URL` - API base URL (set in docker-compose.yml) - -### API Endpoints Used -- `POST /api/auth/register` - Email + password -- `POST /api/auth/login` - Email + password -- `GET /api/characters` - List user's characters -- `POST /api/characters` - Create character -- `POST /api/characters/select` - Select character -- `DELETE /api/characters/{id}` - Delete character - ---- - -## ๐Ÿ› Known Issues / Future Enhancements - -### Current Limitations -- No avatar image selection (uses initials placeholder) -- No character stats preview before creation -- Cannot rename characters after creation -- No character transfer between accounts -- Premium upgrade button not yet functional - -### Future Enhancements -1. **Avatar System:** - - Add 10 preset avatars - - Avatar selection in character creation - - Equipment-based avatar generation - - Custom avatar upload (premium feature) - -2. **Character Management:** - - Character stats respec (respec potion item) - - Character appearance customization - - Character biography/backstory field - - Character achievements display - -3. **Premium Features:** - - Stripe payment integration - - Premium upgrade modal - - Premium benefits showcase - - Trial period countdown - -4. **UX Improvements:** - - Character creation tutorial - - Stat allocation recommendations - - Character comparison view - - Quick character switch in game header - - Character sorting/filtering - -5. **Social Features:** - - Share character build codes - - Character showcase profiles - - Compare characters with friends - - Character leaderboards by build type - ---- - -## ๐Ÿ“Š Technical Stats - -**Lines of Code Added/Modified:** -- API Service: +120 lines -- Auth Context: +150 lines -- Character Selection: +170 lines (TS) + +240 lines (CSS) -- Character Creation: +250 lines (TS) + +200 lines (CSS) -- Login Updates: +30 lines -- App Routing: +40 lines -- Game Header: +5 lines -- **Total: ~1,205 lines** - -**Components Created:** 2 (CharacterSelection, CharacterCreation) -**Components Modified:** 3 (Login, App, GameHeader) -**Context Files Modified:** 1 (AuthContext) -**Service Files Modified:** 1 (api.ts) - -**Build Size:** -- JavaScript: 293 KB (87 KB gzipped) -- CSS: 79 KB (13 KB gzipped) -- Total: 372 KB (100 KB gzipped) - ---- - -## โœ… Completion Status - -**Backend:** โœ… 100% Complete -**Frontend:** โœ… 100% Complete -**Integration:** โœ… Complete -**Deployment:** โœ… Complete -**Testing:** โณ Ready for user testing - ---- - -## ๐Ÿ“ž Next Steps - -1. **User Acceptance Testing:** - - Test full registration โ†’ character creation โ†’ gameplay flow - - Test character switching - - Test character deletion - - Verify on mobile devices - - Check edge cases (slow network, etc.) - -2. **Avatar System Implementation:** - - Design/source 10 avatar presets - - Create avatar selection component - - Integrate with character creation - -3. **Premium System:** - - Set up Stripe account - - Create payment flow - - Implement premium upgrade modal - - Add premium features - -4. **Polish:** - - Add loading skeletons - - Improve error messages - - Add animations/transitions - - Optimize images - -5. **Documentation:** - - Update user guide - - Create FAQ for new system - - Document breaking changes - - Create migration guide for existing users - ---- - -**Status:** ๐ŸŽ‰ **FRONTEND IMPLEMENTATION COMPLETE** - Ready for production use! - -All code is deployed and running. Users can now register, create characters, manage multiple characters, and play the game with the new account/character separation system. diff --git a/old/IMPLEMENTATION_COMPLETE_BACKEND.md b/old/IMPLEMENTATION_COMPLETE_BACKEND.md deleted file mode 100644 index 02fc129..0000000 --- a/old/IMPLEMENTATION_COMPLETE_BACKEND.md +++ /dev/null @@ -1,331 +0,0 @@ -# Major Implementation Complete: Account/Character System - -**Date:** November 9, 2025 -**Status:** โœ… BACKEND COMPLETE - Frontend Pending - ---- - -## ๐ŸŽ‰ What Was Implemented - -### 1. Database Migration โœ… -- **New Tables Created:** - - `accounts` - Authentication and account management - - `characters` - Game characters (1-10 per account) - -- **Migration Results:** - - โœ… 5 existing players migrated to accounts + characters - - โœ… All foreign keys updated (inventory, combats, equipment, etc.) - - โœ… Old `players` table dropped - - โœ… Indexes optimized for new schema - -### 2. Authentication System Refactor โœ… -- **Email-Based Auth:** Login/register now use email instead of username -- **JWT Tokens Updated:** Include both `account_id` and `character_id` -- **Character Selection Required:** Must select character after login - -**New Endpoints:** -``` -POST /api/auth/register - Register with email -POST /api/auth/login - Login with email -GET /api/characters - List all characters for account -POST /api/characters - Create new character (with stat allocation) -POST /api/characters/select - Select character to play -DELETE /api/characters/{id} - Delete character -``` - -### 3. Character System Features โœ… -- **Multi-Character Support:** 1 character for free, 10 for premium -- **Character Creation:** - - Unique name requirement (validated) - - 20 stat points to distribute (strength/agility/endurance/intellect) - - Calculated HP/stamina based on endurance - - Avatar support (placeholder for now) - -- **Premium Restrictions Enforced:** - - Free accounts: 1 character maximum - - Premium accounts: 10 characters maximum - -### 4. Database Schema Changes โœ… - -**Accounts Table:** -```sql -- id (PRIMARY KEY) -- email (UNIQUE, NOT NULL) -- password_hash -- steam_id (UNIQUE, for future Steam integration) -- account_type ('web' or 'steam') -- premium_expires_at (NULL = free, timestamp = premium end) -- created_at, last_login_at -``` - -**Characters Table:** -```sql -- id (PRIMARY KEY) -- account_id (FK to accounts) -- name (UNIQUE, character name) -- avatar_data (JSON for avatar customization) -- level, xp, hp, max_hp, stamina, max_stamina -- strength, agility, endurance, intellect, unspent_points -- location_id, is_dead, last_movement_time -- created_at, last_played_at -``` - -**Updated Foreign Keys:** -- `inventory.character_id` (was player_id) -- `active_combats.character_id` (was player_id) -- `pvp_combats.attacker_character_id` & `defender_character_id` -- `equipment_slots.character_id` -- `player_status_effects.character_id` -- `player_statistics.character_id` - ---- - -## ๐Ÿ“‹ Current State - -### What Works โœ… -1. **Registration:** `POST /api/auth/register` with email + password -2. **Login:** `POST /api/auth/login` returns account + character list -3. **Character Creation:** Full stat allocation system working -4. **Character Selection:** Select character to play -5. **Character Deletion:** Delete unwanted characters -6. **Premium Enforcement:** Free users limited to 1 character -7. **Old Player Data:** All 5 existing players successfully migrated - -### API Test Examples - -**Register:** -```bash -curl -X POST http://localhost:8000/api/auth/register \ - -H "Content-Type: application/json" \ - -d '{"email": "test@example.com", "password": "password123"}' -``` - -**Login:** -```bash -curl -X POST http://localhost:8000/api/auth/login \ - -H "Content-Type: application/json" \ - -d '{"email": "test@example.com", "password": "password123"}' -``` - -**Create Character:** -```bash -curl -X POST http://localhost:8000/api/characters \ - -H "Authorization: Bearer YOUR_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Aragorn", - "strength": 8, - "agility": 5, - "endurance": 4, - "intellect": 3 - }' -``` - -**Select Character:** -```bash -curl -X POST http://localhost:8000/api/characters/select \ - -H "Authorization: Bearer YOUR_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"character_id": 1}' -``` - ---- - -## ๐Ÿšง What's Pending (Frontend) - -### 1. Character Selection Screen -**Component:** `CharacterSelection.tsx` -**Features Needed:** -- Display character cards (name, level, avatar, last played) -- "Create New Character" button -- "Play" button for each character -- "Delete" button with confirmation -- Premium upgrade banner (for free users at 1 char limit) - -### 2. Character Creation Screen -**Component:** `CharacterCreation.tsx` -**Features Needed:** -- Name input with real-time uniqueness validation -- Stat allocator with 20 points to distribute -- Stat preview showing calculated HP/stamina -- Avatar selector (10 presets) -- "Create Character" button - -### 3. Auth UI Update -**Component:** `Auth.tsx` -**Changes Needed:** -- Replace username field with email -- Update validation (email format) -- Modern card-based design -- Password strength indicator -- Error messages for duplicate email - -### 4. Game State Management -**Files:** Frontend state management -**Updates Needed:** -- Handle account vs character separation -- Store selected character_id in state -- Update API calls to include character context -- Character switching flow - -### 5. Avatar System -**Directory:** `/images/avatars/` -**Assets Needed:** -- 10 preset avatar images (warrior, mage, rogue, etc.) -- Placeholder avatar for characters without custom avatar -- Avatar display component - ---- - -## ๐Ÿ—‚๏ธ File Changes Summary - -### Created Files -- `/migrate_account_player_separation.py` - Database migration script -- `/ACCOUNT_PLAYER_SEPARATION_PLAN.md` - Complete implementation plan -- `/EMERGENCY_FIX_2025-11-09.md` - Telegram ID cleanup documentation -- `/IMPLEMENTATION_COMPLETE_BACKEND.md` - This file - -### Modified Files -- `/api/database.py` - New tables, functions for accounts/characters -- `/api/main.py` - Updated auth endpoints, character management -- `/api/requirements.txt` - Added asyncpg for migrations - -### Database Changes -- Created `accounts` table -- Created `characters` table -- Dropped `players` table -- Updated all foreign keys to `character_id` -- Migrated 5 existing users - ---- - -## ๐ŸŽฏ Next Steps - -### Immediate (To Make It Usable) -1. **Build Character Selection UI** - Users need to select/create characters -2. **Build Character Creation UI** - Full stat allocation interface -3. **Update Login Flow** - Show character selection after login -4. **Update Auth Forms** - Email-based registration/login - -### Short Term -5. Add avatar presets (images) -6. Test complete flow end-to-end -7. Update existing users to select characters -8. Polish UI/UX - -### Future Enhancements -- Steam authentication integration -- Dynamic avatars based on equipment -- Character stats preview in selection screen -- Character import/export -- Character templates - ---- - -## ๐Ÿ“Š Migration Statistics - -- **Players Migrated:** 5 -- **Accounts Created:** 5 -- **Characters Created:** 5 -- **Tables Updated:** 6 (inventory, active_combats, pvp_combats, equipment_slots, player_status_effects, player_statistics) -- **Foreign Keys Updated:** 8 -- **Indexes Created:** 6 -- **Duration:** ~2 hours (with debugging) - ---- - -## ๐Ÿ” Testing Checklist - -### Backend (โœ… Complete) -- [x] Register new account with email -- [x] Login with email returns character list -- [x] Create character with stat allocation (20 points) -- [x] Validate character name uniqueness -- [x] Select character to play -- [x] Delete character -- [x] Free account limited to 1 character -- [x] Premium check works correctly -- [x] Old player data accessible via characters - -### Frontend (โณ Pending) -- [ ] Register form uses email -- [ ] Login form uses email -- [ ] Character selection screen displays after login -- [ ] Character creation screen works -- [ ] Stat allocation totals 20 points -- [ ] Character name validation works -- [ ] Character deletion with confirmation -- [ ] Premium upgrade prompt shows for free users -- [ ] Game loads after character selection - ---- - -## ๐Ÿ› Known Issues - -### Resolved -- โœ… Telegram ID references cleaned up -- โœ… Database column mismatches fixed -- โœ… JWT token format updated -- โœ… Foreign key constraints updated -- โœ… Migration completed successfully - -### Outstanding -- โš ๏ธ Frontend not yet updated (still uses old auth flow) -- โš ๏ธ No avatar images yet (placeholder only) -- โš ๏ธ Old JWT tokens won't work (users must re-login) - ---- - -## ๐Ÿ“š Documentation References - -- `ACCOUNT_PLAYER_SEPARATION_PLAN.md` - Complete technical spec -- `STEAM_AND_PREMIUM_PLAN.md` - Steam integration roadmap -- API endpoints documented in code comments - ---- - -## ๐Ÿš€ Deployment Notes - -**Database Migration:** Already run on production -**API Version:** Updated and running -**Frontend Version:** Needs update before users can access - -**Rollback Plan:** -- Backup exists: `players_backup_20251109` table -- Migration can be manually reversed if needed -- Frontend can temporarily use old endpoints (with fallback logic) - ---- - -**Status:** ๐ŸŽ‰ **BACKEND IMPLEMENTATION COMPLETE** - Ready for production use! - ---- - -## โœ… UPDATE: FRONTEND COMPLETE - -**Date:** November 9, 2025 - -The frontend has now been fully updated to support the new account/character system! - -### What Was Added: -1. โœ… **Email-based login/register** - Username replaced with email -2. โœ… **Character Selection Screen** - Grid of character cards with stats -3. โœ… **Character Creation Screen** - Full stat allocator (20 points) -4. โœ… **New Routes** - `/characters`, `/create-character` -5. โœ… **Updated Auth Flow** - Login โ†’ Character Selection โ†’ Game -6. โœ… **Character Management** - Create, select, delete characters -7. โœ… **Premium UI** - Upgrade banner for free users at limit - -### Build Status: -- โœ… Frontend built successfully (293 KB JS, 79 KB CSS) -- โœ… PWA container deployed and running -- โœ… Production site live at https://echoesoftheashgame.patacuack.net - -### Files Changed: -- **Created:** CharacterSelection.tsx/css, CharacterCreation.tsx/css (4 files) -- **Modified:** api.ts, AuthContext.tsx, Login.tsx, App.tsx, GameHeader.tsx (5 files) -- **Total:** 1,205 lines of code added/modified - -See `FRONTEND_IMPLEMENTATION_COMPLETE.md` for full documentation. - -**READY FOR USER TESTING!** ๐Ÿš€ diff --git a/old/INTERACTABLE_COOLDOWN_SYSTEM.md b/old/INTERACTABLE_COOLDOWN_SYSTEM.md deleted file mode 100644 index 13db025..0000000 --- a/old/INTERACTABLE_COOLDOWN_SYSTEM.md +++ /dev/null @@ -1,242 +0,0 @@ -# Interactable Cooldown System - Real-Time Updates - -## Overview -Implemented a complete real-time notification and live countdown system for interactable cooldowns, similar to the combat turn timer system. - -## Implementation Date -November 8, 2025 - -## Features Implemented - -### 1. WebSocket Broadcasts on Interaction -**Location**: `api/main.py` - `/api/game/interact` endpoint - -When a player interacts with an object: -- **Broadcast sent to all players in location** with message type `interactable_cooldown` -- Includes: - - `instance_id`: The interactable's unique identifier - - `cooldown_expiry`: Unix timestamp when cooldown expires (60 seconds from interaction) - - `message`: "{username} interacted with {interactable_name}" -- All players in the same location see the cooldown start immediately - -### 2. Background Task for Cooldown Expiry -**Location**: `api/background_tasks.py` - `cleanup_interactable_cooldowns()` - -- **Runs every 30 seconds** to check for expired cooldowns -- Gets expired cooldowns from database before removal -- Maps `instance_id` to `location_id` by searching through world locations -- **Broadcasts `interactable_ready` message** to all players in affected locations -- Message: "{interactable_name} is ready to use again" -- Added as 7th background task in the system - -**Task Count**: System now runs 7 background tasks: -1. Enemy spawn/despawn -2. Dropped item decay -3. Stamina regeneration -4. Combat timers -5. Corpse decay -6. Status effects processor -7. **Interactable cooldown cleanup** โ† NEW - -### 3. Database Functions -**Location**: `api/database.py` - -Added two new functions: -- `get_expired_interactable_cooldowns()`: Returns list of expired cooldowns with instance_id -- `remove_expired_interactable_cooldowns()`: Removes expired cooldowns and returns count - -### 4. Frontend Real-Time Countdown -**Location**: `pwa/src/components/Game.tsx` - -#### State Management -```typescript -const [interactableCooldowns, setInteractableCooldowns] = useState>({}) -``` -- Stores mapping of `instance_id` โ†’ `expiry_timestamp` - -#### WebSocket Handlers -Two new message types: -- **`interactable_cooldown`**: Adds cooldown to state when someone interacts -- **`interactable_ready`**: Removes cooldown from state when expired - -#### Live Countdown Timer -```typescript -useEffect(() => { - const timer = setInterval(() => { - const now = Date.now() / 1000 - setInteractableCooldowns(prev => { - // Remove expired cooldowns every second - // Updates UI automatically - }) - }, 1000) -}, [interactableCooldowns]) -``` - -#### Updated Rendering -- **Live calculation** of remaining seconds: `Math.ceil(cooldownExpiry - now)` -- **Dynamic display**: Shows `โณ{remainingSeconds}s` next to interactable name -- **Live button state**: Disables button when cooldown > 0 -- **Live tooltip**: Updates every second with current remaining time -- **Automatic cleanup**: Timer removed when cooldown reaches 0 - -## Message Flow - -### When Player A Interacts with Dumpster: - -1. **Player A clicks "Search Dumpster" button** - -2. **API receives interaction** - - Sets 60-second cooldown in database - - Broadcasts to all players in location: - ```json - { - "type": "interactable_cooldown", - "data": { - "instance_id": "start_point_dumpster", - "cooldown_expiry": 1731108178.5, - "message": "PlayerA interacted with Dumpster" - } - } - ``` - -3. **All Players' Clients (including Player A)** - - Add cooldown to state: `interactableCooldowns["start_point_dumpster"] = 1731108178.5` - - Start live countdown: `โณ60s โ†’ โณ59s โ†’ โณ58s...` - - Disable interaction buttons - - Show message in location log: "PlayerA interacted with Dumpster" - - Refresh game data to update inventory/location state - -4. **After 60 Seconds - Background Task** - - Detects cooldown expired - - Removes from database - - Broadcasts to all players in location: - ```json - { - "type": "interactable_ready", - "data": { - "instance_id": "start_point_dumpster", - "message": "Dumpster is ready to use again" - } - } - ``` - -5. **All Players' Clients** - - Remove cooldown from state - - Enable interaction buttons - - Show message in location log: "Dumpster is ready to use again" - - Refresh game data - -## Key Benefits - -### 1. **Real-Time Synchronization** -- All players see cooldowns at the same time -- No stale data from page loads -- Automatic updates without manual refresh - -### 2. **Live Countdown Display** -- Updates every second like combat turn timer -- Shows exact time remaining: `โณ5s` -- More engaging than static "on cooldown" message - -### 3. **Consistent UX** -- Same pattern as combat turn timer -- Familiar to players -- Professional feel - -### 4. **Efficient Updates** -- Targeted broadcasts only to players in affected locations -- No unnecessary network traffic -- Client-side countdown reduces server load - -### 5. **Clear Feedback** -- Players know who interacted ("PlayerA interacted with Dumpster") -- Know when it's ready again ("Dumpster is ready to use again") -- See exact time remaining in both tooltip and display - -## Technical Details - -### Cooldown Duration -- **Default**: 60 seconds (hardcoded in `game_logic.py` line 271) -- Can be modified per-interactable if needed - -### Timer Precision -- **Backend check**: Every 30 seconds -- **Frontend update**: Every 1 second -- **Display**: Rounds up to nearest second (shows 1s until truly expired) - -### Performance Considerations -- Background task only runs in one worker (file lock) -- Broadcasts only to affected locations (not global) -- Client-side countdown reduces API calls -- Timer automatically cleared when no cooldowns active - -## Files Modified - -### Backend -1. `api/main.py` - - Added `time` import - - Updated `/api/game/interact` endpoint to broadcast cooldown start - -2. `api/database.py` - - Added `get_expired_interactable_cooldowns()` - - Added `remove_expired_interactable_cooldowns()` - -3. `api/background_tasks.py` - - Added `cleanup_interactable_cooldowns()` task - - Updated `start_background_tasks()` to include new task (7 total) - - Updated `start_background_tasks()` signature to accept `world_locations` - - Updated `lifespan()` in main.py to pass `LOCATIONS` - -### Frontend -1. `pwa/src/components/Game.tsx` - - Added `interactableCooldowns` state - - Added `interactable_cooldown` WebSocket handler - - Added `interactable_ready` WebSocket handler - - Added live countdown timer effect - - Updated interactable rendering with live countdown display - -## Testing Checklist - -โœ… Player interacts with dumpster โ†’ All players see cooldown start -โœ… Cooldown shows live countdown: `โณ60s โ†’ โณ59s โ†’ ...` -โœ… Button disabled during cooldown -โœ… Tooltip shows remaining time -โœ… After 60 seconds, all players see "ready" message -โœ… Button re-enabled when cooldown expires -โœ… Multiple interactables can have independent cooldowns -โœ… Players in different locations don't see each other's cooldowns -โœ… Background task runs every 30 seconds (check logs) -โœ… 7 background tasks started (check startup logs) - -## Future Enhancements - -### Potential Improvements: -1. **Variable cooldown durations** per interactable type -2. **Cooldown persistence** across server restarts (already in DB) -3. **Sound notification** when interactable becomes ready -4. **Visual effects** like pulsing when cooldown expires -5. **Skill-based cooldown reduction** (faster cooldowns for skilled players) -6. **Multiple interaction types** per interactable with separate cooldowns - -## Deployment - -```bash -# Build both containers -docker compose build echoes_of_the_ashes_api echoes_of_the_ashes_pwa - -# Deploy -docker compose up -d - -# Verify 7 background tasks started -docker compose logs echoes_of_the_ashes_api | grep "background tasks" -# Output: โœ… Started 7 background tasks in this worker -``` - -## Related Systems - -This implementation follows the same pattern as: -- **Combat turn timer** (PvP countdown) -- **Movement cooldown** (travel between locations) -- **Location messages log** (activity feed) - -All use WebSocket broadcasts + client-side countdown for smooth real-time experience. diff --git a/old/OPTIMIZATION_STRATEGY.md b/old/OPTIMIZATION_STRATEGY.md deleted file mode 100644 index a5fe6a5..0000000 --- a/old/OPTIMIZATION_STRATEGY.md +++ /dev/null @@ -1,418 +0,0 @@ -# Game Optimization Strategy - -## Current Performance Analysis - -### Polling Overhead (Every 5 seconds) -Current polling fetches **5 endpoints simultaneously**: -1. `/api/game/state` - ~1.3KB+ (inventory, equipment, dropped items, player data) -2. `/api/game/location` - Location data -3. `/api/game/profile` - Player profile -4. `/api/game/combat` - Combat status -5. `/api/game/pvp/status` - PvP combat status - -**Problems:** -- Inventory sent every poll (~1.3KB) even though it rarely changes -- Equipment sent every poll even though it rarely changes -- Dropped items fetched every poll even though location doesn't change often -- Duplicate data across endpoints (player data in multiple responses) - ---- - -## Optimization Strategy - -### Phase 1: Smart Polling - Only Fetch What Changes - -#### A. **Lightweight Polling Endpoint** (Every 5s) -Create `/api/game/state/minimal` that returns ONLY data that changes frequently: - -```json -{ - "player": { - "hp": 100, - "stamina": 95, - "location_id": "ruins_entrance", - "movement_cooldown": 2 - }, - "in_combat": false, - "in_pvp_combat": false, - "location_changed": false, // Flag if location changed - "inventory_changed": false, // Flag if inventory changed - "equipment_changed": false, // Flag if equipment changed - "last_update_timestamp": 1699380123.456 -} -``` - -**Size estimate:** ~200 bytes vs current 1.5KB+ = **87% reduction** - -#### B. **On-Demand Fetching** -Only fetch full data when flags indicate changes or after specific actions: - -**Inventory** - Fetch only when: -- `inventory_changed` flag is true -- After pickup/drop/craft/use actions -- After equipment changes -- On initial load - -**Equipment** - Fetch only when: -- `equipment_changed` flag is true -- After equip/unequip actions -- On initial load - -**Location Details** - Fetch only when: -- `location_changed` flag is true -- After movement -- On initial load - -**Dropped Items** - Fetch only when: -- Location changes -- After dropping items -- After picking up items - -#### C. **Change Tracking in Database** -Add columns to `players` table: -```sql -ALTER TABLE players ADD COLUMN inventory_version INTEGER DEFAULT 0; -ALTER TABLE players ADD COLUMN equipment_version INTEGER DEFAULT 0; -ALTER TABLE players ADD COLUMN last_state_change FLOAT DEFAULT 0; -``` - -Increment versions when: -- Inventory changes: `inventory_version++` -- Equipment changes: `equipment_version++` - -Frontend caches versions and only re-fetches if version changed. - ---- - -### Phase 2: Delta Updates (Advanced) - -**Pros:** -- Even smaller payload (only changes) -- Extremely efficient for large inventories - -**Cons:** -- More complex implementation -- Need to handle edge cases (out-of-sync states) -- Need full refresh mechanism as fallback - -**Verdict:** **Not recommended initially** -- Current optimization (Phase 1) will give 85%+ bandwidth reduction -- Delta updates add complexity for diminishing returns -- Only consider if player base grows significantly (10k+ concurrent users) - ---- - -### Phase 3: WebSocket for Real-Time Updates (Future) - -Instead of polling, use WebSocket for: -- Push notifications when state changes -- Real-time PvP combat updates -- Instant location updates from other players -- Server broadcasts (events, announcements) - -**Benefits:** -- Zero polling overhead -- True real-time experience -- Server can push updates only when needed - -**Implementation complexity:** Medium-High - ---- - -## Immediate Action Plan - -### 1. Create Minimal State Endpoint (1-2 hours) - -**File:** `api/main.py` -```python -@app.get("/api/game/state/minimal") -async def get_minimal_state(current_user: dict = Depends(get_current_user)): - """Lightweight endpoint for polling - returns only frequently changing data""" - player = await db.get_player_by_id(current_user['id']) - - # Calculate movement cooldown - import time - current_time = time.time() - last_movement = player.get('last_movement_time', 0) - movement_cooldown = max(0, 5 - (current_time - last_movement)) - - # Check if data changed since last poll - # Client sends last known versions, server returns flags - inventory_version = player.get('inventory_version', 0) - equipment_version = player.get('equipment_version', 0) - - return { - "player": { - "hp": player['hp'], - "max_hp": player['max_hp'], - "stamina": player['stamina'], - "max_stamina": player['max_stamina'], - "location_id": player['location_id'], - "movement_cooldown": int(movement_cooldown), - "is_dead": player.get('is_dead', False) - }, - "versions": { - "inventory": inventory_version, - "equipment": equipment_version - }, - "timestamp": current_time - } -``` - -### 2. Update Frontend Polling Logic (2-3 hours) - -**File:** `pwa/src/components/Game.tsx` - -```typescript -// Cache for full data -const [cachedInventory, setCachedInventory] = useState(null) -const [cachedEquipment, setCachedEquipment] = useState(null) -const [lastVersions, setLastVersions] = useState({ inventory: 0, equipment: 0 }) - -const fetchMinimalState = async () => { - const res = await api.get('/api/game/state/minimal') - - // Update HP/Stamina/Location immediately - setPlayerState(prev => ({ - ...prev, - health: res.data.player.hp, - stamina: res.data.player.stamina, - location_id: res.data.player.location_id - })) - - // Check if inventory changed - if (res.data.versions.inventory !== lastVersions.inventory) { - await fetchFullInventory() - setLastVersions(prev => ({ ...prev, inventory: res.data.versions.inventory })) - } - - // Check if equipment changed - if (res.data.versions.equipment !== lastVersions.equipment) { - await fetchFullEquipment() - setLastVersions(prev => ({ ...prev, equipment: res.data.versions.equipment })) - } -} -``` - -### 3. Add Version Tracking to Database Functions (1 hour) - -Update these functions to increment versions: -- `add_item_to_inventory()` - increment `inventory_version` -- `remove_inventory_item()` - increment `inventory_version` -- `equip_item()` - increment `equipment_version` -- `unequip_item()` - increment `equipment_version` - -### 4. Keep Full State Fetch for Actions (Already done!) - -After actions (pickup, craft, equip, etc.), fetch full data: -```typescript -await handlePickup(itemId) -await fetchFullInventory() // Refresh immediately after action -``` - ---- - -## Expected Performance Gains - -### Bandwidth Reduction -- **Current:** 1.5KB every 5s = **18KB/minute** per player -- **Optimized:** 200 bytes every 5s = **2.4KB/minute** per player -- **Savings:** **87% reduction** in polling bandwidth - -### Server Load Reduction -- **Database queries per poll:** - - Current: 8-12 queries (inventory items, equipment, dropped items, etc.) - - Optimized: 1 query (player state only) -- **CPU usage:** ~80% reduction per poll -- **Memory:** Significant reduction from not loading/serializing inventory every poll - -### Scalability -- **Current:** ~100 concurrent users max -- **Optimized:** ~800+ concurrent users with same resources - ---- - -## Steam Integration & Monetization - -### Can you release on Steam? -**YES!** Your game architecture is perfect for Steam: -- Progressive Web App can be packaged as desktop app -- Electron or Tauri wrapper around your PWA -- Steamworks SDK integration is straightforward - -### Integration Steps - -#### 1. **Package as Desktop App** (Choose one) - -**Option A: Electron (Most common)** -```json -{ - "name": "echoes-of-the-ashes", - "main": "main.js", - "dependencies": { - "electron": "^27.0.0" - } -} -``` - -**Option B: Tauri (More efficient, Rust-based)** -- Smaller bundle size -- Better performance -- Rust backend integration - -#### 2. **Integrate Steamworks** -```typescript -// Steam initialization -const steamworks = require('steamworks.js') - -// Initialize Steam client -if (steamworks.init()) { - const steamId = steamworks.localplayer.getSteamId() - const username = steamworks.localplayer.getName() - - // Use Steam ID for authentication - await api.post('/api/auth/steam', { - steamId, - username, - ticket: steamworks.auth.getSessionTicket() - }) -} -``` - -#### 3. **Two-Version Strategy (Free vs Premium)** - -**Database Schema:** -```sql -ALTER TABLE players ADD COLUMN account_type VARCHAR(20) DEFAULT 'free'; --- Values: 'free', 'steam_premium', 'web_premium' - -ALTER TABLE players ADD COLUMN steam_id VARCHAR(50) UNIQUE; -ALTER TABLE players ADD COLUMN premium_expires_at FLOAT; -``` - -**Backend Restrictions:** -```python -@app.post("/api/game/move") -async def move(req, current_user): - player = await db.get_player_by_id(current_user['id']) - - # Check premium restrictions - if player['account_type'] == 'free': - if player['level'] >= 3: - raise HTTPException( - status_code=402, - detail="Level 3 is the maximum for free accounts. Upgrade to premium to continue!" - ) - - # Check location restrictions - if location_id in PREMIUM_LOCATIONS: - raise HTTPException( - status_code=402, - detail="This area is only accessible to premium accounts." - ) - - # Continue with move logic... -``` - -**Monetization Options:** - -1. **Steam Purchase (One-time)** - - Buy on Steam = Permanent premium - - Price: $9.99 - $19.99 - - Steam handles payment, you get 70% - -2. **Web Subscription** - - Stripe/PayPal integration - - $4.99/month or $39.99/year - - Separate from Steam version - -3. **Hybrid Model** - ```python - def is_premium(player): - # Steam premium (permanent) - if player['account_type'] == 'steam_premium': - return True - - # Web premium (subscription) - if player['account_type'] == 'web_premium': - if player['premium_expires_at'] > time.time(): - return True - - return False - ``` - -### Steam Features You Can Use - -1. **Achievements** - ```typescript - steamworks.achievement.activate('FIRST_COMBAT_WIN') - ``` - -2. **Cloud Saves** - - Sync player state across devices - - Automatic backups - -3. **Leaderboards** - ```typescript - steamworks.leaderboards.uploadScore('PLAYER_LEVEL', player.level) - ``` - -4. **Friends/Multiplayer** - - See Steam friends in-game - - Invite to PvP combat - - Group features - -5. **Workshop** - - User-created content - - Custom locations/items - - Community maps - -### Legal Considerations - -1. **Steam Agreement** - - Need business entity (or individual) to sign Steamworks agreement - - Need Tax ID (EIN for business, SSN for individual) - -2. **Separate Versions** - - โœ… **ALLOWED:** Selling Steam version + separate web subscription - - โŒ **NOT ALLOWED:** Forcing Steam users to pay again on web - - โœ… **ALLOWED:** Different pricing models - - โœ… **ALLOWED:** Web has features Steam doesn't (and vice versa) - -3. **Revenue Sharing** - - Steam: 30% cut (you get 70%) - - Web direct: 100% yours (minus payment processor ~3%) - -### Recommended Strategy - -**Phase 1: Optimize & Test** (Current) -- Implement polling optimizations -- Test with small player base -- Gather feedback - -**Phase 2: Steam Preparation** (2-4 weeks) -- Package as Electron app -- Integrate Steamworks SDK -- Implement premium restrictions -- Add achievements/leaderboards - -**Phase 3: Soft Launch** (Steam Early Access) -- Launch as Early Access ($14.99) -- Gather Steam community feedback -- Regular updates - -**Phase 4: Dual Platform** (After Steam is stable) -- Keep Steam version -- Launch web version with subscription -- Cross-platform support (shared servers) - ---- - -## Priority Order - -1. **โœ… Immediate:** Polling optimization (biggest impact, low effort) -2. **โณ Short-term:** Premium restrictions system (prep for monetization) -3. **โณ Medium-term:** Steam integration (packaging + Steamworks) -4. **๐Ÿ”ฎ Long-term:** WebSocket real-time updates (if needed) - -Would you like me to start implementing the polling optimization now? diff --git a/old/README.md b/old/README.md deleted file mode 100644 index 412cfb6..0000000 --- a/old/README.md +++ /dev/null @@ -1,371 +0,0 @@ -# Echoes of the Ashes - -A post-apocalyptic survival RPG available on **Telegram** and **Web**, featuring turn-based exploration, resource management, and a persistent world. - -![Python](https://img.shields.io/badge/python-3.11-blue) -![Telegram Bot API](https://img.shields.io/badge/telegram--bot--api-21.0.1-blue) -![PostgreSQL](https://img.shields.io/badge/postgresql-15-blue) -![Docker](https://img.shields.io/badge/docker-compose-blue) -![React](https://img.shields.io/badge/react-18-blue) -![FastAPI](https://img.shields.io/badge/fastapi-0.104-green) - -## ๐ŸŒ Play Now - -- **Telegram Bot**: [@your_bot_username](https://t.me/your_bot_username) -- **Web/Mobile**: [echoesoftheashgame.patacuack.net](https://echoesoftheashgame.patacuack.net) - -## ๐ŸŽฎ Features - -### Core Gameplay -- **๐Ÿ—บ๏ธ Exploration**: Navigate through 7 interconnected locations -- **๐Ÿ‘€ Interact**: Search and interact with 24+ unique objects -- **๐ŸŽ’ Inventory**: Collect, use, and manage 28 different items -- **โšก๏ธ Stamina System**: Actions require stamina management with automatic regeneration -- **โค๏ธ Survival**: Heal using consumables, avoid damage -- **๐Ÿ”„ Cooldowns**: Per-action cooldown system prevents spam -- **โ™ป๏ธ Auto-Recovery**: Stamina regenerates over time (1+ per 5 minutes based on endurance) - -### Visual Experience -- **๐Ÿ“ธ Location Images**: Every location has a unique image -- **๐Ÿ–ผ๏ธ Smart Caching**: Images cached in database for instant loading -- **โœจ Smooth Transitions**: Uses `edit_message_media` for seamless navigation -- **๐Ÿงญ Context-Aware**: Location images persist across menus - -### Game World -- **7 Locations**: Downtown, Gas Station, Residential, Clinic, Plaza, Park, Overpass -- **5 Interactable Types**: Rubble, Sedans, Houses, Medical Cabinets, Tool Sheds, Dumpsters, Vending Machines -- **28 Items**: Resources, consumables, weapons, equipment, quest items -- **Risk vs Reward**: Higher risk actions can cause damage but yield better loot - -## ๐Ÿš€ Quick Start - -### Telegram Bot - -1. Get a Bot Token from [@BotFather](https://t.me/botfather) -2. Create `.env` file with your credentials -3. Run `docker-compose up -d --build` -4. Find your bot and send `/start` - -See [Installation Guide](#installation) for detailed instructions. - -### Progressive Web App (PWA) - -1. Run `./setup_pwa.sh` to set up the web version -2. Open [echoesoftheashgame.patacuack.net](https://echoesoftheashgame.patacuack.net) -3. Register an account and play! - -See [PWA_QUICKSTART.md](PWA_QUICKSTART.md) for detailed instructions. - -## ๐Ÿ“ฑ Platform Features - -### Telegram Bot -- ๐Ÿค– Native Telegram integration -- ๐Ÿ”” Instant push notifications -- ๐Ÿ’ฌ Chat-based gameplay -- ๐Ÿ‘ฅ Easy sharing with friends - -### Web/Mobile PWA -- ๐ŸŒ Play in any browser -- ๐Ÿ“ฑ Install as mobile app -- ๐ŸŽจ Modern responsive UI -- ๐Ÿ” Separate authentication -- โšก Offline support (coming soon) -- ๐Ÿ”” Web push notifications (coming soon) - -## ๐Ÿ› ๏ธ Installation - -### Prerequisites -- Docker and Docker Compose -- For Telegram: Bot Token from [@BotFather](https://t.me/botfather) -- For PWA: Node.js 20+ (for development) - -### Basic Setup - -1. Clone the repository: -```bash -cd /opt/dockers/echoes_of_the_ashes -``` - -2. Create `.env` file: -```env -TELEGRAM_BOT_TOKEN=your_bot_token_here -DATABASE_URL=postgresql+psycopg://user:password@echoes_of_the_ashes_db:5432/telegram_rpg -POSTGRES_USER=user -POSTGRES_PASSWORD=password -POSTGRES_DB=telegram_rpg -JWT_SECRET_KEY=generate-with-openssl-rand-hex-32 -``` - -3. Start services: -```bash -# Telegram bot only -docker-compose up -d --build - -# With PWA (web version) -./setup_pwa.sh -``` - -4. Check logs: -```bash -docker logs echoes_of_the_ashes_bot -f -docker logs echoes_of_the_ashes_api -f -docker logs echoes_of_the_ashes_pwa -f -``` - -## ๐ŸŽฏ How to Play - -### Basic Commands -- `/start` - Start your journey or return to main menu - -### Main Menu -- **๐Ÿ—บ๏ธ Move** - Travel to connected locations -- **๐Ÿ‘€ Inspect Area** - View and interact with objects -- **๐Ÿ‘ค Profile** - View your character stats -- **๐ŸŽ’ Inventory** - Manage your items - -### Actions -- **Search/Loot** - Find items in the environment (costs stamina) -- **Use Items** - Consume food/medicine to restore HP/stamina -- **Drop Items** - Leave items at current location -- **Pick Up** - Collect items from the ground - -### Stats -- **HP**: Health Points (die at 0) -- **Stamina**: Required for actions (regenerates over time) -- **Weight/Volume**: Inventory capacity limits - -## ๐Ÿ—บ๏ธ World Map - -``` - ๐Ÿ›ฃ๏ธ Highway Overpass - | - ๐Ÿฅ Clinic --- โ›ฝ๏ธ Gas Station - | | - ๐Ÿ˜๏ธ Residential --- ๐ŸŒ† Downtown --- ๐Ÿฌ Plaza - | | - +------------ ๐ŸŒณ Park ------------+ -``` - -## ๐Ÿ“ฆ Items - -### Consumables -| Item | Effect | Emoji | -|------|--------|-------| -| First Aid Kit | +50 HP | ๐Ÿฉน | -| Mystery Pills | +30 HP | ๐Ÿ’Š | -| Canned Beans | +20 HP, +5 Stamina | ๐Ÿฅซ | -| Energy Bar | +15 Stamina | ๐Ÿซ | -| Bottled Water | +10 Stamina | ๐Ÿ’ง | - -### Resources -- โš™๏ธ Scrap Metal -- ๐Ÿชต Wood Planks -- ๐Ÿ“Œ Rusty Nails -- ๐Ÿงต Cloth Scraps -- ๐Ÿถ Plastic Bottles - -### Equipment -- ๐ŸŽ’ Hiking Backpack (+20 capacity) -- ๐Ÿ”ฆ Flashlight -- ๐Ÿ”ง Tire Iron -- โšพ Baseball Bat - -## ๐Ÿ—๏ธ Architecture - -### Tech Stack -- **Language**: Python 3.11 -- **Bot Framework**: python-telegram-bot 21.0.1 -- **Database**: PostgreSQL 15 (async with SQLAlchemy) -- **Deployment**: Docker Compose -- **Scheduler**: APScheduler (for stamina regeneration) - -### Project Structure -``` -telegram-rpg/ -โ”œโ”€โ”€ bot/ -โ”‚ โ”œโ”€โ”€ database.py # Database operations -โ”‚ โ”œโ”€โ”€ handlers.py # Telegram event handlers -โ”‚ โ”œโ”€โ”€ keyboards.py # Inline keyboard layouts -โ”‚ โ””โ”€โ”€ logic.py # Game logic -โ”œโ”€โ”€ data/ -โ”‚ โ”œโ”€โ”€ items.py # Item definitions -โ”‚ โ”œโ”€โ”€ models.py # Game world models -โ”‚ โ””โ”€โ”€ world_loader.py # World construction -โ”œโ”€โ”€ docs/ # Comprehensive documentation -โ”œโ”€โ”€ images/ # Location and interactable images -โ”œโ”€โ”€ main.py # Entry point -โ””โ”€โ”€ docker-compose.yml # Container orchestration -``` - -### Database Schema -- **players**: Character stats and state -- **inventory**: Player item storage -- **dropped_items**: World item storage -- **cooldowns**: Per-action cooldown tracking -- **image_cache**: Telegram file_id caching - -## ๐Ÿ“š Documentation - -Detailed documentation in `docs/`: -- **INVENTORY_USE.md** - Item usage system -- **EXPANDED_WORLD.md** - All locations and items -- **WORLD_MAP.md** - Map visualization and strategy -- **IMAGE_SYSTEM.md** - Image caching implementation -- **UX_IMPROVEMENTS.md** - Clean chat mechanics -- **ACTION_FEEDBACK.md** - Action result display -- **SMOOTH_TRANSITIONS.md** - Message editing system -- **UPDATE_SUMMARY.md** - Latest changes - -## ๐ŸŽจ Adding Content - -### New Item -Edit `data/items.py`: -```python -"new_item": { - "name": "New Item", - "weight": 1.0, - "volume": 0.5, - "type": "consumable", - "effects": {"hp": 20}, - "emoji": "๐ŸŽ" -} -``` - -### New Interactable -Edit `data/world_loader.py`: -```python -NEW_TEMPLATE = Interactable( - id="new_object", - name="New Object", - image_path="images/interactables/new.png" -) -action = Action(id="search", label="๐Ÿ”Ž Search", stamina_cost=2) -action.add_outcome("success", Outcome( - text="You find something!", - items_reward={"new_item": 1} -)) -NEW_TEMPLATE.add_action(action) -``` - -### New Location -```python -new_location = Location( - id="new_place", - name="๐Ÿ›๏ธ New Place", - description="Description here", - image_path="images/locations/new_place.png" -) -new_location.add_interactable("new_place_object", NEW_TEMPLATE) -new_location.add_exit("north", "other_location") -world.add_location(new_location) -``` - -## ๐Ÿ”ง Development - -### Local Development -```bash -# Install dependencies -pip install -r requirements.txt - -# Run bot -python main.py -``` - -### Database Management -```bash -# Access database -docker exec -it echoes_of_the_ashes_db psql -U user -d telegram_rpg - -# Backup database -docker exec echoes_of_the_ashes_db pg_dump -U user telegram_rpg > backup.sql - -# Restore database -docker exec -i echoes_of_the_ashes_db psql -U user telegram_rpg < backup.sql -``` - -### Logs -```bash -# Follow bot logs -docker logs echoes_of_the_ashes_bot -f - -# Database logs -docker logs echoes_of_the_ashes_db -f -``` - -## ๐ŸŽฒ Game Mechanics - -### Outcome Probability -- **Critical Failure**: Rare, negative effects -- **Failure**: Common, no reward -- **Success**: Common, standard rewards - -Configured in `bot/logic.py`: -```python -def roll_outcome(action: Action): - roll = random.random() - if roll < 0.1: return "critical_failure" - elif roll < 0.5: return "failure" - else: return "success" -``` - -### Stamina Regeneration -- **Rate**: 1 stamina per 5 minutes -- **Maximum**: Defined by player stats -- **Automatic**: Background scheduler - -### Cooldowns -- **Per-Action**: Each action has independent cooldown -- **Duration**: Configured per action (30-60 minutes typical) -- **Storage**: Composite key `instance_id:action_id` - -## ๐Ÿšง Future Plans - -### Planned Features -- [ ] Combat system -- [ ] Crafting mechanics -- [ ] Quest system -- [ ] NPC interactions -- [ ] Base building -- [ ] Equipment slots -- [ ] Status effects -- [ ] Day/night cycle -- [ ] Weather system -- [ ] Trading economy - -### Balance Improvements -- [ ] Dynamic difficulty -- [ ] Rare item spawns -- [ ] Location-based dangers -- [ ] Resource scarcity tuning - -## ๐Ÿค Contributing - -Contributions are welcome! Please: -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Test thoroughly -5. Submit a pull request - -## ๐Ÿ“ License - -This project is open source and available under the MIT License. - -## ๐Ÿ™ Acknowledgments - -- Built with [python-telegram-bot](https://python-telegram-bot.org/) -- Inspired by classic post-apocalyptic RPGs -- Community feedback and testing - -## ๐Ÿ“ž Support - -For issues or questions: -- Open a GitHub issue -- Check the documentation in `docs/` -- Review error logs with `docker logs` - ---- - -**Current Version**: 1.1.0 (Expanded World Update) -**Last Updated**: October 16, 2025 -**Status**: โœ… Active Development diff --git a/old/REDIS_IMPLEMENTATION_COMPLETE.md b/old/REDIS_IMPLEMENTATION_COMPLETE.md deleted file mode 100644 index 085663b..0000000 --- a/old/REDIS_IMPLEMENTATION_COMPLETE.md +++ /dev/null @@ -1,636 +0,0 @@ -# Redis Integration - Implementation Complete โœ… - -**Date**: November 9, 2025 -**Status**: **LIVE IN PRODUCTION** ๐Ÿš€ - ---- - -## ๐ŸŽฏ Implementation Summary - -Successfully implemented comprehensive Redis integration for **multi-worker scalability** with **pub/sub** for cross-worker communication and **caching** for performance. - -### โœ… Completed Features - -1. **Redis Container** - AOF + RDB persistence, 512MB memory limit -2. **RedisManager Module** - Comprehensive async Redis client with pub/sub, caching, locks -3. **ConnectionManager Integration** - Redis pub/sub for cross-worker broadcasts -4. **Multi-Worker Support** - 4 FastAPI workers with load balancing -5. **Cache Invalidation** - Aggressive invalidation on inventory, combat, movement -6. **Disconnected Player Mechanics** - Keep players in location registry, mark as vulnerable -7. **Distributed Background Tasks** - Redis locks for task coordination - ---- - -## ๐Ÿ“Š Current Status - -### Redis Deployment -```bash -$ docker ps | grep redis -echoes_of_the_ashes_redis Running redis:7-alpine - -$ docker exec echoes_of_the_ashes_redis redis-cli INFO server -redis_version:7.4.7 -uptime_in_seconds:51 -``` - -### Active Workers -```bash -$ docker exec echoes_of_the_ashes_redis redis-cli SMEMBERS active_workers -9ef23102 -70bbc0c6 -bed4293b -758e940e - -โœ… 4 workers registered and healthy -``` - -### Redis Data Structures (Live) -```bash -$ docker exec echoes_of_the_ashes_redis redis-cli KEYS "*" -active_workers # Set of worker IDs -worker:9ef23102:heartbeat # Worker heartbeat -worker:70bbc0c6:heartbeat -worker:bed4293b:heartbeat -worker:758e940e:heartbeat -player:1:session # Player session cache -location:overpass:players # Location player registry -``` - -### Player Session Example -```bash -$ docker exec echoes_of_the_ashes_redis redis-cli HGETALL "player:1:session" -websocket_connected: true -username: Jocaru -location_id: overpass -hp: 8560 -max_hp: 10000 -stamina: 9215 -max_stamina: 10000 -level: 9 -xp: 109 -``` - ---- - -## ๐Ÿ—๏ธ Architecture - -### Before (Single Worker) -``` -Client โ†’ Gunicorn (1 worker) โ†’ PostgreSQL - โ†“ - WebSocket (in-memory only) -``` - -**Limitations**: -- Single worker bottleneck -- No horizontal scaling -- WebSocket broadcasts limited to local connections -- No cache layer - -### After (Multi-Worker with Redis) -``` -Clients โ†’ Load Balancer โ†’ Gunicorn (4 workers) โ†’ PostgreSQL - โ†“ โ†“ - Redis Pub/Sub + Cache - โ†“ - Cross-Worker Communication -``` - -**Benefits**: -- โœ… 4x concurrency (4 workers) -- โœ… Horizontal scaling ready -- โœ… Cross-worker WebSocket broadcasts -- โœ… Redis cache layer (70-80% DB query reduction) -- โœ… Distributed background tasks - ---- - -## ๐Ÿ“ Files Modified - -### New Files Created -1. **`api/redis_manager.py`** (560 lines) - - RedisManager class with pub/sub, caching, locks - - Player sessions, location registry, inventory caching - - Combat state caching, disconnected player tracking - - Distributed lock acquisition for background tasks - -### Modified Files -1. **`docker-compose.yml`** - - Added `echoes_of_the_ashes_redis` service - - Redis 7 Alpine with AOF/RDB persistence - - 512MB memory limit, LRU eviction policy - - Added `echoes-redis-data` volume - -2. **`api/main.py`** - - Imported `redis_manager` - - Updated `ConnectionManager` with Redis pub/sub - - Added `lifespan` Redis initialization - - Updated movement endpoint with cache updates - - Updated combat endpoint with cache invalidation - - Updated inventory endpoints with cache invalidation - - Updated location endpoint to show disconnected players - -3. **`api/requirements.txt`** - - Added `redis[hiredis]==5.0.1` - -4. **`requirements.txt`** (root) - - Added `redis[hiredis]==5.0.1` - -5. **`api/start.sh`** - - Updated from 1 worker to 4 workers - - Removed TODO comment (now implemented!) - ---- - -## ๐Ÿ”ง Redis Configuration - -### Persistence -```bash -# AOF (Append-Only File) - Durability ---appendonly yes ---appendfsync everysec # Sync every second (max 1s data loss) - -# RDB (Snapshotting) - Fast restarts ---save 900 1 # Backup every 15 min if 1+ key changed ---save 300 10 # Backup every 5 min if 10+ keys changed ---save 60 10000 # Backup every 1 min if 10k+ keys changed -``` - -### Memory Management -```bash ---maxmemory 512mb # Max memory usage ---maxmemory-policy allkeys-lru # Evict least recently used keys -``` - -### Data Expiration -- **Player sessions**: 30 minutes TTL (refreshed on activity) -- **Inventory cache**: 10 minutes TTL (invalidated on changes) -- **Combat state**: No expiration (deleted when combat ends) -- **Dropped items**: 1 hour TTL - ---- - -## ๐Ÿš€ Pub/Sub Channels - -### Channel Types - -#### Location Channels (14 total) -``` -location:start_point -location:overpass -location:gas_station -location:abandoned_house -location:forest_edge -location:forest_clearing -location:forest_depths -location:cave_entrance -location:cave_passage -location:cave_depths -location:ruins_entrance -location:ruins_interior -location:supply_depot -location:raider_camp -``` - -**Usage**: Broadcast messages to all players in a specific location -- Player arrivals/departures -- Combat events -- Item pickups/drops -- NPC spawns - -#### Player Channels (Dynamic) -``` -player:{character_id} -``` - -**Usage**: Personal messages to specific players -- Combat updates -- XP gain notifications -- Level up messages -- PvP challenges - -#### Global Broadcast -``` -game:broadcast -``` - -**Usage**: Server-wide announcements -- Maintenance notifications -- Event triggers -- Admin messages - ---- - -## ๐Ÿ“Š Cache Strategy - -### What We Cache - -#### Player Sessions (30min TTL) -```redis -HSET player:{id}:session - websocket_connected: true/false - username: string - location_id: string - hp: int - max_hp: int - stamina: int - max_stamina: int - level: int - xp: int - disconnect_time: timestamp (if disconnected) -``` - -**Why**: Avoid DB queries for frequently accessed player data - -#### Location Player Registry (No TTL) -```redis -SADD location:{location_id}:players {character_id} -``` - -**Why**: Fast lookups for "who's in this location" without DB query - -#### Inventory Cache (10min TTL) -```redis -SET player:{id}:inventory JSON -``` - -**Why**: Inventory displayed frequently, reduce DB load - -#### Combat State (No TTL) -```redis -HSET player:{id}:combat - npc_id: string - npc_hp: int - npc_max_hp: int - turn: "player" | "npc" - round: int -``` - -**Why**: Combat actions require fast access, deleted when combat ends - -### What We DON'T Cache - -- โŒ **Locations** - Already in memory from `locations.json` -- โŒ **Items** - Already in memory from `items.json` -- โŒ **NPCs** - Already in memory from `npcs.json` - -**Reason**: Static data loaded on startup, no need for Redis duplication - ---- - -## ๐ŸŽฎ Disconnected Player Mechanics - -### Feature: Players Stay in Location After Disconnect - -**Rationale**: Adds risk/consequence to disconnecting in dangerous areas - -#### Behavior -1. **When player disconnects**: - - WebSocket connection closed - - Player session marked as `websocket_connected: false` - - `disconnect_time` timestamp stored - - **Player STAYS in location registry** (not removed!) - - Broadcast to location: "{username} has disconnected (vulnerable)" - -2. **Other players see disconnected player**: - ```json - { - "id": 5, - "name": "OtherPlayer", - "level": 7, - "is_connected": false, - "vulnerable": true // If in dangerous zone (danger_level >= 3) - } - ``` - -3. **PvP with disconnected players**: - - Can still be attacked in dangerous zones - - Auto-acknowledge combat (can't respond) - - Attacker gets first strike advantage - - Message: "OtherPlayer is disconnected - you get first strike!" - -4. **Cleanup policy**: - - After 1 hour disconnected: Remove from location registry - - Background task runs every 5 minutes to cleanup - -#### Frontend Display -```tsx -{!player.is_connected && ( - โš ๏ธ Disconnected (Vulnerable) -)} -{player.vulnerable && ( - -)} -``` - ---- - -## ๐Ÿ“ˆ Performance Improvements - -### Estimated Metrics - -#### Database Query Reduction -- **Before**: Every location broadcast queries `get_players_in_location()` from DB -- **After**: Check Redis `location:{id}:players` set (O(1) lookup) -- **Reduction**: ~70-80% fewer DB queries - -#### WebSocket Latency -- **Before**: Single worker, broadcasts queue if busy -- **After**: 4 workers, load balanced, Redis pub/sub < 2ms -- **Improvement**: ~50% reduction in broadcast latency - -#### Concurrent Players -- **Before**: ~200-300 players (single worker bottleneck) -- **After**: ~800-1200 players (4 workers, Redis coordination) -- **Scaling**: Horizontal scaling ready (add more workers) - ---- - -## ๐Ÿงช Testing & Verification - -### Manual Tests Performed - -1. **Multi-Worker Startup** โœ… - ```bash - $ docker logs echoes_of_the_ashes_api | grep "Worker" - โœ… Worker registered: 70bbc0c6 - โœ… Worker registered: bed4293b - โœ… Worker registered: 9ef23102 - โœ… Worker registered: 758e940e - ``` - -2. **Redis Connection** โœ… - ```bash - $ docker logs echoes_of_the_ashes_api | grep "Redis" - โœ… Redis connected (Worker: 70bbc0c6) - โœ… Redis connected (Worker: bed4293b) - โœ… Redis connected (Worker: 9ef23102) - โœ… Redis connected (Worker: 758e940e) - ``` - -3. **Channel Subscriptions** โœ… - ```bash - $ docker logs echoes_of_the_ashes_api | grep "subscribed" - ๐Ÿ“ก Worker 70bbc0c6 subscribed to 15 channels - ๐Ÿ“ก Worker bed4293b subscribed to 15 channels - ๐Ÿ“ก Worker 9ef23102 subscribed to 15 channels - ๐Ÿ“ก Worker 758e940e subscribed to 15 channels - ``` - -4. **Player Session Caching** โœ… - ```bash - $ docker exec echoes_of_the_ashes_redis redis-cli HGETALL "player:1:session" - username: Jocaru - location_id: overpass - hp: 8560 - level: 9 - ``` - -5. **Location Registry** โœ… - ```bash - $ docker exec echoes_of_the_ashes_redis redis-cli SMEMBERS "location:overpass:players" - 1 - ``` - -6. **Background Task Distribution** โœ… - ```bash - $ docker logs echoes_of_the_ashes_api | grep "Background" - โœ… Started 6 background tasks in this worker # Only one worker - โญ๏ธ Background tasks running in another worker # Other 3 workers - ``` - -### Next Steps for Testing - -1. **Load Testing**: - - Simulate 100+ concurrent WebSocket connections - - Verify cross-worker broadcasts work correctly - - Monitor Redis pub/sub latency - -2. **Cache Hit Rate**: - - Monitor `redis-cli INFO stats` for keyspace_hits vs keyspace_misses - - Target: >70% hit rate for inventory/sessions - -3. **Disconnected Player Flow**: - - Test disconnect โ†’ stay visible โ†’ PvP attack โ†’ cleanup - -4. **Failover Testing**: - - Kill a worker, verify remaining workers handle load - - Check Redis automatic failover (if using Redis Sentinel) - ---- - -## ๐Ÿ› Known Issues & Limitations - -### Current Limitations - -1. **No Redis Clustering** (Yet) - - Single Redis instance - - Future: Redis Cluster for HA/scalability - -2. **No Monitoring Dashboard** - - No Grafana/Prometheus metrics yet - - Future: Redis metrics, worker health, cache hit rates - -3. **Manual Cache Invalidation** - - Requires careful invalidation on every write - - Risk: Stale data if invalidation missed - - Mitigation: Short TTLs (10-30 min) as fallback - -4. **No Circuit Breaker** - - If Redis down, app crashes - - Future: Graceful degradation to single-worker mode - -### Edge Cases Handled - -โœ… **Worker crash**: Redis pub/sub continues with remaining workers -โœ… **Redis restart**: Workers reconnect automatically (connection retry logic) -โœ… **Player disconnect**: Session kept for 30min, cleanup after 1 hour -โœ… **Duplicate combat logs**: WebSocket deduplication by worker_id -โœ… **Inventory desync**: Aggressive invalidation on all changes - ---- - -## ๐Ÿ“š Code Examples - -### Publishing a Message to Location -```python -# In main.py movement endpoint -await redis_manager.publish_to_location( - new_location_id, - { - "type": "location_update", - "data": { - "message": f"{player['name']} arrived", - "action": "player_arrived", - "player_id": player_id - } - } -) -``` - -### Handling Redis Message (Cross-Worker) -```python -# In ConnectionManager -async def handle_redis_message(self, channel: str, data: dict): - # Worker receives message from Redis pub/sub - if channel.startswith("location:"): - location_id = channel.split(":")[1] - player_ids = await redis_manager.get_players_in_location(location_id) - - # Only send to local WebSocket connections - for player_id in player_ids: - if player_id in self.active_connections: - await self._send_direct(player_id, message) -``` - -### Cache Invalidation on Inventory Change -```python -# After dropping item -await db.remove_item_from_inventory(player_id, item_id, quantity) - -# Invalidate cache -if redis_manager: - await redis_manager.invalidate_inventory(player_id) -``` - -### Disconnected Player Tracking -```python -# On WebSocket disconnect -await manager.disconnect(player_id) - -# In ConnectionManager.disconnect() -if redis_manager: - await redis_manager.mark_player_disconnected(player_id) - # Player STAYS in location registry, marked as vulnerable -``` - ---- - -## ๐ŸŽฏ Performance Targets vs Actual - -| Metric | Target | Actual | Status | -|--------|--------|--------|--------| -| Workers | 4 | 4 | โœ… | -| DB Query Reduction | 70% | ~70-80% (estimated) | โœ… | -| WebSocket Latency | < 50ms | < 2ms (Redis) + network | โœ… | -| Concurrent Players | 800+ | TBD (needs load test) | ๐ŸŸก | -| Cache Hit Rate | > 70% | TBD (needs monitoring) | ๐ŸŸก | -| Redis Memory Usage | < 512MB | < 50MB (current) | โœ… | - ---- - -## ๐Ÿ”ฎ Future Enhancements - -### Phase 2 (Next Steps) -1. **Redis Sentinel** - High availability, automatic failover -2. **Monitoring Dashboard** - Grafana + Prometheus for Redis metrics -3. **Cache Preloading** - Warm cache on server startup -4. **Circuit Breaker** - Graceful degradation if Redis fails -5. **Rate Limiting** - Redis-based rate limiter for API endpoints - -### Phase 3 (Advanced) -1. **Redis Cluster** - Horizontal scaling of Redis itself -2. **Session Replication** - Replicate sessions across Redis nodes -3. **WebSocket Sticky Sessions** - Optimize routing with sticky sessions -4. **Cache Analytics** - Track cache hit rates, optimize TTLs -5. **Distributed Tracing** - OpenTelemetry for request tracing - ---- - -## ๐Ÿ“ž Troubleshooting - -### Redis Not Connecting -```bash -# Check Redis is running -docker ps | grep redis - -# Check Redis logs -docker logs echoes_of_the_ashes_redis - -# Test connection -docker exec echoes_of_the_ashes_redis redis-cli PING -# Should return: PONG -``` - -### Workers Not Registering -```bash -# Check worker logs -docker logs echoes_of_the_ashes_api | grep "Worker registered" - -# Check active workers in Redis -docker exec echoes_of_the_ashes_redis redis-cli SMEMBERS active_workers -``` - -### Cache Not Working -```bash -# Check cache keys -docker exec echoes_of_the_ashes_redis redis-cli KEYS "*" - -# Monitor cache hits/misses -docker exec echoes_of_the_ashes_redis redis-cli INFO stats | grep keyspace - -# Check TTLs -docker exec echoes_of_the_ashes_redis redis-cli TTL player:1:session -``` - ---- - -## โœ… Deployment Checklist - -- [x] Add Redis container to docker-compose.yml -- [x] Create redis_manager.py module -- [x] Update ConnectionManager for pub/sub -- [x] Update main.py lifespan for Redis init -- [x] Add cache invalidation to critical endpoints -- [x] Implement disconnected player mechanics -- [x] Add redis dependency to requirements.txt -- [x] Update start.sh to 4 workers -- [x] Rebuild API container with Redis -- [x] Test multi-worker startup -- [x] Verify Redis connection -- [x] Verify pub/sub channels -- [x] Verify cache functionality -- [x] Deploy to production - ---- - -## ๐ŸŽ‰ Success Metrics - -### Deployment Success -- โœ… All 4 workers started -- โœ… Redis connected with AOF+RDB persistence -- โœ… All workers subscribed to 15 channels -- โœ… Background tasks distributed (only 1 worker runs them) -- โœ… Player sessions cached -- โœ… Location registry working -- โœ… No errors in logs - -### System Health -```bash -$ docker ps --format "table {{.Names}}\t{{.Status}}" -echoes_of_the_ashes_pwa Up 5 minutes (healthy) -echoes_of_the_ashes_api Up 5 minutes (healthy) -echoes_of_the_ashes_redis Up 5 minutes (healthy) -echoes_of_the_ashes_db Up 5 minutes (healthy) -echoes_of_the_ashes_map Up 5 minutes (healthy) -``` - ---- - -## ๐Ÿ“ Notes - -- Redis persistence enabled: AOF (every second) + RDB (periodic snapshots) -- Memory limit set to 512MB with LRU eviction -- 4 workers configured for ~800-1200 concurrent players -- Background tasks use Redis locks to ensure only one worker runs them -- Player sessions include disconnect tracking for PvP vulnerability -- Cache invalidation is aggressive to prevent stale data -- Static game data (locations, items, NPCs) NOT cached in Redis - ---- - -**Implementation Complete**: November 9, 2025 -**Production Deployment**: November 9, 2025 -**Status**: โœ… LIVE AND OPERATIONAL diff --git a/old/REDIS_INTEGRATION_PLAN.md b/old/REDIS_INTEGRATION_PLAN.md deleted file mode 100644 index 502c2dc..0000000 --- a/old/REDIS_INTEGRATION_PLAN.md +++ /dev/null @@ -1,1168 +0,0 @@ -# Redis Integration Plan: Scalable Multi-Worker Architecture - -## Executive Summary - -This document outlines a comprehensive plan to integrate Redis into Echoes of the Ashes for: -1. **Horizontal Scaling**: Support multiple FastAPI workers behind a load balancer -2. **Performance**: Reduce database queries by caching frequently accessed data -3. **Real-time Updates**: Improve WebSocket message delivery across workers using Pub/Sub - -## Current Architecture Analysis - -### Existing Limitations - -1. **Single-Worker WebSocket Management** - - `ConnectionManager` stores WebSocket connections in memory - - Each worker has its own isolated connection dictionary - - Player on Worker A cannot receive broadcasts triggered by Worker B - - No cross-worker communication mechanism - -2. **Database Query Bottlenecks** - - Every location broadcast queries `get_players_in_location()` from PostgreSQL - - Profile/state endpoints fetch fresh data on every request - - Location data, item definitions, NPC stats loaded repeatedly - - High database load with many concurrent players - -3. **Background Tasks** - - Single worker executes background tasks (status effects, spawns, etc.) - - Uses file locking to prevent duplicate execution - - Not suitable for true multi-worker horizontal scaling - -## Proposed Redis Architecture - -### 1. Redis Data Structures - -#### A. Player Session Data (Redis Hash) -**Key Pattern**: `player:{character_id}:session` - -**Fields**: -```json -{ - "worker_id": "worker-1", - "websocket_connected": "true", - "location_id": "overpass", - "hp": "85", - "max_hp": "100", - "stamina": "42", - "max_stamina": "50", - "level": "12", - "xp": "2450", - "last_movement_time": "1762710676.592", - "in_combat": "false", - "last_heartbeat": "1762710676.592" -} -``` - -**TTL**: 30 minutes (refreshed on activity) - -**Purpose**: -- Track which worker manages each player's WebSocket -- Cache player stats to avoid DB queries -- Detect disconnected/stale sessions - ---- - -#### B. Location Player Registry (Redis Set) -**Key Pattern**: `location:{location_id}:players` - -**Values**: Character IDs (integers) - -**Example**: -``` -location:overpass:players = {1, 2, 5, 12, 45} -``` - -**Purpose**: -- Instantly query who's in a location (no DB query) -- Used for targeted broadcasts -- Updated on player movement - ---- - -#### C. Worker Connection Registry (Redis Hash) -**Key Pattern**: `worker:{worker_id}:connections` - -**Fields**: `character_id -> websocket_info` - -**Example**: -```json -{ - "1": "connected", - "5": "connected", - "12": "connected" -} -``` - -**Purpose**: -- Each worker tracks its own connections -- Used to route messages to correct worker -- Cleaned up on worker shutdown - ---- - -#### D. Cached Location Data (Redis Hash) -**Key Pattern**: `location:{location_id}:cache` - -**Fields**: -```json -{ - "name": "Overpass", - "description": "A crumbling highway overpass...", - "exits": "{\"north\": \"gas_station\", \"south\": \"ruins\"}", - "danger_level": "3", - "image_path": "/images/locations/overpass.png", - "x": "2.5", - "y": "3.0", - "interactables": "[{...}]", - "npcs": "[{...}]" -} -``` - -**TTL**: No expiration (static data, invalidated manually) - -**Purpose**: -- Eliminate DB queries for location data -- Preloaded on server startup -- Served directly from Redis - ---- - -#### E. Cached Item Definitions (Redis Hash) -**Key Pattern**: `item:{item_id}:def` - -**Fields**: -```json -{ - "name": "Iron Sword", - "type": "weapon", - "weight": "3.5", - "damage_min": "5", - "damage_max": "12", - "durability": "100", - "tier": "2" -} -``` - -**TTL**: No expiration (static data) - -**Purpose**: -- Fast item lookups without parsing JSON files -- Preloaded on server startup - ---- - -#### F. Player Inventory Cache (Redis List) -**Key Pattern**: `player:{character_id}:inventory` - -**Values**: JSON-encoded inventory items - -**TTL**: 10 minutes - -**Purpose**: -- Cache inventory for quick profile/state responses -- Invalidated on item add/remove/use - ---- - -### 2. Redis Pub/Sub Channels - -#### A. Global Broadcast Channel -**Channel**: `game:broadcast` - -**Message Format**: -```json -{ - "type": "global_broadcast", - "payload": { - "type": "server_announcement", - "data": { - "message": "Server maintenance in 5 minutes" - } - } -} -``` - -**Subscribers**: All workers - -**Purpose**: Server-wide announcements - ---- - -#### B. Location-Specific Channels -**Channel Pattern**: `location:{location_id}` - -**Message Format**: -```json -{ - "type": "location_update", - "location_id": "overpass", - "exclude_player_id": 5, - "payload": { - "type": "location_update", - "data": { - "message": "Jocaru picked up 3x Iron Ore", - "action": "item_picked_up" - }, - "timestamp": "2025-11-09T17:52:00Z" - } -} -``` - -**Subscribers**: All workers - -**Purpose**: -- Workers subscribe to all location channels -- When receiving a message, check if they have connected players in that location -- Send WebSocket messages to their local connections only - ---- - -#### C. Player-Specific Channels -**Channel Pattern**: `player:{character_id}` - -**Message Format**: -```json -{ - "type": "personal_message", - "player_id": 5, - "payload": { - "type": "combat_update", - "data": { - "message": "You dealt 12 damage!", - "combat_over": false - } - } -} -``` - -**Subscribers**: Worker managing that player's WebSocket - -**Purpose**: -- Direct messages to specific players -- Combat updates, inventory changes, etc. - ---- - -#### D. Worker Coordination Channel -**Channel**: `game:workers` - -**Message Format**: -```json -{ - "type": "worker_join", - "worker_id": "worker-3", - "timestamp": "2025-11-09T17:52:00Z" -} -``` - -**Purpose**: -- Worker lifecycle events -- Graceful shutdown coordination -- Load distribution awareness - ---- - -## Implementation Plan - -### Phase 1: Redis Infrastructure Setup - -#### 1.1 Add Redis to Docker Compose - -**File**: `docker-compose.yml` - -```yaml - echoes_redis: - image: redis:7-alpine - container_name: echoes_of_the_ashes_redis - restart: unless-stopped - command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru - volumes: - - redis_data:/data - networks: - - default_docker - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 3s - retries: 5 - -volumes: - redis_data: -``` - -**Environment Variables** (`.env`): -```bash -REDIS_HOST=echoes_redis -REDIS_PORT=6379 -REDIS_DB=0 -REDIS_PASSWORD= # Optional, leave empty for dev -``` - -#### 1.2 Install Python Dependencies - -**File**: `requirements.txt` - -```txt -redis==5.0.1 -hiredis==2.3.2 # C parser for performance -``` - ---- - -### Phase 2: Redis Client Module - -#### 2.1 Create Redis Manager - -**File**: `api/redis_manager.py` - -```python -""" -Redis manager for caching and pub/sub functionality. -Handles connection pooling, pub/sub, and caching operations. -""" -import os -import json -import asyncio -import redis.asyncio as redis -from typing import Optional, Dict, Any, List, Set -from datetime import timedelta - -class RedisManager: - def __init__(self): - self.redis_client: Optional[redis.Redis] = None - self.pubsub: Optional[redis.client.PubSub] = None - self.worker_id: str = f"worker-{os.getpid()}" - self.subscribed_channels: Set[str] = set() - - async def connect(self): - """Initialize Redis connection pool""" - host = os.getenv("REDIS_HOST", "localhost") - port = int(os.getenv("REDIS_PORT", "6379")) - db = int(os.getenv("REDIS_DB", "0")) - password = os.getenv("REDIS_PASSWORD", None) - - self.redis_client = redis.Redis( - host=host, - port=port, - db=db, - password=password, - decode_responses=True, - socket_keepalive=True, - socket_connect_timeout=5, - retry_on_timeout=True - ) - - # Test connection - await self.redis_client.ping() - print(f"โœ… Redis connected: {host}:{port}") - - # Initialize pub/sub - self.pubsub = self.redis_client.pubsub() - - async def disconnect(self): - """Close Redis connections""" - if self.pubsub: - await self.pubsub.close() - if self.redis_client: - await self.redis_client.close() - - # ====== SESSION MANAGEMENT ====== - - async def set_player_session(self, character_id: int, data: Dict[str, Any], ttl: int = 1800): - """Store player session data with TTL""" - key = f"player:{character_id}:session" - data['worker_id'] = self.worker_id - data['last_heartbeat'] = str(asyncio.get_event_loop().time()) - - await self.redis_client.hset(key, mapping=data) - await self.redis_client.expire(key, ttl) - - async def get_player_session(self, character_id: int) -> Optional[Dict[str, Any]]: - """Retrieve player session data""" - key = f"player:{character_id}:session" - data = await self.redis_client.hgetall(key) - return data if data else None - - async def delete_player_session(self, character_id: int): - """Remove player session""" - key = f"player:{character_id}:session" - await self.redis_client.delete(key) - - # ====== LOCATION REGISTRY ====== - - async def add_player_to_location(self, character_id: int, location_id: str): - """Add player to location set""" - key = f"location:{location_id}:players" - await self.redis_client.sadd(key, character_id) - - async def remove_player_from_location(self, character_id: int, location_id: str): - """Remove player from location set""" - key = f"location:{location_id}:players" - await self.redis_client.srem(key, character_id) - - async def get_players_in_location(self, location_id: str) -> List[int]: - """Get all player IDs in a location""" - key = f"location:{location_id}:players" - members = await self.redis_client.smembers(key) - return [int(m) for m in members] if members else [] - - async def move_player_location(self, character_id: int, from_location: str, to_location: str): - """Atomically move player between locations""" - await self.remove_player_from_location(character_id, from_location) - await self.add_player_to_location(character_id, to_location) - - # ====== CACHING ====== - - async def cache_location(self, location_id: str, data: Dict[str, Any]): - """Cache location data (no expiration - static data)""" - key = f"location:{location_id}:cache" - # Serialize complex objects to JSON strings - cache_data = {} - for k, v in data.items(): - if isinstance(v, (dict, list)): - cache_data[k] = json.dumps(v) - else: - cache_data[k] = str(v) - await self.redis_client.hset(key, mapping=cache_data) - - async def get_cached_location(self, location_id: str) -> Optional[Dict[str, Any]]: - """Retrieve cached location data""" - key = f"location:{location_id}:cache" - data = await self.redis_client.hgetall(key) - if not data: - return None - - # Deserialize JSON fields - for k in ['exits', 'interactables', 'npcs']: - if k in data: - try: - data[k] = json.loads(data[k]) - except: - pass - return data - - async def cache_inventory(self, character_id: int, inventory: List[Dict], ttl: int = 600): - """Cache player inventory""" - key = f"player:{character_id}:inventory" - await self.redis_client.delete(key) # Clear old data - if inventory: - inventory_json = [json.dumps(item) for item in inventory] - await self.redis_client.rpush(key, *inventory_json) - await self.redis_client.expire(key, ttl) - - async def get_cached_inventory(self, character_id: int) -> Optional[List[Dict]]: - """Retrieve cached inventory""" - key = f"player:{character_id}:inventory" - items = await self.redis_client.lrange(key, 0, -1) - if not items: - return None - return [json.loads(item) for item in items] - - async def invalidate_inventory(self, character_id: int): - """Invalidate inventory cache""" - key = f"player:{character_id}:inventory" - await self.redis_client.delete(key) - - # ====== PUB/SUB ====== - - async def publish_to_location(self, location_id: str, message: Dict[str, Any], exclude_player_id: Optional[int] = None): - """Publish message to location channel""" - channel = f"location:{location_id}" - payload = { - "type": "location_update", - "location_id": location_id, - "exclude_player_id": exclude_player_id, - "payload": message - } - await self.redis_client.publish(channel, json.dumps(payload)) - - async def publish_to_player(self, character_id: int, message: Dict[str, Any]): - """Publish message to player-specific channel""" - channel = f"player:{character_id}" - payload = { - "type": "personal_message", - "player_id": character_id, - "payload": message - } - await self.redis_client.publish(channel, json.dumps(payload)) - - async def subscribe_to_channels(self, channels: List[str]): - """Subscribe to multiple channels""" - await self.pubsub.subscribe(*channels) - self.subscribed_channels.update(channels) - print(f"๐Ÿ“ก Worker {self.worker_id} subscribed to {len(channels)} channels") - - async def subscribe_to_player(self, character_id: int): - """Subscribe to player-specific channel""" - channel = f"player:{character_id}" - await self.pubsub.subscribe(channel) - self.subscribed_channels.add(channel) - - async def unsubscribe_from_player(self, character_id: int): - """Unsubscribe from player channel""" - channel = f"player:{character_id}" - if channel in self.subscribed_channels: - await self.pubsub.unsubscribe(channel) - self.subscribed_channels.remove(channel) - - async def listen_for_messages(self, message_handler): - """Listen for pub/sub messages (blocking)""" - async for message in self.pubsub.listen(): - if message['type'] == 'message': - try: - data = json.loads(message['data']) - await message_handler(message['channel'], data) - except Exception as e: - print(f"โŒ Error handling pub/sub message: {e}") - -# Global Redis instance -redis_manager = RedisManager() -``` - ---- - -### Phase 3: Enhanced Connection Manager - -#### 3.1 Update ConnectionManager to Use Redis - -**File**: `api/main.py` (ConnectionManager class) - -**Changes**: - -1. **On WebSocket Connect**: - - Store connection locally (as before) - - Register player session in Redis - - Add player to location registry - - Subscribe to player-specific channel - - Broadcast "player_joined" to location - -2. **On WebSocket Disconnect**: - - Remove local connection - - Delete player session from Redis - - Remove from location registry - - Unsubscribe from player channel - - Broadcast "player_left" to location - -3. **Broadcasting**: - - `send_personal_message()`: Publish to Redis player channel (not direct WebSocket) - - `send_to_location()`: Publish to Redis location channel (not query DB) - - Local message handler receives Redis pub/sub and sends to local WebSockets - -**Implementation**: - -```python -class ConnectionManager: - def __init__(self, redis_manager): - self.active_connections: Dict[int, WebSocket] = {} - self.player_usernames: Dict[int, str] = {} - self.redis = redis_manager - self.worker_id = redis_manager.worker_id - - async def connect(self, websocket: WebSocket, player_id: int, username: str, location_id: str): - """Accept WebSocket and register in Redis""" - await websocket.accept() - self.active_connections[player_id] = websocket - self.player_usernames[player_id] = username - - # Register in Redis - await self.redis.set_player_session(player_id, { - 'websocket_connected': 'true', - 'location_id': location_id, - 'username': username - }) - await self.redis.add_player_to_location(player_id, location_id) - await self.redis.subscribe_to_player(player_id) - - print(f"๐Ÿ”Œ WebSocket connected: {username} (player_id={player_id}) on {self.worker_id}") - - def disconnect(self, player_id: int, location_id: str): - """Remove WebSocket and clean up Redis""" - if player_id in self.active_connections: - username = self.player_usernames.get(player_id, "unknown") - del self.active_connections[player_id] - if player_id in self.player_usernames: - del self.player_usernames[player_id] - - # Clean up Redis (fire-and-forget) - asyncio.create_task(self.redis.delete_player_session(player_id)) - asyncio.create_task(self.redis.remove_player_from_location(player_id, location_id)) - asyncio.create_task(self.redis.unsubscribe_from_player(player_id)) - - print(f"๐Ÿ”Œ WebSocket disconnected: {username} (player_id={player_id})") - - async def send_personal_message(self, player_id: int, message: dict): - """Publish to Redis player channel (cross-worker)""" - await self.redis.publish_to_player(player_id, message) - - async def send_to_location(self, location_id: str, message: dict, exclude_player_id: Optional[int] = None): - """Publish to Redis location channel (cross-worker)""" - await self.redis.publish_to_location(location_id, message, exclude_player_id) - - async def handle_redis_message(self, channel: str, data: dict): - """Handle incoming Redis pub/sub messages""" - if channel.startswith('player:'): - # Personal message - send to local WebSocket if connected - player_id = int(channel.split(':')[1]) - if player_id in self.active_connections: - try: - await self.active_connections[player_id].send_json(data['payload']) - except Exception as e: - print(f"โŒ Failed to send to player {player_id}: {e}") - # Don't disconnect here - let WebSocket handler detect it - - elif channel.startswith('location:'): - # Location broadcast - send to local WebSockets in that location - location_id = data['location_id'] - exclude_player_id = data.get('exclude_player_id') - payload = data['payload'] - - # Get players in location from Redis (fast!) - players_in_location = await self.redis.get_players_in_location(location_id) - - # Send to local connections only - for player_id in players_in_location: - if player_id != exclude_player_id and player_id in self.active_connections: - try: - await self.active_connections[player_id].send_json(payload) - except: - pass # Let WebSocket handler detect disconnects -``` - ---- - -### Phase 4: Preload Static Data into Redis - -#### 4.1 Startup Data Loader - -**File**: `api/redis_loader.py` - -```python -""" -Load static game data into Redis on server startup. -Reduces database queries for frequently accessed data. -""" -from .redis_manager import redis_manager -from .world_loader import load_world -from .items import ItemsManager - -async def preload_game_data(): - """Load all static game data into Redis""" - print("๐Ÿ”„ Preloading game data into Redis...") - - # Load world data - world = load_world() - - # Cache all locations - for location_id, location in world.locations.items(): - location_data = { - 'id': location.id, - 'name': location.name, - 'description': location.description, - 'exits': location.exits, - 'danger_level': location.danger_level, - 'image_path': location.image_path, - 'x': getattr(location, 'x', 0.0), - 'y': getattr(location, 'y', 0.0), - 'tags': getattr(location, 'tags', []), - 'interactables': [interactable.to_dict() for interactable in location.interactables], - 'npcs': location.npcs - } - await redis_manager.cache_location(location_id, location_data) - - print(f"โœ… Cached {len(world.locations)} locations in Redis") - - # Cache all items - items_manager = ItemsManager() - for item_id, item in items_manager.items.items(): - item_data = { - 'id': item.id, - 'name': item.name, - 'description': item.description, - 'type': item.type, - 'weight': item.weight, - 'volume': item.volume, - 'emoji': item.emoji, - 'image_path': item.image_path, - 'equippable': item.equippable, - 'consumable': item.consumable, - 'stats': item.stats or {}, - 'effects': item.effects or {} - } - key = f"item:{item_id}:def" - await redis_manager.redis_client.hset(key, mapping={ - k: json.dumps(v) if isinstance(v, (dict, list, bool)) else str(v) - for k, v in item_data.items() - }) - - print(f"โœ… Cached {len(items_manager.items)} items in Redis") -``` - -**Update `lifespan()` in `main.py`**: - -```python -@asynccontextmanager -async def lifespan(app: FastAPI): - # Startup - await db.init_db() - print("โœ… Database initialized") - - # Connect to Redis - await redis_manager.connect() - - # Preload static data - await preload_game_data() - - # Start pub/sub listener in background - asyncio.create_task(redis_manager.listen_for_messages(manager.handle_redis_message)) - - # Subscribe to all location channels - location_channels = [f"location:{loc_id}" for loc_id in LOCATIONS.keys()] - await redis_manager.subscribe_to_channels(location_channels + ['game:broadcast']) - - # Start background tasks - tasks = await background_tasks.start_background_tasks(manager, LOCATIONS) - - yield - - # Shutdown - await background_tasks.stop_background_tasks(tasks) - await redis_manager.disconnect() -``` - ---- - -### Phase 5: Update Endpoints to Use Redis Cache - -#### 5.1 Example: `/api/game/state` Endpoint - -**Before** (database queries): -```python -@app.get("/api/game/state") -async def get_game_state(current_user: dict = Depends(get_current_user)): - player = await db.get_player_by_id(current_user['id']) # DB query - location = LOCATIONS.get(player['location_id']) # In-memory - inventory = await db.get_inventory(current_user['id']) # DB query - equipment = await db.get_all_equipment(current_user['id']) # DB query - # ... more DB queries -``` - -**After** (Redis cache): -```python -@app.get("/api/game/state") -async def get_game_state(current_user: dict = Depends(get_current_user)): - # Get player session from Redis (cached) - player_session = await redis_manager.get_player_session(current_user['id']) - - # If not in cache, fetch from DB and cache - if not player_session: - player = await db.get_player_by_id(current_user['id']) - await redis_manager.set_player_session(current_user['id'], player) - player_session = player - - # Get location from Redis cache - location_data = await redis_manager.get_cached_location(player_session['location_id']) - - # Get inventory from Redis cache - inventory = await redis_manager.get_cached_inventory(current_user['id']) - if not inventory: - inventory = await db.get_inventory(current_user['id']) - await redis_manager.cache_inventory(current_user['id'], inventory) - - # ... rest of endpoint -``` - -#### 5.2 Example: `/api/game/move` Endpoint - -**Update to invalidate caches and publish events**: - -```python -@app.post("/api/game/move") -async def move(move_req: MoveRequest, current_user: dict = Depends(get_current_user)): - # ... existing validation ... - - # Execute move - success, message, new_location_id, stamina_cost, distance = await game_logic.move_player( - current_user['id'], - move_req.direction, - LOCATIONS - ) - - if success: - old_location = player['location_id'] - - # Update Redis location registry - await redis_manager.move_player_location( - current_user['id'], - old_location, - new_location_id - ) - - # Update player session cache - await redis_manager.set_player_session(current_user['id'], { - 'location_id': new_location_id, - 'stamina': player['stamina'] - stamina_cost, - 'last_movement_time': current_time - }) - - # Broadcast to OLD location via Redis pub/sub - await redis_manager.publish_to_location( - old_location, - { - "type": "location_update", - "data": { - "message": f"{player['name']} left the area", - "action": "player_left", - "player_id": current_user['id'] - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=current_user['id'] - ) - - # Broadcast to NEW location via Redis pub/sub - await redis_manager.publish_to_location( - new_location_id, - { - "type": "location_update", - "data": { - "message": f"{player['name']} arrived", - "action": "player_arrived", - "player_id": current_user['id'] - }, - "timestamp": datetime.utcnow().isoformat() - }, - exclude_player_id=current_user['id'] - ) - - # Send direct update to moving player - await redis_manager.publish_to_player( - current_user['id'], - { - "type": "state_update", - "data": { - "player": { - "stamina": player['stamina'] - stamina_cost, - "location_id": new_location_id - } - }, - "timestamp": datetime.utcnow().isoformat() - } - ) -``` - ---- - -### Phase 6: Multi-Worker Setup - -#### 6.1 Update Docker Compose for Multiple Workers - -**File**: `docker-compose.yml` - -```yaml - echoes_of_the_ashes_api: - build: - context: . - dockerfile: Dockerfile.api - restart: unless-stopped - env_file: - - .env - environment: - - WORKERS=4 # Number of Uvicorn workers - volumes: - - ./gamedata:/app/gamedata:ro - - ./images:/app/images:ro - depends_on: - - echoes_of_the_ashes_db - - echoes_redis - deploy: - replicas: 1 # Single container, multiple workers inside - networks: - - default_docker - - traefik - labels: - # ... Traefik labels -``` - -#### 6.2 Update Dockerfile to Support Multi-Worker - -**File**: `Dockerfile.api` - -```dockerfile -# ... existing build steps ... - -# Run with multiple workers -CMD ["sh", "-c", "uvicorn api.main:app --host 0.0.0.0 --port 8000 --workers ${WORKERS:-4}"] -``` - ---- - -### Phase 7: Background Tasks with Redis Coordination - -#### 7.1 Distributed Task Locking - -**File**: `api/background_tasks.py` - -**Update to use Redis locks instead of file locks**: - -```python -import asyncio -from .redis_manager import redis_manager - -async def acquire_distributed_lock(lock_name: str, ttl: int = 300) -> bool: - """Acquire a distributed lock in Redis""" - key = f"lock:{lock_name}" - acquired = await redis_manager.redis_client.set( - key, - redis_manager.worker_id, - nx=True, # Only set if not exists - ex=ttl # TTL in seconds - ) - return acquired - -async def release_distributed_lock(lock_name: str): - """Release a distributed lock""" - key = f"lock:{lock_name}" - # Only delete if we own the lock - lock_value = await redis_manager.redis_client.get(key) - if lock_value == redis_manager.worker_id: - await redis_manager.redis_client.delete(key) - -async def spawn_manager_task(): - """Background task: Spawn wandering enemies (single worker)""" - while True: - try: - # Try to acquire lock - if await acquire_distributed_lock('spawn_manager', ttl=60): - print(f"๐Ÿ”’ {redis_manager.worker_id} acquired spawn_manager lock") - - # Do work - await spawn_wandering_enemies() - - # Release lock - await release_distributed_lock('spawn_manager') - else: - print(f"โญ๏ธ Another worker handling spawn_manager") - except Exception as e: - print(f"โŒ Error in spawn_manager: {e}") - await release_distributed_lock('spawn_manager') - - await asyncio.sleep(30) -``` - ---- - -## Performance Benefits - -### Expected Improvements - -1. **Horizontal Scaling** - - Support 4+ workers behind load balancer - - Linear scaling with player count - - No single point of failure - -2. **Database Load Reduction** - - **Location queries**: 0 DB queries (100% Redis) - - **Player sessions**: 90% cache hit rate (estimated) - - **Inventory queries**: 80% cache hit rate (estimated) - - **Overall DB load**: Reduced by 70-80% - -3. **Latency Improvements** - - Location broadcasts: Redis pub/sub ~1-2ms vs DB query ~10-50ms - - Player state lookups: Redis GET ~0.5ms vs DB query ~5-20ms - - Inventory fetches: Redis LIST ~1ms vs DB query ~10-30ms - -4. **Concurrent Player Capacity** - - Current (single worker): ~100-200 concurrent players - - With Redis (4 workers): ~800-1200 concurrent players - ---- - -## Migration Strategy - -### Step-by-Step Rollout - -**Week 1: Infrastructure** -- Add Redis container to docker-compose -- Create redis_manager.py module -- Test Redis connectivity - -**Week 2: Caching Layer** -- Implement session caching -- Implement location caching -- Implement inventory caching -- A/B test: 10% traffic uses Redis cache - -**Week 3: Pub/Sub Integration** -- Update ConnectionManager for Redis pub/sub -- Migrate location broadcasts to Redis -- Migrate personal messages to Redis -- Test with 2 workers - -**Week 4: Multi-Worker Rollout** -- Deploy 2 workers in production -- Monitor for 3 days -- If stable, scale to 4 workers -- Monitor database load reduction - -**Week 5: Background Tasks** -- Migrate background tasks to Redis locks -- Remove file-based locking -- Test distributed task execution - -**Week 6: Optimization** -- Fine-tune TTLs -- Add monitoring/metrics -- Optimize cache invalidation -- Performance profiling - ---- - -## Monitoring & Metrics - -### Redis Metrics to Track - -1. **Connection Pool** - - Active connections per worker - - Connection failures - - Reconnection attempts - -2. **Cache Performance** - - Hit rate (target: >80%) - - Miss rate - - Eviction rate - -3. **Pub/Sub** - - Messages published/second - - Subscriber count - - Message delivery latency - -4. **Memory Usage** - - Total Redis memory - - Key count by pattern - - Eviction policy effectiveness - -### Database Metrics to Track - -1. **Query Reduction** - - Queries/second before vs after - - Slow query log reduction - - Connection pool utilization - ---- - -## Rollback Plan - -If Redis integration causes issues: - -1. **Immediate Rollback** (< 5 minutes) - - Set `REDIS_ENABLED=false` env variable - - Restart API containers - - Falls back to original ConnectionManager - -2. **Graceful Degradation** - - Keep DB queries as fallback for cache misses - - Log Redis errors but don't fail requests - - Monitor error rate and alert if > 5% - ---- - -## Cost Estimation - -### Redis Resource Requirements - -- **Memory**: 512MB (covers ~5000 concurrent players) -- **CPU**: Minimal (< 5% of single core) -- **Network**: ~1-5 Mbps for pub/sub - -### Infrastructure Costs (AWS example) - -- **ElastiCache (Redis)**: ~$15-30/month (cache.t3.micro) -- **Additional EC2 capacity**: ~$20-40/month (for extra workers) -- **Total increase**: ~$35-70/month -- **Savings**: ~$50-100/month (reduced RDS IOPS/queries) -- **Net cost**: $0-20/month (may actually save money!) - ---- - -## Success Criteria - -### Key Performance Indicators (KPIs) - -1. **Scalability** - - โœ… Support 4+ workers - - โœ… Handle 1000+ concurrent players - - โœ… Linear scaling with worker count - -2. **Performance** - - โœ… 70% reduction in database queries - - โœ… < 10ms p95 latency for cached operations - - โœ… < 2ms p95 for Redis pub/sub delivery - -3. **Reliability** - - โœ… 99.9% uptime - - โœ… Graceful handling of Redis failures - - โœ… No message loss during worker restarts - -4. **Developer Experience** - - โœ… Simple cache invalidation API - - โœ… Clear pub/sub message patterns - - โœ… Easy to add new cached data types - ---- - -## Questions for Review - -1. **TTL Strategy**: Are the proposed TTLs (30min for sessions, 10min for inventory) appropriate? - -2. **Cache Invalidation**: Should we implement more aggressive cache invalidation (e.g., on every DB write)? - -3. **Worker Count**: Start with 2 workers or go straight to 4? - -4. **Redis Persistence**: Use RDB, AOF, or both? (Affects recovery time vs write performance) - -5. **Fallback Strategy**: Should Redis cache misses always fall back to DB, or fail fast? - -6. **Monitoring**: What additional metrics do you want to track? - ---- - -## Next Steps - -**If approved, I will:** - -1. Create detailed implementation tasks for each phase -2. Set up feature flags for gradual rollout -3. Add comprehensive logging for debugging -4. Create automated tests for Redis functionality -5. Document all new pub/sub message formats -6. Create runbook for Redis operational issues - -**Please review and provide feedback on:** -- Architecture approach -- Data structure choices -- Migration strategy -- Timeline/prioritization -- Any concerns or alternative approaches - ---- - -## Appendix: Alternative Approaches Considered - -### A. Using Redis Streams Instead of Pub/Sub - -**Pros**: Message persistence, consumer groups, replay capability -**Cons**: More complex, higher memory usage, not needed for ephemeral game events -**Decision**: Stick with Pub/Sub for simplicity - -### B. Using Kafka for Message Broker - -**Pros**: Better for high-throughput, message persistence -**Cons**: Much heavier infrastructure, overkill for this use case -**Decision**: Redis Pub/Sub is sufficient - -### C. Caching in Application Memory Instead of Redis - -**Pros**: Faster access (no network hop) -**Cons**: No cross-worker sharing, higher memory per worker -**Decision**: Redis for cross-worker coordination - diff --git a/old/REDIS_INTEGRATION_QA.md b/old/REDIS_INTEGRATION_QA.md deleted file mode 100644 index 1859b3c..0000000 --- a/old/REDIS_INTEGRATION_QA.md +++ /dev/null @@ -1,765 +0,0 @@ -# Redis Integration: Questions & Answers - -## Q1: Why cache locations/items if they're already in memory? - -**Short Answer**: You're absolutely right - we should **NOT** cache static data that's already loaded in memory! - -**Revised Approach**: - -### What to Cache in Redis: -1. โœ… **Player sessions** (dynamic, needs cross-worker sharing) -2. โœ… **Location player registry** (who's where, changes constantly) -3. โœ… **Player inventory** (reduce DB queries for frequently accessed data) -4. โœ… **Active combat states** (for cross-worker coordination) -5. โœ… **Dropped items per location** (dynamic world state) - -### What NOT to Cache: -1. โŒ **Locations** - Already in `LOCATIONS` dict from `world_loader.py` -2. โŒ **Items** - Already in `ITEMS_MANAGER.items` from `items.py` -3. โŒ **NPCs** - Already in `NPCS` dict from `npcs.py` -4. โŒ **Interactables** - Already in each `Location.interactables` list - -**Why This Matters**: -- Each worker loads `load_world()` on startup โ†’ all static data in memory -- No point duplicating in Redis (wastes memory, adds latency) -- Redis should only store **dynamic, cross-worker state** - ---- - -## Q2: How do unique items work? - -**Database Structure**: - -```python -# unique_items table (single source of truth) -unique_items = Table( - "unique_items", - Column("id", Integer, primary_key=True), - Column("item_id", String), # Template reference (e.g., "iron_sword") - Column("durability", Integer), - Column("max_durability", Integer), - Column("tier", Integer, default=1), - Column("unique_stats", JSON), # Custom stats - Column("created_at", Float) -) - -# inventory table (references unique_items) -inventory = Table( - "inventory", - Column("id", Integer, primary_key=True), - Column("character_id", Integer), - Column("item_id", String), # Template ID - Column("quantity", Integer), # Always 1 for unique items - Column("unique_item_id", Integer, ForeignKey("unique_items.id")), # Link - Column("is_equipped", Boolean) -) -``` - -**Flow**: -1. **Creation**: NPC drops weapon โ†’ `create_unique_item()` โ†’ insert into `unique_items` -2. **Pickup**: Player picks up โ†’ insert into `inventory` with `unique_item_id` reference -3. **Equip**: Player equips โ†’ queries join `inventory โ‹ˆ unique_items` to get stats -4. **Drop**: Player drops โ†’ move to `dropped_items` (keeping `unique_item_id` link) -5. **Deletion**: Item despawns โ†’ CASCADE delete removes from `inventory`/`dropped_items` - -**Redis Caching Strategy**: -```python -# Cache unique item data when equipped/viewed -key = f"unique_item:{unique_item_id}" -value = { - "item_id": "iron_sword", - "durability": 85, - "max_durability": 100, - "tier": 2, - "unique_stats": {"damage_bonus": 5} -} -# TTL: 5 minutes (invalidate on durability change) -``` - ---- - -## Q3: How do enemies work with custom stats? - -**Combat Initialization**: - -When combat starts, NPC gets **randomized HP**: - -```python -# NPCDefinition in npcs.py -@dataclass -class NPCDefinition: - hp_min: int # e.g., 80 - hp_max: int # e.g., 120 - damage_min: int - damage_max: int - defense: int - # ... other stats - -# When combat starts (in game_logic.py or main.py) -import random -npc_def = NPCS.get("raider") # Load from memory -npc_hp = random.randint(npc_def.hp_min, npc_def.hp_max) # Random HP - -# Store in database -await db.create_combat( - player_id=player_id, - npc_id="raider", - npc_hp=npc_hp, # Randomized - npc_max_hp=npc_hp, - location_id=location_id -) -``` - -**Redis Caching for Active Combat**: - -```python -# Cache active combat state (avoid repeated DB queries) -key = f"player:{character_id}:combat" -value = { - "npc_id": "raider", - "npc_hp": 95, - "npc_max_hp": 115, - "turn": "player", - "npc_damage_min": 8, - "npc_damage_max": 15, - "npc_defense": 3 -} -# TTL: No expiration (deleted when combat ends) -``` - -**Combat Flow**: -1. Player attacks โ†’ Check Redis cache for combat state -2. If miss โ†’ Query DB โ†’ Cache in Redis -3. Calculate damage, update NPC HP -4. Update Redis cache + Publish `combat_update` to player channel -5. NPC turn โ†’ Repeat -6. Combat ends โ†’ Delete Redis cache + Publish `combat_over` - ---- - -## Q4: How is everything loaded on server startup? - -**Current Flow** (per worker): - -```python -# api/main.py - Lifespan startup -@asynccontextmanager -async def lifespan(app: FastAPI): - # 1. Database - await db.init_db() # Connect to PostgreSQL - - # 2. Load static data into memory (THIS PART) - WORLD: World = load_world() # Load locations from gamedata/locations.json - LOCATIONS: Dict[str, Location] = WORLD.locations - ITEMS_MANAGER = ItemsManager() # Load items from gamedata/items.json - # NPCs loaded in data/npcs.py module (imported on demand) - - # 3. Start background tasks (single worker via file lock) - tasks = await background_tasks.start_background_tasks(manager, LOCATIONS) - - yield -``` - -**With Redis Integration**: - -```python -@asynccontextmanager -async def lifespan(app: FastAPI): - # 1. Database - await db.init_db() - - # 2. Redis connection - await redis_manager.connect() - - # 3. Load static data (STAYS IN MEMORY - NO REDIS CACHING) - WORLD: World = load_world() - LOCATIONS: Dict[str, Location] = WORLD.locations - ITEMS_MANAGER = ItemsManager() - - # 4. Subscribe to Redis Pub/Sub channels - location_channels = [f"location:{loc_id}" for loc_id in LOCATIONS.keys()] - await redis_manager.subscribe_to_channels(location_channels + ['game:broadcast']) - - # 5. Start Redis message listener (background task) - asyncio.create_task(redis_manager.listen_for_messages(manager.handle_redis_message)) - - # 6. Register this worker in Redis - await redis_manager.redis_client.sadd('active_workers', redis_manager.worker_id) - - # 7. Start background tasks (distributed via Redis locks) - tasks = await background_tasks.start_background_tasks(manager, LOCATIONS) - - yield - - # Cleanup - await redis_manager.redis_client.srem('active_workers', redis_manager.worker_id) - await redis_manager.disconnect() -``` - ---- - -## Q5: How many channels can exist? - -**Redis Pub/Sub Channels**: - -### Fixed Channels (Always Active): -1. `game:broadcast` - Global announcements (1 channel) -2. `game:workers` - Worker coordination (1 channel) - -### Dynamic Channels (Created on Demand): - -**Location Channels** (14 currently): -- `location:start_point` -- `location:overpass` -- `location:gas_station` -- ... (one per location in `locations.json`) - -**Player Channels** (one per connected player): -- `player:1` (character_id=1) -- `player:2` -- `player:5` -- ... (created on WebSocket connect, destroyed on disconnect) - -**Total Active Channels**: -- **Minimum**: 16 (2 fixed + 14 locations) -- **With 100 players**: 116 (2 + 14 + 100) -- **With 1000 players**: 1016 (2 + 14 + 1000) - -**Redis Limits**: -- Redis supports **millions** of channels simultaneously -- Each channel has minimal memory overhead (~100 bytes) -- 1000 channels = ~100 KB memory (negligible) - -**Subscription Strategy**: -- All workers subscribe to: `game:broadcast` + all location channels -- Each worker subscribes to: only its connected players' channels -- When player connects โ†’ Worker subscribes to `player:{id}` -- When player disconnects โ†’ Worker unsubscribes from `player:{id}` - ---- - -## Q6: How does client update data in the UI? - -**Current Flow** (without Redis): - -``` -1. User clicks "Attack" button - โ†“ -2. Client: POST /api/game/combat/action {"action": "attack"} - โ†“ -3. Server: Process attack, update DB - โ†“ -4. Server: Send WebSocket message to player - โ†“ -5. Server: Query DB for other players in location - โ†“ -6. Server: Send WebSocket messages to location - โ†“ -7. Client: Receives WebSocket "combat_update" - โ†“ -8. Client: Updates UI (HP bar, combat log) - โ†“ -9. Client: GET /api/game/state (refresh full state) - โ†“ -10. Server: Query DB for player, inventory, combat, etc. - โ†“ -11. Client: Re-render entire game UI -``` - -**With Redis** (optimized): - -``` -1. User clicks "Attack" button - โ†“ -2. Client: POST /api/game/combat/action {"action": "attack"} - โ†“ -3. Server: Process attack, update DB + Redis cache - โ†“ -4. Server: Publish to Redis channel "player:{id}" (personal message) - โ†“ -5. Worker handling that player: Receives Redis message - โ†“ -6. Worker: Send WebSocket to local connection - โ†“ -7. Client: Receives WebSocket "combat_update" with ALL needed data - โ†“ -8. Client: Updates UI directly from WebSocket payload (NO API CALL) - โ†“ -9. Server: Publish to Redis channel "location:{id}" (broadcast) - โ†“ -10. All workers: Receive location broadcast - โ†“ -11. Workers: Send WebSocket to their local connections in that location - โ†“ -12. Other players: UI updates with "Jocaru is in combat" -``` - -**Key Changes**: -- โœ… **No more `GET /api/game/state` after actions** - WebSocket payload contains everything -- โœ… **Cross-worker broadcasts** - Redis pub/sub ensures all workers relay messages -- โœ… **Reduced DB queries** - Combat state cached in Redis -- โœ… **Faster UI updates** - WebSocket messages < 2ms via Redis - -**WebSocket Message Format** (enhanced): - -```json -{ - "type": "combat_update", - "data": { - "message": "You dealt 12 damage!", - "log_entry": "You dealt 12 damage!", - "combat_over": false, - "combat": { - "npc_id": "raider", - "npc_hp": 85, - "npc_max_hp": 115, - "turn": "npc" - }, - "player": { - "hp": 78, - "stamina": 42, - "xp": 1250, - "level": 5 - } - }, - "timestamp": "2025-11-09T18:00:00Z" -} -``` - -Client receives this โ†’ Updates HP bar, combat log, turn indicator **WITHOUT** calling `/api/game/state`. - ---- - -## Q7: Disconnected players staying in location? - -**Excellent Gameplay Mechanic!** This adds risk/consequence to disconnecting in dangerous areas. - -### Implementation: - -**When Player Disconnects**: - -```python -# ConnectionManager.disconnect() -async def disconnect(self, player_id: int): - # 1. Remove local WebSocket connection - if player_id in self.active_connections: - del self.active_connections[player_id] - - # 2. Update Redis session (mark as disconnected) - session = await redis_manager.get_player_session(player_id) - if session: - session['websocket_connected'] = 'false' - session['disconnect_time'] = str(time.time()) - await redis_manager.set_player_session(player_id, session, ttl=3600) # Keep for 1 hour - - # 3. KEEP player in location registry (don't remove) - # await redis_manager.remove_player_from_location(...) # DON'T DO THIS - - # 4. Broadcast to location - await redis_manager.publish_to_location( - session['location_id'], - { - "type": "player_status_change", - "data": { - "player_id": player_id, - "username": session['username'], - "status": "disconnected", - "message": f"{session['username']} has disconnected (vulnerable)" - } - } - ) -``` - -**When Other Players Query Location**: - -```python -# GET /api/game/location endpoint -@app.get("/api/game/location") -async def get_current_location(current_user: dict = Depends(get_current_user)): - # Get players in location from Redis - player_ids = await redis_manager.get_players_in_location(location_id) - - other_players = [] - for pid in player_ids: - if pid == current_user['id']: - continue - - # Get player session - session = await redis_manager.get_player_session(pid) - if session: - other_players.append({ - "id": pid, - "username": session['username'], - "level": int(session['level']), - "hp": int(session['hp']), - "is_connected": session['websocket_connected'] == 'true', - "can_attack": True # Always true, even if disconnected! - }) - - return { - "id": location_id, - "other_players": other_players # Includes disconnected players - } -``` - -**Combat with Disconnected Player**: - -```python -# POST /api/game/pvp/initiate -@app.post("/api/game/pvp/initiate") -async def initiate_pvp(target_id: int, current_user: dict = Depends(get_current_user)): - # Check target session - target_session = await redis_manager.get_player_session(target_id) - - if not target_session: - raise HTTPException(400, detail="Target player not found") - - # Allow combat even if disconnected - is_connected = target_session['websocket_connected'] == 'true' - - # Create PvP combat - pvp_combat = await db.create_pvp_combat( - attacker_id=current_user['id'], - defender_id=target_id, - location_id=current_user['location_id'] - ) - - if is_connected: - # Target is online โ†’ Send WebSocket notification - await redis_manager.publish_to_player(target_id, { - "type": "pvp_challenge", - "data": { - "attacker": current_user['name'], - "attacker_level": current_user['level'] - } - }) - else: - # Target is offline โ†’ Auto-acknowledge, they can't respond - await db.acknowledge_pvp_combat(pvp_combat['id'], target_id) - - # Attacker gets free first strike advantage - return { - "message": f"{target_session['username']} is disconnected - you get first strike!", - "pvp_combat": pvp_combat, - "target_vulnerable": True - } -``` - -**Cleanup Policy** (optional): - -```python -# Background task: Remove disconnected players after 1 hour -async def cleanup_disconnected_players(): - while True: - await asyncio.sleep(300) # Every 5 minutes - - # Get all player sessions - keys = await redis_manager.redis_client.keys("player:*:session") - - for key in keys: - session = await redis_manager.redis_client.hgetall(key) - - if session['websocket_connected'] == 'false': - disconnect_time = float(session['disconnect_time']) - - # If disconnected for > 1 hour - if time.time() - disconnect_time > 3600: - character_id = int(key.split(':')[1]) - location_id = session['location_id'] - - # Remove from location registry - await redis_manager.remove_player_from_location(character_id, location_id) - - # Delete session - await redis_manager.delete_player_session(character_id) - - print(f"๐Ÿงน Cleaned up disconnected player {character_id}") -``` - -**UI Display**: - -```tsx -// Frontend: Show disconnected status -{otherPlayers.map(player => ( -
- {player.username} - Lv. {player.level} - {!player.is_connected && ( - โš ๏ธ Disconnected (Vulnerable) - )} - {player.can_attack && ( - - )} -
-))} -``` - ---- - -## Q8: RDB vs AOF - Code changes needed? - -**Short Answer**: No code changes required, only Redis configuration. - -### Redis Persistence Options: - -**RDB (Snapshotting)**: -- Periodic snapshots to disk -- Fast restarts, smaller files -- May lose last few seconds of data - -**AOF (Append-Only File)**: -- Logs every write operation -- More durable, no data loss -- Slower restarts, larger files - -**Recommended Configuration** (for your use case): - -```bash -# docker-compose.yml -echoes_redis: - command: | - redis-server - --appendonly yes # Enable AOF - --appendfsync everysec # Sync every second (good balance) - --save 900 1 # RDB backup every 15 min if 1+ key changed - --save 300 10 # RDB backup every 5 min if 10+ keys changed - --save 60 10000 # RDB backup every 1 min if 10k+ keys changed - --maxmemory 512mb # Max memory usage - --maxmemory-policy allkeys-lru # Evict least recently used keys -``` - -**What This Gives You**: -- โœ… **AOF for durability**: Every write logged (max 1 second data loss) -- โœ… **RDB for fast recovery**: Snapshots for quick restarts -- โœ… **Memory protection**: Won't crash if memory full (evicts old caches) - -**Application Code**: No changes needed! Redis handles persistence transparently. - -**Testing Persistence**: - -```bash -# 1. Add some data -docker exec echoes_of_the_ashes_redis redis-cli SET test:key "hello" - -# 2. Restart Redis -docker restart echoes_of_the_ashes_redis - -# 3. Check if data persisted -docker exec echoes_of_the_ashes_redis redis-cli GET test:key -# Should return: "hello" -``` - ---- - -## Q9: What if cache invalidation isn't aggressive enough? - -**Potential Problems**: - -### 1. Stale Player Stats -**Scenario**: Player levels up, but Redis cache shows old level -``` -1. Player gains XP โ†’ DB updated (level 6) -2. Redis cache still shows level 5 -3. Other players see "Lv. 5" instead of "Lv. 6" -``` - -**Solution**: Invalidate on every stat change -```python -async def update_character_stats(character_id: int, **kwargs): - # Update DB - await db.update_character(character_id, **kwargs) - - # Invalidate Redis cache - await redis_manager.delete_player_session(character_id) - - # Or update cache directly - session = await redis_manager.get_player_session(character_id) - if session: - session.update(kwargs) - await redis_manager.set_player_session(character_id, session) -``` - -### 2. Ghost Items in Inventory -**Scenario**: Player drops item, but cache shows they still have it -``` -1. Player drops "Iron Sword" -2. DB updated (inventory row deleted) -3. Redis cache still shows sword in inventory -4. Player sees sword in UI, tries to equip โ†’ Error! -``` - -**Solution**: Invalidate inventory cache on add/remove/use -```python -async def remove_item_from_inventory(character_id: int, item_id: str): - # Update DB - await db.delete_inventory_item(character_id, item_id) - - # Invalidate cache (force reload next time) - await redis_manager.invalidate_inventory(character_id) -``` - -### 3. Wrong Player Count in Location -**Scenario**: Player moves, but old location still shows them -``` -1. Player moves overpass โ†’ gas_station -2. Redis location registry not updated -3. Other players in overpass still see them -4. Broadcasts sent to wrong location -``` - -**Solution**: Atomic location updates -```python -async def move_player(character_id: int, from_loc: str, to_loc: str): - # Use Redis transaction (atomic) - async with redis_manager.redis_client.pipeline() as pipe: - pipe.srem(f"location:{from_loc}:players", character_id) - pipe.sadd(f"location:{to_loc}:players", character_id) - await pipe.execute() -``` - -### 4. Combat State Desync -**Scenario**: Combat ends, but cache shows still in combat -``` -1. Player defeats enemy -2. DB: active_combats row deleted -3. Redis: combat cache still exists -4. Player sees combat UI, can't move -``` - -**Solution**: Explicit cache deletion on combat end -```python -async def end_combat(character_id: int): - # Delete from DB - await db.end_combat(character_id) - - # Delete Redis cache - await redis_manager.redis_client.delete(f"player:{character_id}:combat") - - # Update player session - session = await redis_manager.get_player_session(character_id) - if session: - session['in_combat'] = 'false' - await redis_manager.set_player_session(character_id, session) -``` - -**General Strategy**: - -```python -# PATTERN 1: Write-Through Cache (recommended for critical data) -async def update_data(key, value): - await db.update(key, value) # Write to DB first - await redis_manager.cache(key, value) # Update cache immediately - -# PATTERN 2: Cache Invalidation (simpler, slight delay) -async def update_data(key, value): - await db.update(key, value) # Write to DB - await redis_manager.delete_cache(key) # Delete cache (reload on next access) - -# PATTERN 3: TTL Fallback (for non-critical data) -# Set short TTLs (e.g., 30 seconds) so cache self-expires if not invalidated -await redis_manager.cache(key, value, ttl=30) -``` - -**For Your Game**: -- โœ… **Aggressive invalidation** for: inventory, combat state, player stats -- โœ… **Write-through cache** for: player sessions, location registry -- โœ… **TTL fallback** for: dropped items list, interactable cooldowns - ---- - -## Q10: No feature flags needed (dev only) - -**Agreed!** Since you're the only tester, we can implement directly without feature flags. - -### Simplified Rollout: - -**Phase 1: Redis Infrastructure (Week 1)** -- Add Redis to docker-compose -- Create redis_manager.py -- Test connection/pub-sub - -**Phase 2: Pub/Sub Only (Week 2)** -- Update ConnectionManager to use Redis pub/sub -- Keep all other logic same (no caching yet) -- Test cross-worker broadcasts - -**Phase 3: Add Caching (Week 3)** -- Add player session cache -- Add inventory cache -- Add combat state cache -- Test performance improvements - -**Phase 4: Multi-Worker (Week 4)** -- Increase workers to 2 -- Test load balancing -- Monitor for race conditions - -**Simplified Implementation** (no toggles): - -```python -# Just implement Redis directly -async def lifespan(app: FastAPI): - await db.init_db() - await redis_manager.connect() # No if/else, just do it - # ... rest of startup -``` - ---- - -## Updated Implementation Priority - -Based on your feedback, here's what we'll actually implement: - -### Phase 1: Redis Pub/Sub (Core Multi-Worker Support) -**Goal**: Enable cross-worker broadcasts - -**Changes**: -1. Add Redis container -2. Create `redis_manager.py` with pub/sub only -3. Update ConnectionManager: - - Keep local WebSocket storage - - Change `send_personal_message()` โ†’ publish to Redis - - Change `send_to_location()` โ†’ publish to Redis - - Add `handle_redis_message()` โ†’ send to local WebSockets -4. Subscribe to location channels on startup - -**What We DON'T Cache**: -- โŒ Locations (already in memory) -- โŒ Items (already in memory) -- โŒ NPCs (already in memory) - -### Phase 2: Dynamic State Caching (Performance) -**Goal**: Reduce database queries for frequently accessed data - -**What We DO Cache**: -1. โœ… Player sessions (location, HP, level, stats) -2. โœ… Location player registry (Set of character IDs per location) -3. โœ… Player inventory (with aggressive invalidation) -4. โœ… Active combat state (with explicit deletion) -5. โœ… Dropped items per location (with TTL) - -### Phase 3: Multi-Worker Deployment -**Goal**: Horizontal scaling - -**Changes**: -1. Update docker-compose for 4 workers -2. Test load distribution -3. Implement distributed background task locks -4. Monitor performance - ---- - -## Next Steps - -Ready to implement? Here's what I'll do: - -1. **Create `redis_manager.py`** - Simplified version (no static data caching) -2. **Update `docker-compose.yml`** - Add Redis container -3. **Update `ConnectionManager`** - Integrate pub/sub -4. **Update endpoints** - Add cache invalidation where needed -5. **Implement disconnected player** - Keep in location, mark as vulnerable -6. **Test suite** - Verify cross-worker communication - -Do you want me to proceed with implementation? diff --git a/old/STEAM_AND_PREMIUM_PLAN.md b/old/STEAM_AND_PREMIUM_PLAN.md deleted file mode 100644 index 4cd7938..0000000 --- a/old/STEAM_AND_PREMIUM_PLAN.md +++ /dev/null @@ -1,564 +0,0 @@ -# Steam Integration & Premium System Plan - -## Overview -Transform Echoes of the Ashes into a premium game with multiple distribution channels: -- **Web Version**: Free trial with level cap, upgrade to premium -- **Steam Version**: Full game, integrated authentication -- **Standalone Executable**: Bundled client for Windows/Mac/Linux - ---- - -## 1. Account System - -### Account Types -```sql -account_type ENUM('web', 'steam') -``` - -- **web**: Email/password registration, optional premium upgrade -- **steam**: Auto-premium via Steam ownership verification - -### Premium System -```sql -premium_expires_at TIMESTAMP NULL -``` - -- **NULL** = Lifetime premium (Steam users, purchased premium) -- **Timestamp** = Free trial expires at this time -- **Non-premium restrictions**: - - Level cap at 10 - - No XP gain after level 10 - - Full map access (level-gated naturally by difficulty) - - Can play with premium users - -### Database Schema -```sql -CREATE TABLE players ( - id SERIAL PRIMARY KEY, - username VARCHAR(255) UNIQUE NOT NULL, - email VARCHAR(255), -- Required for web, NULL for steam - password_hash VARCHAR(255), -- Required for web, NULL for steam - steam_id VARCHAR(255) UNIQUE, -- Steam ID for steam users - account_type VARCHAR(20) DEFAULT 'web', - premium_expires_at TIMESTAMP NULL, -- NULL = premium forever - -- ... existing fields ... -); -``` - ---- - -## 2. Distribution Channels - -### A. Web Version (Current) - -**Registration Flow:** -``` -POST /api/auth/register -{ - "username": "player123", - "email": "player@example.com", // NEW: Required - "password": "securepass" -} -``` - -**Premium Upgrade:** -``` -POST /api/payment/upgrade-premium -{ - "payment_method": "stripe|paypal|crypto", - "payment_token": "..." -} -``` - -### B. Steam Version - -**Architecture:** -``` -[Steam Client] <-> [Steamworks API] <-> [Game Server] - | - [Steam Auth] -``` - -**Steam Authentication Flow:** -1. User launches game via Steam -2. Game requests Steam session ticket via Steamworks SDK -3. Client sends ticket to game server -4. Server validates ticket with Steam API -5. Server creates/logs in user with `steam_id` -6. Premium status = automatic (owns game on Steam) - -**Endpoints:** -``` -POST /api/auth/steam/login -{ - "steam_ticket": "...", - "steam_id": "76561198..." -} - -Response: -{ - "access_token": "...", - "player": {...}, - "is_premium": true, // Always true for Steam - "account_type": "steam" -} -``` - -### C. Standalone Executable - -**Two Approaches:** - -#### Option 1: Electron/Tauri App (Recommended) -**Pros:** -- Web technologies (reuse existing PWA) -- Easy to bundle assets -- Cross-platform (Windows, Mac, Linux) -- Auto-updates -- Local caching - -**Cons:** -- Larger download size (~100-200MB with bundled assets) - -**Tech Stack:** -- **Tauri** (Rust + Web) - smaller, more secure -- **Electron** (Node + Web) - more mature, larger - -**Architecture:** -``` -[Desktop App] - โ”œโ”€โ”€ Bundled Assets/ - โ”‚ โ”œโ”€โ”€ images/ (all icons, locations, NPCs) - โ”‚ โ”œโ”€โ”€ data/ (items.json, npcs.json, etc.) - โ”‚ โ””โ”€โ”€ sounds/ (future) - โ”œโ”€โ”€ Local Cache/ - โ”‚ โ””โ”€โ”€ user_data/ - โ””โ”€โ”€ API Client -> Game Server -``` - -#### Option 2: Native Build (Godot/Unity) -**Pros:** -- True native performance -- Better for future 3D/advanced graphics -- Full offline capability - -**Cons:** -- Complete rewrite -- Different tech stack from web -- More maintenance - -**Recommendation:** Start with **Tauri** for MVP - ---- - -## 3. Asset Bundling Strategy - -### Bundled Assets (Standalone) -``` -app/assets/ -โ”œโ”€โ”€ icons/ -โ”‚ โ”œโ”€โ”€ items/ -โ”‚ โ”œโ”€โ”€ ui/ -โ”‚ โ”œโ”€โ”€ status/ -โ”‚ โ””โ”€โ”€ actions/ -โ”œโ”€โ”€ images/ -โ”‚ โ”œโ”€โ”€ locations/ -โ”‚ โ”œโ”€โ”€ npcs/ -โ”‚ โ””โ”€โ”€ interactables/ -โ”œโ”€โ”€ data/ -โ”‚ โ”œโ”€โ”€ items.json -โ”‚ โ”œโ”€โ”€ npcs.json -โ”‚ โ”œโ”€โ”€ locations.json -โ”‚ โ””โ”€โ”€ interactables.json -โ””โ”€โ”€ sounds/ (future) -``` - -### Caching Strategy - -**On First Launch:** -1. Check local cache version -2. If outdated, download latest assets -3. Store in local cache with version tag -4. Future launches use cache - -**Update Mechanism:** -```json -{ - "version": "1.2.0", - "assets": { - "icons": "v1.2.0", - "images": "v1.1.5", - "data": "v1.2.0" - }, - "cdn_url": "https://cdn.echoesoftheash.com" -} -``` - -**Hybrid Approach (Best):** -1. **Bundle core assets** (~50MB): - - Essential UI icons - - Starting location images - - Common item icons - -2. **Lazy load on demand**: - - High-level location images - - Rare item icons - - NPC portraits - -3. **Cache everything locally** -4. **Check for updates daily** - ---- - -## 4. Steam Integration Technical Details - -### Steamworks SDK Integration - -**1. Setup:** -```bash -# Download Steamworks SDK -# https://partner.steamgames.com/ - -# Install in project -steamworks-sdk/ -โ”œโ”€โ”€ sdk/ -โ”‚ โ”œโ”€โ”€ public/ -โ”‚ โ”‚ โ””โ”€โ”€ steam/ -โ”‚ โ”‚ โ””โ”€โ”€ steam_api.h -โ”‚ โ””โ”€โ”€ redistributable_bin/ -โ”‚ โ”œโ”€โ”€ steam_api.dll (Windows) -โ”‚ โ”œโ”€โ”€ libsteam_api.so (Linux) -โ”‚ โ””โ”€โ”€ libsteam_api.dylib (Mac) -โ””โ”€โ”€ tools/ -``` - -**2. Client-Side (Game Launch):** -```cpp -// Initialize Steam API -if (!SteamAPI_Init()) { - return ERROR_STEAM_NOT_RUNNING; -} - -// Get Steam ID -CSteamID steamID = SteamUser()->GetSteamID(); -uint64 steamID64 = steamID.ConvertToUint64(); - -// Request auth ticket -HAuthTicket hAuthTicket; -uint8 rgubTicket[1024]; -uint32 pcbTicket; - -hAuthTicket = SteamUser()->GetAuthSessionTicket( - rgubTicket, - sizeof(rgubTicket), - &pcbTicket -); - -// Convert to hex string and send to server -std::string ticket = BytesToHex(rgubTicket, pcbTicket); -``` - -**3. Server-Side Validation:** -```python -import requests - -async def validate_steam_ticket(steam_id: str, ticket: str) -> bool: - """Validate Steam auth ticket with Steam API""" - url = "https://api.steampowered.com/ISteamUserAuth/AuthenticateUserTicket/v1/" - params = { - "key": STEAM_WEB_API_KEY, # Get from Steamworks Partner - "appid": YOUR_STEAM_APP_ID, - "ticket": ticket - } - - response = requests.get(url, params=params) - data = response.json() - - if data['response']['params']['result'] == 'OK': - validated_steam_id = data['response']['params']['steamid'] - return validated_steam_id == steam_id - - return False -``` - -### Steam Build Configuration - -**1. Depot Configuration** (`depot_build_1001.vdf`): -```vdf -"DepotBuildConfig" -{ - "DepotID" "1001" - "ContentRoot" "..\build\steam\" - "FileMapping" - { - "LocalPath" "*" - "DepotPath" "." - "recursive" "1" - } - "FileExclusion" "*.pdb" -} -``` - -**2. App Build Script** (`app_build_1000.vdf`): -```vdf -"AppBuild" -{ - "AppID" "1000" // Your Steam App ID - "Desc" "Echoes of the Ashes Build" - "BuildOutput" "..\output\" - "ContentRoot" "..\build\" - "SetLive" "default" - "Depots" - { - "1001" "depot_build_1001.vdf" - } -} -``` - -**3. Upload Script:** -```bash -#!/bin/bash -# build_and_upload_steam.sh - -# Build the game -npm run build:steam - -# Upload to Steam -steamcmd +login $STEAM_USERNAME +run_app_build app_build_1000.vdf +quit -``` - ---- - -## 5. Build Variants - -### Configuration System - -**config/builds.json:** -```json -{ - "web": { - "api_url": "https://api.echoesoftheash.com", - "bundled_assets": false, - "steam_enabled": false, - "premium_required": false - }, - "steam": { - "api_url": "https://api.echoesoftheash.com", - "bundled_assets": true, - "steam_enabled": true, - "premium_required": false, // Validated by ownership - "app_id": "1000000" - }, - "standalone": { - "api_url": "https://api.echoesoftheash.com", - "bundled_assets": true, - "steam_enabled": false, - "premium_required": false, // Check via API - "auto_update": true - } -} -``` - -### Build Commands - -```json -{ - "scripts": { - "build:web": "BUILD_TARGET=web vite build", - "build:steam": "BUILD_TARGET=steam tauri build --target steam", - "build:standalone": "BUILD_TARGET=standalone tauri build" - } -} -``` - ---- - -## 6. Premium Enforcement - -### XP Gain Restriction - -```python -async def award_xp(player_id: int, xp_amount: int): - player = await db.get_player_by_id(player_id) - - # Check premium status - is_premium = await is_player_premium(player) - - if not is_premium and player['level'] >= 10: - return { - "success": False, - "message": "Free trial limited to level 10. Upgrade to premium to continue!", - "xp_gained": 0, - "upgrade_url": "/premium/upgrade" - } - - # Award XP normally - new_xp = player['xp'] + xp_amount - await db.update_player(player_id, xp=new_xp) - # ... level up logic ... -``` - -### Helper Functions - -```python -async def is_player_premium(player: dict) -> bool: - """Check if player has premium access""" - # Steam users are always premium - if player['account_type'] == 'steam': - return True - - # NULL = lifetime premium - if player['premium_expires_at'] is None: - # Check if they ever had premium (distinguish from never-premium) - # Could add a 'premium_granted_at' field - return False # Or check another field - - # Check if premium expired - from datetime import datetime - if player['premium_expires_at'] > datetime.utcnow(): - return True - - return False - -async def grant_premium(player_id: int, duration_days: int = None): - """Grant premium access to a player""" - if duration_days is None: - # Lifetime premium - await db.update_player(player_id, premium_expires_at=None) - else: - # Time-limited premium - from datetime import datetime, timedelta - expires_at = datetime.utcnow() + timedelta(days=duration_days) - await db.update_player(player_id, premium_expires_at=expires_at) -``` - ---- - -## 7. Implementation Roadmap - -### Phase 1: Foundation (Current Sprint) -- [x] Add database columns (steam_id, email, premium_expires_at, account_type) -- [ ] Update registration to require email -- [ ] Add premium check helper functions -- [ ] Implement XP gain restriction for non-premium level 10+ -- [ ] Create icon folder structure - -### Phase 2: Premium System (Next Sprint) -- [ ] Payment integration (Stripe/PayPal) -- [ ] Premium upgrade endpoint -- [ ] Premium status UI indicators -- [ ] Email verification system -- [ ] Password reset flow - -### Phase 3: Steam Integration -- [ ] Set up Steamworks partner account -- [ ] Integrate Steamworks SDK -- [ ] Implement Steam authentication -- [ ] Create Steam build pipeline -- [ ] Test Steam authentication flow - -### Phase 4: Standalone Client -- [ ] Choose framework (Tauri recommended) -- [ ] Set up project structure -- [ ] Implement asset bundling -- [ ] Add auto-update mechanism -- [ ] Create installers (Windows, Mac, Linux) - -### Phase 5: Polish & Launch -- [ ] Replace emojis with custom icons -- [ ] Optimize asset loading -- [ ] Add caching layer -- [ ] Performance testing -- [ ] Beta testing -- [ ] Official launch - ---- - -## 8. Recommended Tech Stack - -### For Standalone Executable: **Tauri** - -**Why Tauri:** -- Smaller bundle size (~3-5MB vs 100MB+ for Electron) -- Uses system WebView (Chromium on Windows, Safari on Mac) -- Rust backend = better security -- Built-in auto-updater -- Native system integration -- Active development - -**Project Structure:** -``` -echoes-desktop/ -โ”œโ”€โ”€ src-tauri/ # Rust backend -โ”‚ โ”œโ”€โ”€ src/ -โ”‚ โ”‚ โ”œโ”€โ”€ main.rs # Entry point -โ”‚ โ”‚ โ”œโ”€โ”€ steam.rs # Steam integration -โ”‚ โ”‚ โ””โ”€โ”€ storage.rs # Local storage -โ”‚ โ”œโ”€โ”€ icons/ -โ”‚ โ””โ”€โ”€ tauri.conf.json -โ”œโ”€โ”€ src/ # Frontend (reuse existing PWA) -โ”‚ โ”œโ”€โ”€ components/ -โ”‚ โ”œโ”€โ”€ hooks/ -โ”‚ โ””โ”€โ”€ main.tsx -โ”œโ”€โ”€ assets/ # Bundled assets -โ”‚ โ”œโ”€โ”€ icons/ -โ”‚ โ”œโ”€โ”€ images/ -โ”‚ โ””โ”€โ”€ data/ -โ””โ”€โ”€ package.json -``` - -**Installation:** -```bash -npm create tauri-app -# Choose: React + TypeScript -``` - ---- - -## 9. Cost Estimates - -### Development -- Steam Steamworks SDK: **Free** (requires $100 one-time app submission fee) -- Payment Processing: **2.9% + $0.30 per transaction** (Stripe) -- CDN for assets: **~$5-20/month** (CloudFlare, AWS CloudFront) - -### Infrastructure -- Current server: **Existing** -- Steam bandwidth: **Free** (Valve covers) -- Asset CDN: **$10-50/month** (depending on traffic) - ---- - -## 10. Monetization Strategy - -### Pricing Options - -**Option A: One-time Purchase** -- **$9.99** - Full game unlock -- No subscription, lifetime access -- Recommended for indie games - -**Option B: Freemium with Optional Premium** -- **Free**: Level 1-10, unlimited play -- **$4.99**: Full unlock -- Easier onboarding - -**Option C: Steam-Only Paid** -- **$14.99** on Steam (full game) -- Free web version with level cap -- Drive Steam sales - -**Recommendation:** **Option C** - Premium on Steam, free trial on web - ---- - -## Next Immediate Steps - -1. **Update registration endpoint** (add email requirement) -2. **Add premium helper functions** to codebase -3. **Implement XP restriction** for level 10+ free users -4. **Create payment integration** (Stripe) -5. **Design icon system** and start replacing emojis -6. **Set up Steamworks partner account** (long lead time) -7. **Prototype Tauri desktop app** (weekend project) - -Would you like me to start implementing any specific part of this plan? diff --git a/old/TESTING_GUIDE.md b/old/TESTING_GUIDE.md deleted file mode 100644 index 7d5ef0b..0000000 --- a/old/TESTING_GUIDE.md +++ /dev/null @@ -1,276 +0,0 @@ -# Quick Test Guide - Account/Character System - -**Test this NOW to verify everything works!** - ---- - -## ๐Ÿงช Test 1: New User Registration - -**Steps:** -1. Open https://echoesoftheashgame.patacuack.net -2. Should redirect to login page -3. Click "Don't have an account? Register" -4. Enter email: `test@example.com` -5. Enter password: `test123` -6. Click "Register" - -**Expected:** -- โœ… Redirects to character selection screen -- โœ… Shows "You don't have any characters yet" -- โœ… Shows "Create New Character" card -- โœ… Shows "0 / 1 slots used" (free account) - ---- - -## ๐Ÿงช Test 2: Character Creation - -**Steps:** -1. From character selection, click "Create New Character" card -2. Enter name: `TestHero` -3. Allocate stats: - - Strength: 8 - - Agility: 5 - - Endurance: 4 - - Intellect: 3 -4. Check "Points Remaining" shows 0 -5. Check HP preview shows 140 (100 + 4*10) -6. Check Stamina preview shows 120 (100 + 4*5) -7. Click "Create Character" - -**Expected:** -- โœ… Redirects back to character selection -- โœ… Shows new character card with: - - Name: TestHero - - Level 1 - - HP: 140/140 - - Correct stat emojis (๐Ÿ’ช8 โšก5 ๐Ÿ›ก๏ธ4 ๐Ÿง 3) -- โœ… Shows "1 / 1 slots used" -- โœ… "Create New Character" card is GONE (at limit) -- โœ… Shows premium upgrade banner - ---- - -## ๐Ÿงช Test 3: Character Selection & Game Entry - -**Steps:** -1. From character selection, click "Play" on TestHero -2. Wait for redirect - -**Expected:** -- โœ… Redirects to game screen -- โœ… Game header shows "TestHero" in top right -- โœ… Game loads normally -- โœ… Can interact with game - ---- - -## ๐Ÿงช Test 4: Character Limit (Free Account) - -**Steps:** -1. From game, click "TestHero" in header -2. Should redirect to character selection -3. Try to click "Create New Character" - -**Expected:** -- โœ… "Create New Character" card is NOT visible -- โœ… Premium banner shows: "Character Limit Reached" -- โœ… Banner shows: "Upgrade to Premium to create up to 10 characters!" - ---- - -## ๐Ÿงช Test 5: Logout and Re-Login - -**Steps:** -1. From character selection, click "Logout" -2. Should redirect to login screen -3. Enter same email: `test@example.com` -4. Enter password: `test123` -5. Click "Login" - -**Expected:** -- โœ… Redirects to character selection -- โœ… Shows TestHero character card -- โœ… Character data persisted (level, stats, etc.) -- โœ… Can click "Play" and enter game again - ---- - -## ๐Ÿงช Test 6: Character Deletion - -**Steps:** -1. From character selection, click "Delete" on TestHero -2. Confirm deletion in popup -3. Wait for deletion - -**Expected:** -- โœ… Shows confirmation dialog -- โœ… Character card disappears after confirm -- โœ… Shows "You don't have any characters yet" message -- โœ… "Create New Character" card appears again -- โœ… Shows "0 / 1 slots used" - ---- - -## ๐Ÿงช Test 7: Validation Tests - -### Character Name Validation -1. Try creating character with name: `ab` (too short) - - โŒ Should show error: "Name must be between 3 and 20 characters" - -2. Try creating character with name: `a very long name that exceeds twenty characters` - - โŒ Should show error: "Name must be between 3 and 20 characters" - -3. Create character named `Hero1`, then try creating another with same name - - โŒ Should show error about name already exists - -### Stat Allocation Validation -1. Try to allocate only 15 points (leave 5 remaining) - - โŒ "Create Character" button should be DISABLED - - โŒ Points remaining shows "5 / 20" in normal color - -2. Try to allocate 25 points (5 over) - - โŒ Should prevent going over 20 - - โŒ Points remaining shows "-5 / 20" in RED - -3. Allocate exactly 20 points - - โœ… "Create Character" button becomes ENABLED - - โœ… Points remaining shows "0 / 20" in GREEN - ---- - -## ๐Ÿงช Test 8: Email Validation - -**Steps:** -1. Try to register with invalid emails: - - `notanemail` - โŒ Should show error - - `test@` - โŒ Should show error - - `@example.com` - โŒ Should show error - -2. Try to register with valid email: - - `valid@example.com` - โœ… Should succeed - -**Expected:** -- Email validation shows clear error message -- Only valid email formats accepted - ---- - -## ๐Ÿงช Test 9: Mobile Responsiveness - -**Steps:** -1. Open site on mobile browser (or resize browser to 375px width) -2. Navigate through all screens: - - Login - - Character selection - - Character creation - - Game - -**Expected:** -- โœ… Login card fits screen, readable text -- โœ… Character cards stack in single column -- โœ… Character creation form is scrollable -- โœ… All buttons are tappable (not too small) -- โœ… No horizontal scrolling -- โœ… Text is readable without zooming - ---- - -## ๐Ÿงช Test 10: Error Handling - -### Invalid Login -1. Try to login with wrong email: `wrong@example.com` - - โŒ Should show error: "Authentication failed" - -2. Try to login with wrong password - - โŒ Should show error: "Authentication failed" - -### Network Error Simulation -1. Open browser dev tools -2. Set network to "Offline" -3. Try to create character - - โŒ Should show error message (not crash) - -4. Set network back to "Online" -5. Try again - - โœ… Should work normally - ---- - -## ๐Ÿ“Š Success Criteria - -**All tests should pass:** -- [x] Can register new account with email -- [x] Can create character with 20 stat points -- [x] Can select character and enter game -- [x] Free account limited to 1 character -- [x] Premium banner shows when at limit -- [x] Can logout and login again -- [x] Can delete characters -- [x] Validation works correctly -- [x] Email format validated -- [x] Mobile responsive -- [x] Errors handled gracefully - ---- - -## ๐Ÿšจ If Something Doesn't Work - -### Frontend Not Loading -```bash -# Check PWA container logs -docker logs echoes_of_the_ashes_pwa --tail 50 - -# Rebuild if needed -cd /opt/dockers/echoes_of_the_ashes -docker compose build echoes_of_the_ashes_pwa -docker compose up -d echoes_of_the_ashes_pwa -``` - -### API Errors -```bash -# Check API logs -docker logs echoes_of_the_ashes_api --tail 50 - -# Restart if needed -docker compose restart echoes_of_the_ashes_api -``` - -### Database Issues -```bash -# Check if migration ran -docker exec echoes_of_the_ashes_db psql -U eota_user -d echoes_of_the_ashes -c "\dt" - -# Should show: accounts, characters tables -``` - -### Clear Browser Cache -If frontend looks old: -1. Open browser dev tools (F12) -2. Right-click refresh button -3. Select "Empty Cache and Hard Reload" - ---- - -## ๐Ÿ“ž Quick Commands - -```bash -# Check all containers -docker ps - -# View all logs -docker compose logs -f - -# Restart everything -docker compose restart - -# Rebuild frontend -docker compose build echoes_of_the_ashes_pwa && docker compose up -d echoes_of_the_ashes_pwa - -# Check database -docker exec echoes_of_the_ashes_db psql -U eota_user -d echoes_of_the_ashes -c "SELECT COUNT(*) FROM accounts;" -docker exec echoes_of_the_ashes_db psql -U eota_user -d echoes_of_the_ashes -c "SELECT COUNT(*) FROM characters;" -``` - ---- - -**Happy Testing!** ๐ŸŽฎ diff --git a/old/WEBSOCKET_DEPLOYMENT.md b/old/WEBSOCKET_DEPLOYMENT.md deleted file mode 100644 index 0770015..0000000 --- a/old/WEBSOCKET_DEPLOYMENT.md +++ /dev/null @@ -1,124 +0,0 @@ -# WebSocket Deployment Guide - -## Quick Deployment Steps - -### 1. Install New Dependencies -```bash -cd /opt/dockers/echoes_of_the_ashes -docker compose down -docker compose build -docker compose up -d -``` - -This will rebuild the containers with the new `websockets` package. - -### 2. Verify Installation -Check that the API container started successfully: -```bash -docker compose logs api | grep "WebSocket" -``` - -You should see log entries about WebSocket connections once players connect. - -### 3. Test WebSocket Connection -Open the game in a browser and check the console (F12): -- Look for: `๐Ÿ”Œ Connecting to WebSocket: ws://...` -- Look for: `โœ… WebSocket connected` - -### 4. Monitor Active Connections -Check the health endpoint to see active WebSocket connections: -```bash -curl http://localhost:8000/health -``` - -(Can be extended to show WebSocket connection count) - -### 5. Test Real-Time Updates -1. **Movement Test:** - - Open game in two browser tabs (different accounts) - - Move one character to a location - - Other tab should see "Player arrived" message immediately - -2. **Combat Test:** - - Start combat with an NPC - - Attacks should update immediately (no 5s delay) - -3. **Pickup Test:** - - Drop an item in a location - - Other players in same location should see it disappear when picked up - -## Rollback (If Needed) - -If WebSocket causes issues, you can roll back to pure polling: - -### Option 1: Disable WebSocket on Frontend -Edit `pwa/src/components/Game.tsx`: -```typescript -const { isConnected, sendMessage } = useGameWebSocket({ - token, - onMessage: handleWebSocketMessage, - enabled: false // Change true to false -}) -``` - -### Option 2: Remove WebSocket from Backend -Revert changes to: -- `api/main.py` (remove WebSocket endpoint and broadcasts) -- `api/database.py` (remove get_players_in_location) -- `requirements.txt` (remove websockets package) - -Then rebuild and redeploy. - -## Performance Monitoring - -### Watch for Issues -```bash -# Monitor API logs for WebSocket errors -docker compose logs -f api | grep "WebSocket\|โŒ" - -# Check for memory usage increases -docker stats echoes_of_the_ashes_api - -# Monitor connection count (check server load) -# Add to health endpoint or check logs for "WebSocket connected/disconnected" -``` - -### Expected Behavior -- **Connection:** Players connect within 1-2 seconds of loading game -- **Reconnection:** If network drops, should reconnect within 3 seconds -- **Memory:** ~10MB per 1,000 connections (very low) -- **CPU:** Should decrease compared to polling (event-driven) - -## Troubleshooting - -### WebSocket Not Connecting -1. Check CORS settings in `api/main.py` -2. Verify token is present: `localStorage.getItem('token')` in browser console -3. Check nginx/proxy configuration (if using reverse proxy) - -### Frequent Disconnections -1. Check heartbeat interval (30s default) -2. Verify network stability -3. Check for proxy timeouts (nginx: `proxy_read_timeout 60s;`) - -### Messages Not Received -1. Verify `manager.send_personal_message()` is being called -2. Check player_id matches active connection -3. Look for WebSocket send errors in logs - -## Next Steps After Deployment - -1. **Monitor for 24 hours:** Check stability and error rates -2. **Gather user feedback:** Is latency better? Any connection issues? -3. **Plan live chat:** Quick win feature using WebSocket infrastructure -4. **Consider party system:** Next major feature enabled by WebSockets - -## Success Metrics - -After deployment, you should see: -- โœ… **95% reduction** in API request volume -- โœ… **<100ms latency** for game updates (vs 2500ms avg with polling) -- โœ… **Lower server CPU** usage (event-driven vs continuous polling) -- โœ… **Improved UX** - instant feedback on actions - -Good luck with the deployment! ๐Ÿš€ diff --git a/old/WEBSOCKET_FINAL_RESOLUTION.md b/old/WEBSOCKET_FINAL_RESOLUTION.md deleted file mode 100644 index 7b4b1ab..0000000 --- a/old/WEBSOCKET_FINAL_RESOLUTION.md +++ /dev/null @@ -1,163 +0,0 @@ -# โœ… WebSocket Configuration - RESOLVED! - -## Final Configuration - -### Problem Solved -The WebSocket configuration is now correct! The 404 errors from curl were expected - WebSocket connections require proper headers and valid tokens. - -### What Was Fixed - -1. **nginx.conf - API proxy path** - - Changed from: `proxy_pass http://echoes_of_the_ashes_api:8000/api/;` - - Changed to: `proxy_pass http://echoes_of_the_ashes_api:8000/;` - - Reason: API routes don't have `/api` prefix, nginx was adding it - -2. **nginx.conf - WebSocket proxy** - - Kept: `proxy_pass http://echoes_of_the_ashes_api:8000/ws/;` - - WebSocket timeout: 86400s (24 hours) - - Proper upgrade headers configured - -3. **Removed Traefik labels from API** - - API doesn't need to be exposed directly through Traefik - - All traffic goes: Browser โ†’ Traefik โ†’ PWA nginx โ†’ API - - Traefik only routes to PWA container - -### Request Flow - -``` -Browser - โ†“ wss://echoesoftheashgame.patacuack.net/ws/game/{token} -Traefik (TLS termination) - โ†“ https://echoes_of_the_ashes_pwa/ws/game/{token} -PWA nginx - โ†“ proxy_pass to http://echoes_of_the_ashes_api:8000/ws/game/{token} -API FastAPI WebSocket Handler - โ†“ @app.websocket("/ws/game/{token}") -WebSocket Connection Established โœ… -``` - -### Evidence It's Working - -**From API logs:** -``` -[2025-11-08 20:59:30] ('192.168.240.15', 45926) - "WebSocket /game/eyJ...token..." 403 -``` - -This shows: -- โœ… WebSocket requests ARE reaching the API -- โœ… Path is correct (`/game/...` - nginx already stripped `/ws`) -- โš ๏ธ Getting 403 because browser was using an old/invalid token - -### Testing Instructions - -1. **Open the game**: https://echoesoftheashgame.patacuack.net -2. **Login** to get a fresh JWT token -3. **Open browser console** (F12) -4. **Look for**: - ``` - ๐Ÿ”Œ Connecting to WebSocket: wss://echoesoftheashgame.patacuack.net/ws/game/... - โœ… WebSocket connected - ``` - -### If You See 403 Forbidden - -This means WebSocket is working but token is invalid. Solutions: -1. **Logout and login again** - Gets fresh token -2. **Clear localStorage** - `localStorage.clear()` -3. **Hard refresh** - Ctrl+Shift+R - -### Expected Behavior After Login - -- WebSocket connects within 1-2 seconds -- Console shows: `โœ… WebSocket connected` -- Movement is instant (no 5s polling delay) -- Combat updates in real-time -- API logs show: `๐Ÿ”Œ WebSocket connected: username (player_id=X)` - -### Testing Real-Time Updates - -1. **Movement**: Move to a new location - should update instantly -2. **Combat**: Attack an enemy - damage shows immediately -3. **Multi-player**: Open in two browsers - see other player arrivals - -### Network Tab Verification - -Open Chrome DevTools โ†’ Network tab โ†’ WS filter: -- Should see 1 WebSocket connection to `/ws/game/...` -- Status: 101 Switching Protocols (success) -- Frames tab shows messages being exchanged - -### Why Docker Cache Was An Issue - -Docker's build cache is very aggressive. Even after changing `nginx.conf`, it kept using the cached layer. Had to: -1. Clear all Docker build cache: `docker builder prune -f` -2. Rebuild without cache: `docker compose build --no-cache` -3. This finally copied the correct nginx.conf into the container - -### Configuration Files - -**nginx.conf** (correct configuration): -```nginx -location /api/ { - proxy_pass http://echoes_of_the_ashes_api:8000/; # Note: ends with / not /api/ - # ... headers ... -} - -location /ws/ { - proxy_pass http://echoes_of_the_ashes_api:8000/ws/; # Keeps /ws prefix - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_read_timeout 86400s; # 24 hour timeout - # ... other headers ... -} -``` - -**docker-compose.yml**: -```yaml -echoes_of_the_ashes_api: - # ... - networks: - - default_docker # Only on internal network - # NO Traefik labels needed! - -echoes_of_the_ashes_pwa: - # ... - networks: - - default_docker - - traefik # Exposed through Traefik - labels: - - traefik.enable=true - # ... PWA routes only ... -``` - -### Troubleshooting Commands - -```bash -# Check API health -curl https://echoesoftheashgame.patacuack.net/api/health - -# Check nginx config in container -docker exec echoes_of_the_ashes_pwa cat /etc/nginx/conf.d/default.conf - -# Watch API logs for WebSocket connections -docker compose logs -f echoes_of_the_ashes_api | grep -i websocket - -# Check PWA nginx logs -docker compose logs -f echoes_of_the_ashes_pwa -``` - -### Success Metrics - -After successful WebSocket connection: -- โœ… Bandwidth reduced by 95% (18KB/min โ†’ 1KB/min) -- โœ… Latency improved 50x (2500ms โ†’ <100ms) -- โœ… Server load reduced by 90% (event-driven vs polling) -- โœ… Real-time updates feel instant -- โœ… Users report faster, more responsive gameplay - -## Summary - -The WebSocket system is now **fully configured and working**. The API logs confirm WebSocket requests are reaching the endpoint. The 403 errors are just authentication issues that will resolve once users login with fresh tokens. - -**Next step**: Test in browser after logging in to verify complete end-to-end functionality! ๐Ÿš€ diff --git a/old/WEBSOCKET_IMPLEMENTATION_COMPLETE.md b/old/WEBSOCKET_IMPLEMENTATION_COMPLETE.md deleted file mode 100644 index 169ba64..0000000 --- a/old/WEBSOCKET_IMPLEMENTATION_COMPLETE.md +++ /dev/null @@ -1,335 +0,0 @@ -# WebSocket Implementation - Complete โœ… - -## Overview -Successfully implemented a complete WebSocket system for real-time game updates, replacing the aggressive polling system with efficient push-based communication. - -## Implementation Summary - -### Backend Changes - -#### 1. Dependencies Added -**Files Modified:** -- `requirements.txt` - Added `websockets==12.0` and `python-multipart==0.0.6` -- `api/requirements.txt` - Added `websockets==12.0` - -#### 2. WebSocket Connection Manager -**File:** `api/main.py` - -**New Class:** `ConnectionManager` -- Tracks active WebSocket connections (Dict[player_id, WebSocket]) -- Methods: - - `connect(websocket, player_id, username)` - Accept new connection - - `disconnect(player_id)` - Remove connection - - `send_personal_message(player_id, message)` - Send to specific player - - `broadcast(message, exclude_player_id)` - Send to all connected players - - `send_to_location(location_id, message, exclude_player_id)` - Send to players in location - - `get_connected_count()` - Get active connection count - -**Global Instance:** `manager = ConnectionManager()` - -#### 3. WebSocket Endpoint -**Endpoint:** `@app.websocket("/ws/game/{token}")` - -**Features:** -- JWT token authentication -- Initial state push on connect -- Heartbeat/ping support -- Message loop for incoming messages -- Automatic cleanup on disconnect -- Error handling with proper close codes - -**Message Types Handled:** -- `heartbeat` โ†’ `heartbeat_ack` -- `ping` โ†’ `pong` -- Future: chat, emotes, etc. - -#### 4. Database Helper -**File:** `api/database.py` - -**New Function:** `get_players_in_location(location_id: str)` -- Returns list of all players in a specific location -- Used by ConnectionManager for location-based broadcasting - -#### 5. Action Endpoint Updates -**Modified Endpoints:** - -**`/api/game/move`** - Broadcasts: -- `player_left` to old location (excluding mover) -- `player_arrived` to new location (excluding mover) -- `state_update` to moving player (with stamina, location, encounter) - -**`/api/game/pickup`** - Broadcasts: -- `item_picked_up` to location (excluding picker) -- `inventory_update` to picker - -**`/api/game/combat/action`** - Broadcasts: -- `combat_update` to player (with message, combat state, HP/XP/level) - -### Frontend Changes - -#### 1. WebSocket Custom Hook -**File:** `pwa/src/hooks/useGameWebSocket.ts` - -**Hook:** `useGameWebSocket({ token, onMessage, enabled })` - -**Features:** -- Automatic WebSocket connection management -- Auto-reconnection with exponential backoff (max 5 attempts) -- Heartbeat every 30 seconds -- Message parsing and error handling -- Environment-aware URL generation (localhost vs production) -- Manual reconnect function - -**Returns:** -- `isConnected: boolean` - Connection status -- `sendMessage(message)` - Send message to server -- `reconnect()` - Manual reconnect trigger - -#### 2. Game Component Integration -**File:** `pwa/src/components/Game.tsx` - -**Changes:** -1. Import WebSocket hook -2. Added state: `wsConnected` -3. Created `handleWebSocketMessage()` - Message dispatcher -4. Initialized WebSocket connection with token -5. Updated polling logic - Reduced frequency when WebSocket connected (30s vs 5s) - -**Message Handlers:** -- `connected` - Log connection success -- `state_update` - Update player state, location, handle encounters -- `combat_update` - Update combat log, combat state, player stats -- `inventory_update` - Refresh inventory -- `player_arrived` - Show notification, refresh location -- `player_left` - Show notification, refresh location -- `item_picked_up` - Refresh location items -- `error` - Log error message - -## Performance Improvements - -### Before WebSocket -- **Polling Frequency:** Every 5 seconds -- **Bandwidth:** ~18 KB/minute per player (5 endpoints ร— 1.5KB ร— 12 times/min) -- **Database Queries:** 8-12 queries per poll ร— 12 times/min = 96-144 queries/min -- **Latency:** 0-5000ms (average 2500ms) -- **Scalability:** ~100 concurrent users - -### After WebSocket -- **Polling Frequency:** Every 30 seconds (fallback only) -- **Bandwidth:** ~1 KB/minute per player (real-time push messages only) -- **Database Queries:** Only when actions occur (event-driven) -- **Latency:** <100ms (real-time push) -- **Scalability:** 1,000+ concurrent users - -### Metrics -- **95% Bandwidth Reduction** (18KB/min โ†’ 1KB/min) -- **50x Faster Latency** (2500ms โ†’ <100ms) -- **90% CPU Reduction** (event-driven vs continuous polling) -- **10x Scalability Improvement** - -## Message Flow Examples - -### Player Movement -``` -1. Player moves north -2. API: /api/game/move endpoint processes -3. WebSocket broadcasts: - - OLD_LOCATION players: {"type": "player_left", "player_name": "Alice"} - - NEW_LOCATION players: {"type": "player_arrived", "player_name": "Alice"} - - MOVING player: {"type": "state_update", "data": {...}} -4. Frontend updates immediately (no polling wait) -``` - -### Combat Update -``` -1. Player attacks enemy -2. API: /api/game/combat/action endpoint processes -3. WebSocket sends to player: - {"type": "combat_update", "data": { - "message": "You attack for 15 damage!", - "combat": {...combat state...}, - "player": {"hp": 85, "xp": 150} - }} -4. Frontend updates combat log + state instantly -``` - -### Item Pickup -``` -1. Player picks up item -2. API: /api/game/pickup endpoint processes -3. WebSocket broadcasts: - - LOCATION players: {"type": "item_picked_up", "player_name": "Bob", "item_id": "rusty_sword"} - - PICKER: {"type": "inventory_update"} -4. Frontend refreshes inventory + location items -``` - -## Fallback Polling Strategy - -### Hybrid Approach -- **WebSocket Active:** Poll every 30 seconds (backup sync) -- **WebSocket Disconnected:** Poll every 5 seconds (full fallback) -- **PvP Combat:** Always poll for critical state sync - -### Why Keep Polling? -1. **Reliability:** WebSocket can disconnect (network issues, server restart) -2. **State Sync:** Periodic full state refresh catches any missed messages -3. **PvP Critical:** Combat timeout requires accurate time sync -4. **Gradual Migration:** Can disable WebSocket per-user with feature flags - -## Testing Checklist - -### Connection Testing -- [x] WebSocket connects successfully with JWT token -- [x] Invalid token rejected with close code 4001 -- [x] Automatic reconnection works (disconnect network) -- [x] Heartbeat prevents connection timeout -- [x] Multiple tabs/devices support - -### Message Testing -- [ ] Move: Other players see "player arrived/left" -- [ ] Pickup: Other players see item disappear -- [ ] Combat: Player receives real-time damage/XP updates -- [ ] Encounter: Player receives ambush notification immediately -- [ ] Disconnection: Fallback polling takes over seamlessly - -### Performance Testing -- [ ] 10 concurrent users: Smooth updates -- [ ] 50 concurrent users: No lag -- [ ] 100+ concurrent users: Monitor server load -- [ ] Network interruption recovery: Auto-reconnect works -- [ ] Browser tab sleep/wake: Reconnects properly - -## Future Enhancements - -### Immediate Opportunities -1. **Live Chat System** - - Global chat channel - - Location-based chat - - Private messages - - Trade requests - -2. **Party System** - - Real-time party invites - - Shared HP/status display - - Party member locations on map - - Loot distribution - -3. **Real-Time Map** - - See other players moving in real-time - - Live enemy spawns - - Dynamic danger indicators - - Event markers - -4. **Server Events** - - Boss spawn notifications - - Server-wide events - - Admin broadcasts - - Maintenance warnings - -### Advanced Features -1. **Spectator Mode** - Watch other players' combat -2. **Live Leaderboards** - Real-time rank updates -3. **Trading System** - Player-to-player item exchanges -4. **Guilds/Clans** - Shared guild chat and events -5. **Dynamic Weather** - Real-time environmental changes - -## Scaling Considerations - -### Current Architecture (Single Server) -- **Capacity:** 1,000+ concurrent WebSocket connections -- **Memory:** ~10MB per 1,000 connections -- **CPU:** Event-driven (low idle usage) - -### Multi-Server Scaling (Future) -When reaching 1,000+ concurrent users: - -1. **Redis Pub/Sub Integration** - ```python - # Broadcast across all servers - await redis.publish('game_events', json.dumps({ - 'type': 'player_moved', - 'location_id': 'town_square', - 'data': {...} - })) - ``` - -2. **Load Balancer Configuration** - - Sticky sessions (player โ†’ server affinity) - - WebSocket-aware routing - - Health check endpoints - -3. **Connection Manager Updates** - - Track which server has which player - - Route messages through Redis - - Handle cross-server location broadcasts - -## Deployment Notes - -### Docker Configuration -No changes needed - FastAPI's built-in WebSocket support is included. - -### Environment Variables -No new variables required. Uses existing JWT_SECRET_KEY. - -### Gunicorn Workers -WebSocket connections work with multiple workers. Each worker maintains its own ConnectionManager instance. - -**Note:** Background tasks (spawn manager) run in only one worker due to locking. - -### CORS Configuration -Already configured to allow WebSocket connections from: -- `https://echoesoftheashgame.patacuack.net` -- `http://localhost:3000` -- `http://localhost:5173` - -## Monitoring - -### Metrics to Track -1. **Active WebSocket Connections:** `manager.get_connected_count()` -2. **Message Throughput:** Log message types and frequency -3. **Reconnection Rate:** Track disconnect/reconnect cycles -4. **Polling Fallback Usage:** Monitor when polling takes over -5. **Error Rates:** WebSocket send failures - -### Logging -All WebSocket events logged with emoji prefixes: -- ๐Ÿ”Œ Connection/disconnection -- ๐Ÿ“จ Message received -- โŒ Errors -- โœ… Successful operations - -### Health Check -Existing `/health` endpoint can be extended: -```python -{ - "status": "healthy", - "version": "2.0.0", - "websocket_connections": manager.get_connected_count() -} -``` - -## Rollback Plan - -If issues arise, WebSocket can be disabled without code changes: - -1. **Frontend:** Set `enabled: false` in `useGameWebSocket` hook -2. **Backend:** Comment out WebSocket broadcasts in action endpoints -3. **Fallback:** Polling system remains fully functional - -## Conclusion - -โœ… **Complete WebSocket implementation ready for production** - -The system provides: -- 95% bandwidth reduction -- 50x faster real-time updates -- Automatic fallback to polling -- Room for future features (chat, parties, live map) -- Scalable to 1,000+ concurrent users - -**Next Steps:** -1. Deploy to production -2. Monitor connection stability -3. Test with real users -4. Implement live chat (quick win) -5. Plan party system (high-value feature) diff --git a/old/WEBSOCKET_MIGRATION_PLAN.md b/old/WEBSOCKET_MIGRATION_PLAN.md deleted file mode 100644 index 83f5a0b..0000000 --- a/old/WEBSOCKET_MIGRATION_PLAN.md +++ /dev/null @@ -1,608 +0,0 @@ -# WebSocket Migration Plan - -## Difficulty Assessment: **Medium** โš™๏ธ - -**Time estimate:** 3-5 days for full implementation -**Complexity:** Moderate - FastAPI has excellent WebSocket support - ---- - -## Architecture Changes - -### Current (Polling) -``` -Client โ†’ HTTP GET every 5s โ†’ Server โ†’ Query DB โ†’ Return JSON - โ† HTTP Response โ† -``` - -### Future (WebSocket) -``` -Client โ†โ†’ WebSocket (persistent) โ†โ†’ Server - โ†“ - DB Query only on changes - โ†“ - Push to client immediately -``` - ---- - -## Implementation Steps - -### Phase 1: Backend WebSocket Setup (1-2 days) - -#### 1. Add WebSocket endpoint -**File:** `api/main.py` - -```python -from fastapi import WebSocket, WebSocketDisconnect -from typing import Dict -import asyncio -import json - -# Connection manager to track active WebSocket connections -class ConnectionManager: - def __init__(self): - self.active_connections: Dict[int, WebSocket] = {} # player_id -> websocket - - async def connect(self, websocket: WebSocket, player_id: int): - await websocket.accept() - self.active_connections[player_id] = websocket - print(f"Player {player_id} connected via WebSocket") - - def disconnect(self, player_id: int): - if player_id in self.active_connections: - del self.active_connections[player_id] - print(f"Player {player_id} disconnected") - - async def send_personal_message(self, player_id: int, message: dict): - """Send message to specific player""" - if player_id in self.active_connections: - try: - await self.active_connections[player_id].send_json(message) - except Exception as e: - print(f"Error sending to player {player_id}: {e}") - self.disconnect(player_id) - - async def broadcast(self, message: dict, exclude: int = None): - """Send message to all connected players""" - disconnected = [] - for player_id, connection in self.active_connections.items(): - if player_id == exclude: - continue - try: - await connection.send_json(message) - except Exception as e: - print(f"Error broadcasting to player {player_id}: {e}") - disconnected.append(player_id) - - # Clean up disconnected players - for player_id in disconnected: - self.disconnect(player_id) - - async def send_to_location(self, location_id: str, message: dict, exclude: int = None): - """Send message to all players in a specific location""" - # Get players in location from database - players_in_location = await db.get_players_in_location(location_id) - for player in players_in_location: - if player['id'] != exclude and player['id'] in self.active_connections: - await self.send_personal_message(player['id'], message) - -manager = ConnectionManager() - - -@app.websocket("/ws/game/{token}") -async def websocket_endpoint(websocket: WebSocket, token: str): - """ - Main WebSocket endpoint for real-time game updates - Client connects with auth token, receives push updates - """ - # Verify token and get player - try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - player_id = payload.get("user_id") - if not player_id: - await websocket.close(code=1008, reason="Invalid token") - return - except JWTError: - await websocket.close(code=1008, reason="Invalid token") - return - - player = await db.get_player_by_id(player_id) - if not player: - await websocket.close(code=1008, reason="Player not found") - return - - # Connect player - await manager.connect(websocket, player_id) - - # Send initial state - initial_state = await get_full_game_state(player_id) - await websocket.send_json({ - "type": "initial_state", - "data": initial_state - }) - - try: - # Keep connection alive and listen for messages - while True: - # Receive messages from client (heartbeat, actions, etc.) - data = await websocket.receive_json() - - # Handle different message types - if data.get("type") == "heartbeat": - await websocket.send_json({"type": "pong"}) - - elif data.get("type") == "action": - # Client performed action - process and broadcast updates - await handle_websocket_action(player_id, data, websocket) - - except WebSocketDisconnect: - manager.disconnect(player_id) - print(f"Player {player_id} disconnected") - - -async def handle_websocket_action(player_id: int, data: dict, websocket: WebSocket): - """Handle actions received via WebSocket""" - action = data.get("action") - - if action == "move": - # Process movement - result = await process_move(player_id, data.get("direction")) - - # Send update to player - await manager.send_personal_message(player_id, { - "type": "state_update", - "data": result - }) - - # Notify players in new location - if result.get("success"): - await manager.send_to_location( - result["new_location_id"], - { - "type": "player_entered", - "player": { - "id": player_id, - "username": result["username"] - } - }, - exclude=player_id - ) - - # Add more actions as needed... -``` - -#### 2. Push updates on state changes - -**Modify existing endpoints to push updates:** - -```python -@app.post("/api/game/pickup") -async def pickup(req, current_user): - # ... existing pickup logic ... - - # After successful pickup, push update via WebSocket - await manager.send_personal_message(current_user['id'], { - "type": "inventory_update", - "data": { - "inventory": new_inventory, - "dropped_items": new_dropped_items - } - }) - - # Also notify other players in location - await manager.send_to_location(player['location_id'], { - "type": "dropped_items_update", - "data": {"dropped_items": new_dropped_items} - }, exclude=current_user['id']) - - return result -``` - -#### 3. Add database helper for location players - -**File:** `api/database.py` - -```python -async def get_players_in_location(location_id: str) -> List[Dict]: - """Get all players currently in a location""" - async with DatabaseSession() as session: - stmt = select(players).where(players.c.location_id == location_id) - result = await session.execute(stmt) - return [dict(row) for row in result.fetchall()] -``` - ---- - -### Phase 2: Frontend WebSocket Setup (1-2 days) - -#### 1. Create WebSocket hook - -**File:** `pwa/src/hooks/useGameWebSocket.ts` - -```typescript -import { useEffect, useRef, useState } from 'react' - -interface WebSocketMessage { - type: string - data: any -} - -export const useGameWebSocket = (token: string, onMessage: (msg: WebSocketMessage) => void) => { - const ws = useRef(null) - const [isConnected, setIsConnected] = useState(false) - const reconnectTimeout = useRef(null) - - const connect = () => { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - const host = window.location.host - const wsUrl = `${protocol}//${host}/ws/game/${token}` - - ws.current = new WebSocket(wsUrl) - - ws.current.onopen = () => { - console.log('WebSocket connected') - setIsConnected(true) - - // Start heartbeat - const heartbeat = setInterval(() => { - if (ws.current?.readyState === WebSocket.OPEN) { - ws.current.send(JSON.stringify({ type: 'heartbeat' })) - } - }, 30000) // Every 30 seconds - - // Store interval ID for cleanup - ws.current.addEventListener('close', () => clearInterval(heartbeat)) - } - - ws.current.onmessage = (event) => { - try { - const message = JSON.parse(event.data) - onMessage(message) - } catch (error) { - console.error('Error parsing WebSocket message:', error) - } - } - - ws.current.onerror = (error) => { - console.error('WebSocket error:', error) - } - - ws.current.onclose = () => { - console.log('WebSocket disconnected') - setIsConnected(false) - - // Attempt to reconnect after 3 seconds - reconnectTimeout.current = window.setTimeout(() => { - console.log('Attempting to reconnect...') - connect() - }, 3000) - } - } - - useEffect(() => { - connect() - - return () => { - // Cleanup on unmount - if (reconnectTimeout.current) { - clearTimeout(reconnectTimeout.current) - } - if (ws.current) { - ws.current.close() - } - } - }, [token]) - - const sendMessage = (message: any) => { - if (ws.current?.readyState === WebSocket.OPEN) { - ws.current.send(JSON.stringify(message)) - } - } - - return { isConnected, sendMessage } -} -``` - -#### 2. Update Game component - -**File:** `pwa/src/components/Game.tsx` - -```typescript -import { useGameWebSocket } from '../hooks/useGameWebSocket' - -const Game = () => { - // ... existing state ... - - const handleWebSocketMessage = (message: WebSocketMessage) => { - switch (message.type) { - case 'initial_state': - // Set full game state on connect - setPlayerState(message.data.player) - setLocation(message.data.location) - setInventory(message.data.inventory) - setEquipment(message.data.equipment) - break - - case 'state_update': - // Partial update to player state - setPlayerState(prev => ({ ...prev, ...message.data.player })) - break - - case 'inventory_update': - // Update inventory - setInventory(message.data.inventory) - if (message.data.dropped_items) { - setDroppedItems(message.data.dropped_items) - } - break - - case 'pvp_combat_start': - // PvP combat initiated - setCombatState(message.data.combat) - break - - case 'pvp_combat_action': - // Opponent performed action in PvP - setCombatLog(prev => [...prev, message.data.action]) - setCombatState(prev => ({ ...prev, ...message.data.combat })) - break - - case 'player_entered': - // Another player entered your location - setMessage(`${message.data.player.username} entered the area`) - break - - // Add more message types... - } - } - - const { isConnected, sendMessage } = useGameWebSocket( - localStorage.getItem('token') || '', - handleWebSocketMessage - ) - - // Remove old polling useEffect, replace with WebSocket - // Keep fallback polling for when WebSocket disconnects - useEffect(() => { - if (!isConnected) { - // Fallback to polling if WebSocket disconnected - const interval = setInterval(() => { - fetchGameData(true) - }, 10000) // Poll every 10s as fallback - - return () => clearInterval(interval) - } - }, [isConnected]) - - // ... rest of component -} -``` - ---- - -### Phase 3: Hybrid Approach (Recommended - Best of Both Worlds) - -**Use WebSockets for real-time updates + Polling as fallback** - -```typescript -const Game = () => { - const [connectionMode, setConnectionMode] = useState<'websocket' | 'polling'>('websocket') - - const { isConnected, sendMessage } = useGameWebSocket(token, handleWebSocketMessage) - - // Monitor connection and switch to polling if WebSocket fails - useEffect(() => { - if (!isConnected) { - console.warn('WebSocket unavailable, using polling fallback') - setConnectionMode('polling') - } else { - setConnectionMode('websocket') - } - }, [isConnected]) - - // Fallback polling when WebSocket not available - useEffect(() => { - if (connectionMode === 'polling') { - const interval = setInterval(fetchGameData, 5000) - return () => clearInterval(interval) - } - }, [connectionMode]) -} -``` - -**Benefits:** -- โœ… Best UX when WebSocket works (99% of time) -- โœ… Graceful fallback for problematic networks -- โœ… Works behind corporate firewalls -- โœ… No downtime during deployments - ---- - -## Scaling Considerations - -### Single Server (Current - Simple) -``` -Client โ†โ†’ WebSocket โ†โ†’ FastAPI Server โ†โ†’ Database -``` -**Works for:** Up to 1,000 concurrent connections - -### Multi-Server (Future - When growing) -``` -Client โ†โ†’ WebSocket โ†โ†’ FastAPI Server 1 โ†โ†’ Redis Pub/Sub - โ†“ โ†“ - Database โ†โ†’ FastAPI Server 2 -``` - -**Use Redis for message broadcasting between servers:** - -```python -import redis.asyncio as redis - -redis_client = redis.from_url("redis://localhost") - -class ConnectionManager: - async def broadcast(self, message: dict): - # Publish to Redis channel - await redis_client.publish('game_events', json.dumps(message)) - - async def listen_to_broadcasts(self): - # Subscribe to Redis channel - pubsub = redis_client.pubsub() - await pubsub.subscribe('game_events') - - async for message in pubsub.listen(): - if message['type'] == 'message': - data = json.loads(message['data']) - # Forward to connected clients - await self._send_to_local_connections(data) -``` - -**Works for:** Unlimited connections (horizontal scaling) - ---- - -## Migration Strategy: Gradual Rollout - -### Option 1: Big Bang (3-5 days downtime) -- Implement everything at once -- Test thoroughly -- Deploy and switch - -### Option 2: Gradual (Recommended - Zero downtime) - -**Week 1:** -- โœ… Implement WebSocket endpoint -- โœ… Keep polling working -- โœ… Add feature flag: `USE_WEBSOCKET=false` - -**Week 2:** -- โœ… Test WebSocket with beta users -- โœ… Fix any issues -- โœ… Enable for 10% of users - -**Week 3:** -- โœ… Enable for 50% of users -- โœ… Monitor performance -- โœ… Fix edge cases - -**Week 4:** -- โœ… Enable for 100% of users -- โœ… Keep polling as fallback -- โœ… Remove old polling code (optional) - ---- - -## Expected Benefits After Migration - -### Performance -- **Latency:** 5000ms โ†’ **<100ms** (50x faster) -- **Bandwidth:** 18KB/min โ†’ **~1KB/min** (95% reduction) -- **Server Load:** 12 queries/poll โ†’ **1 query/change** (90% reduction) - -### User Experience -- โšก **Instant combat updates** (no 5s delay) -- ๐Ÿ—บ๏ธ **Live player locations** on map -- ๐Ÿ’ฌ **Real-time chat** capability -- ๐ŸŽฎ **Better PvP** feel (see actions immediately) -- ๐Ÿ“ข **Server announcements** (events, maintenance) - -### New Features Enabled -- ๐Ÿ‘ฅ Party/group system -- ๐Ÿ’ฌ In-game chat -- ๐Ÿ—บ๏ธ Live world map with player positions -- ๐Ÿ“Š Real-time leaderboards -- ๐ŸŽช Live events/raids -- ๐ŸŽ Random spawns/drops broadcast - ---- - -## My Recommendation - -### For Your Game: **Implement WebSockets Now** ๐Ÿš€ - -**Why:** -1. **You're already planning Steam release** - WebSocket quality expected -2. **PvP combat exists** - Real-time feel makes huge difference -3. **FastAPI has excellent WebSocket support** - Not that hard -4. **Your codebase is clean** - Easy to refactor -5. **Growing player base** - Better to do it now than later - -**Timeline:** -- Day 1-2: Backend WebSocket setup -- Day 3-4: Frontend integration -- Day 5: Testing & polish -- Week 2: Gradual rollout - -**Risk:** Low - Keep polling as fallback - ---- - -## Code Complexity Comparison - -### Polling (Current) -```typescript -// Client - 20 lines -useEffect(() => { - const interval = setInterval(fetchGameData, 5000) - return () => clearInterval(interval) -}, []) -``` - -```python -# Server - 5 lines -@app.get("/api/game/state") -async def get_state(user): - return await db.get_player_state(user.id) -``` - -**Total:** ~25 lines, very simple - -### WebSocket (After migration) -```typescript -// Client - 80 lines (hook + handler) -const useGameWebSocket = (token, onMessage) => { - // Connection management - // Reconnection logic - // Message handling - // Heartbeat -} -``` - -```python -# Server - 150 lines -class ConnectionManager: - # Connection tracking - # Broadcasting - # Message routing - -@app.websocket("/ws/game/{token}") -async def websocket_endpoint(): - # Auth - # Connection handling - # Message processing -``` - -**Total:** ~230 lines, moderate complexity - -**Verdict:** About 10x more code, but FastAPI does heavy lifting. Complexity is **manageable**. - ---- - -## My Assessment - -**Difficulty:** 6/10 for me to implement -- I know the codebase well -- FastAPI WebSocket support is great -- Your architecture is clean - -**Would take me:** 3-4 days to implement fully with testing - -**Worth it?** **Absolutely YES** ๐Ÿ’ฏ -- Long-term better performance -- Better UX -- Industry standard for real-time games -- Enables future features -- Required for serious Steam release - -Want me to start implementing it? I can do it in phases with zero downtime! diff --git a/old/WEBSOCKET_TESTING.md b/old/WEBSOCKET_TESTING.md deleted file mode 100644 index d1d03ed..0000000 --- a/old/WEBSOCKET_TESTING.md +++ /dev/null @@ -1,122 +0,0 @@ -# WebSocket Testing Guide - -## Quick Test - Browser Console - -Open the game in your browser and press F12 to open the console. Look for these messages: - -### Expected Console Messages -``` -๐Ÿ”Œ Connecting to WebSocket: wss://echoesoftheashgame.patacuack.net/ws/game/... -โœ… WebSocket connected -๐Ÿ“จ WebSocket message: connected -``` - -### Test WebSocket Messages - -1. **Test Movement:** - - Move your character to a new location - - You should see instant state updates (no 5-second delay) - - Check console for: `๐Ÿ“จ WebSocket message: state_update` - -2. **Test Combat:** - - Enter combat with an NPC - - Attack and observe instant feedback - - Check console for: `๐Ÿ“จ WebSocket message: combat_update` - -3. **Test Multi-User (Optional):** - - Open game in two different browsers/accounts - - Move one character - - Other browser should see "Player arrived" notification - -## Verify WebSocket Connection - -Run this in your browser console while in the game: - -```javascript -// Check if WebSocket is connected -console.log('WebSocket Status:', window.performance.getEntriesByType('resource').filter(r => r.name.includes('/ws/game/'))) -``` - -## Check Server-Side - -Check API logs for WebSocket connections: - -```bash -cd /opt/dockers/echoes_of_the_ashes -docker compose logs echoes_of_the_ashes_api | grep "WebSocket" -``` - -You should see: -``` -๐Ÿ”Œ WebSocket connected: username (player_id=123) -``` - -## Performance Comparison - -### Before WebSocket -- Open browser DevTools Network tab -- Filter for "game" API calls -- Count requests per minute: Should be ~60 requests (5 endpoints ร— 12 times/min) - -### After WebSocket -- Same network tab -- Should see 1 WebSocket connection (ws:// or wss://) -- Regular polling should drop to ~2-6 requests/min (backup polling every 30s) - -## Troubleshooting - -### WebSocket Not Connecting - -1. **Check Token:** - ```javascript - console.log('Token:', localStorage.getItem('token')) - ``` - Should show a JWT token string. - -2. **Check CORS/Network:** - - Look for errors in console - - Check if using HTTPS (wss://) on production - - Check if firewall blocking WebSocket - -3. **Check API Container:** - ```bash - docker compose logs echoes_of_the_ashes_api | tail -50 - ``` - -### Frequent Disconnections - -1. Check heartbeat is working (every 30s) -2. Look for network issues -3. Check nginx timeout settings (if using reverse proxy) - -### Messages Not Updating - -1. Verify WebSocket shows "connected" in console -2. Check if fallback polling is taking over -3. Look for errors in API logs - -## Success Indicators - -โœ… WebSocket connection established within 2 seconds -โœ… Movement updates are instant (<100ms) -โœ… Combat actions show immediate feedback -โœ… Network tab shows reduced API calls (87% reduction) -โœ… No WebSocket errors in console -โœ… Auto-reconnect works after network interruption - -## Next Steps - -Once WebSocket is confirmed working: - -1. **Monitor for 24 hours** - Check stability -2. **Gather user feedback** - Are updates faster? -3. **Check server metrics** - CPU/memory usage should be lower -4. **Plan new features** - Live chat, parties, real-time map - -## Known Limitations - -- WebSocket requires modern browsers (all current browsers support it) -- Fallback polling ensures old browsers still work -- First connection may take 1-2 seconds (JWT validation) - -Good luck! ๐ŸŽฎ diff --git a/old/WEBSOCKET_TRAEFIK_FIX.md b/old/WEBSOCKET_TRAEFIK_FIX.md deleted file mode 100644 index 125f781..0000000 --- a/old/WEBSOCKET_TRAEFIK_FIX.md +++ /dev/null @@ -1,188 +0,0 @@ -# WebSocket Traefik Configuration - FIXED โœ… - -## Changes Made - -### 1. Added Traefik Labels to API Service -**File:** `docker-compose.yml` - -Added routing for both `/api` and `/ws` paths through Traefik: -- HTTP to HTTPS redirect for both routes -- TLS/SSL enabled for secure WebSocket (wss://) -- Service port 8000 exposed - -### 2. Added WebSocket Proxy to nginx -**File:** `nginx.conf` - -Added `/ws/` location block with: -- WebSocket upgrade headers -- Long timeout (86400s = 24 hours) -- Proper proxy configuration - -## Testing - -### 1. Check WebSocket Connection in Browser - -Open https://echoesoftheashgame.patacuack.net and press F12: - -**Expected Console Output:** -``` -๐Ÿ”Œ Connecting to WebSocket: wss://echoesoftheashgame.patacuack.net/ws/game/... -โœ… WebSocket connected -``` - -**If Still Failing:** -``` -โŒ WebSocket connection failed -``` - -### 2. Test WebSocket Endpoint Directly - -Run this command to test if WebSocket is accessible: -```bash -curl -i -N \ - -H "Connection: Upgrade" \ - -H "Upgrade: websocket" \ - -H "Sec-WebSocket-Version: 13" \ - -H "Sec-WebSocket-Key: $(openssl rand -base64 16)" \ - https://echoesoftheashgame.patacuack.net/ws/game/test -``` - -**Expected:** Should get HTTP 401 (Unauthorized) or 400 (Bad Request) -**Good Sign:** Connection is reaching the API -**Bad Sign:** Connection refused or timeout - -### 3. Check Traefik Logs - -```bash -docker logs traefik | grep websocket -docker logs traefik | grep echoesoftheash -``` - -### 4. Check API Logs for WebSocket Connections - -```bash -docker compose logs echoes_of_the_ashes_api | grep "WebSocket" -``` - -**Expected after a successful connection:** -``` -๐Ÿ”Œ WebSocket connected: username (player_id=123) -``` - -## Architecture - -### Request Flow - -``` -Browser (wss://) - โ†“ -Traefik (TLS termination) - โ†“ -PWA nginx (proxy /ws/ โ†’ API) - โ†“ -API FastAPI (WebSocket handler) -``` - -### URL Mapping - -- **Browser connects to:** `wss://echoesoftheashgame.patacuack.net/ws/game/{token}` -- **Traefik routes to:** PWA nginx container -- **Nginx proxies to:** `http://echoes_of_the_ashes_api:8000/ws/game/{token}` -- **API handles:** WebSocket connection - -## Common Issues & Solutions - -### Issue 1: Connection Refused -**Cause:** Traefik not routing to API -**Solution:** โœ… Added Traefik labels to API service - -### Issue 2: 502 Bad Gateway -**Cause:** nginx not proxying WebSocket correctly -**Solution:** โœ… Added `/ws/` location block with upgrade headers - -### Issue 3: Connection Timeout -**Cause:** WebSocket timeout too short -**Solution:** โœ… Set `proxy_read_timeout 86400s` (24 hours) - -### Issue 4: Works on HTTP but not HTTPS -**Cause:** TLS not configured properly -**Solution:** โœ… Using Traefik's TLS termination with certResolver - -### Issue 5: CORS Errors -**Cause:** Missing CORS headers -**Check:** API already has CORS configured in `api/main.py` - -## Verification Commands - -### 1. Check if API is accessible through Traefik -```bash -curl https://echoesoftheashgame.patacuack.net/api/health -``` - -Should return: -```json -{"status":"healthy","version":"2.0.0","locations_loaded":14,"items_loaded":42} -``` - -### 2. Check if containers are running -```bash -docker compose ps -``` - -All should show "running" status. - -### 3. Check Traefik routing -```bash -docker exec traefik cat /etc/traefik/traefik.yml -``` - -### 4. Test WebSocket from command line (with valid token) -```bash -# Get your token from browser localStorage -# Then test: -wscat -c "wss://echoesoftheashgame.patacuack.net/ws/game/YOUR_TOKEN_HERE" -``` - -**Note:** You need to install wscat first: `npm install -g wscat` - -## If Still Not Working - -### Debug Checklist - -1. โœ… Traefik labels added to API service -2. โœ… API service connected to traefik network -3. โœ… nginx.conf has /ws/ location block -4. โœ… PWA container rebuilt with new nginx.conf -5. โœ… All containers restarted -6. โฌœ Check Traefik dashboard for routing rules -7. โฌœ Check browser network tab for WebSocket connection attempt -8. โฌœ Check API logs for incoming connections -9. โฌœ Check firewall rules (if applicable) - -### Manual Test (Browser Console) - -```javascript -// Test WebSocket connection manually -const token = localStorage.getItem('token'); -const ws = new WebSocket(`wss://echoesoftheashgame.patacuack.net/ws/game/${token}`); - -ws.onopen = () => console.log('โœ… Connected!'); -ws.onerror = (err) => console.error('โŒ Error:', err); -ws.onmessage = (msg) => console.log('๐Ÿ“จ Message:', JSON.parse(msg.data)); -``` - -## Next Steps After Success - -1. โœ… Verify WebSocket stays connected (check after 1 minute) -2. โœ… Test movement - should see instant updates -3. โœ… Test combat - should see real-time damage -4. โœ… Monitor server load - should be much lower than before -5. โœ… Check user feedback - faster response times - -## Performance Benefits (After Working) - -- **Latency:** 2500ms โ†’ <100ms (25x faster) -- **Bandwidth:** 18KB/min โ†’ 1KB/min (95% reduction) -- **Server Load:** 96-144 queries/min โ†’ Event-driven (90% reduction) - -Good luck! ๐Ÿš€ diff --git a/old/bot/__init__.py b/old/bot/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/old/bot/action_handlers.py b/old/bot/action_handlers.py deleted file mode 100644 index a4b83e7..0000000 --- a/old/bot/action_handlers.py +++ /dev/null @@ -1,417 +0,0 @@ -""" -Action handlers for button callbacks. -This module contains organized handler functions for different types of player actions. -""" -import logging -import json -import random -from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup -from telegram.ext import ContextTypes -from . import keyboards, logic -from .api_client import api_client -from .utils import format_stat_bar -from data.world_loader import game_world -from data.items import ITEMS - -logger = logging.getLogger(__name__) - - -# ============================================================================ -# UTILITY FUNCTIONS -# ============================================================================ - -async def check_and_redirect_if_in_combat(query, user_id: int, player: dict) -> bool: - """ - Check if player is in combat and redirect to combat view if so. - Returns True if player is in combat (and was redirected), False otherwise. - """ - combat_data = await api_client.get_combat(user_id) - if combat_data: - from data.npcs import NPCS - npc_def = NPCS.get(combat_data['npc_id']) - - message = f"โš”๏ธ You're in combat with {npc_def.emoji} {npc_def.name}!\n" - message += format_stat_bar("Your HP", "โค๏ธ", player['hp'], player['max_hp']) + "\n" - message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n" - message += "๐ŸŽฏ Your turn!" if combat_data['turn'] == 'player' else "โณ Enemy's turn..." - - keyboard = await keyboards.combat_keyboard(user_id) - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=message, - reply_markup=keyboard, - image_path=npc_def.image_url if npc_def else None - ) - await query.answer("โš”๏ธ You're in combat! Finish or flee first.", show_alert=True) - return True - return False - -async def get_player_status_text(player_id: int) -> str: - """Generate player status text with location and stats. - - Args: - player_id: The unique database ID of the player (not telegram_id) - """ - from .api_client import api_client - - player = await api_client.get_player_by_id(player_id) - if not player: - return "Could not find player data." - - location = game_world.get_location(player["location_id"]) - if not location: - return "Error: Player is in an unknown location." - - # Get inventory from API - inv_result = await api_client.get_inventory(player_id) - inventory = inv_result.get('inventory', []) - weight, volume = logic.calculate_inventory_load(inventory) - max_weight, max_volume = logic.get_player_capacity(inventory, player) - - # Get equipped items - equipped_items = [] - for item in inventory: - if item.get('is_equipped'): - item_def = ITEMS.get(item['item_id'], {}) - emoji = item_def.get('emoji', 'โ”') - equipped_items.append(f"{emoji} {item_def.get('name', 'Unknown')}") - - # Build status with visual bars - status = f"๐Ÿ“ Location: {location.name}\n" - status += f"{format_stat_bar('HP', 'โค๏ธ', player['hp'], player['max_hp'])}\n" - status += f"{format_stat_bar('Stamina', 'โšก', player['stamina'], player['max_stamina'])}\n" - status += f"๐ŸŽ’ Load: {weight}/{max_weight} kg | {volume}/{max_volume} vol\n" - - if equipped_items: - status += f"โš”๏ธ Equipped: {', '.join(equipped_items)}\n" - - status += "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n" - status += f"{location.description}" - return status - - -# ============================================================================ -# INSPECTION & WORLD INTERACTION HANDLERS -# ============================================================================ - -async def handle_inspect_area(query, user_id: int, player: dict, data: list = None): - """Handle inspect area action - show NPCs and interactables in current location.""" - # Check if player is in combat and redirect if so - if await check_and_redirect_if_in_combat(query, user_id, player): - return - - await query.answer() - location_id = player['location_id'] - location = game_world.get_location(location_id) - dropped_items = await api_client.get_dropped_items_in_location(location_id) - wandering_enemies = await api_client.get_wandering_enemies_in_location(location_id) - - keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies) - image_path = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text="You scan the area. You notice...", - reply_markup=keyboard, - image_path=image_path - ) - - -async def handle_attack_wandering(query, user_id: int, player: dict, data: list): - """Handle attacking a wandering enemy.""" - enemy_db_id = int(data[1]) - await query.answer() - - # Get the enemy from database - wandering_enemies = await api_client.get_wandering_enemies_in_location(player['location_id']) - enemy_data = next((e for e in wandering_enemies if e['id'] == enemy_db_id), None) - - if not enemy_data: - await query.answer("That enemy has already moved on!", show_alert=True) - # Refresh inspect menu - location_id = player['location_id'] - location = game_world.get_location(location_id) - dropped_items = await api_client.get_dropped_items_in_location(location_id) - wandering_enemies = await api_client.get_wandering_enemies_in_location(location_id) - keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies) - image_path = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text="You scan the area. You notice...", - reply_markup=keyboard, - image_path=image_path - ) - return - - npc_id = enemy_data['npc_id'] - - # Remove enemy from wandering table (they're now in combat) - await api_client.remove_wandering_enemy(enemy_db_id) - - from data.npcs import NPCS - from bot import combat - - # Initiate combat - combat_data = await combat.initiate_combat( - user_id, npc_id, player['location_id'], from_wandering_enemy=True - ) - - if combat_data: - npc_def = NPCS.get(npc_id) - message = f"โš”๏ธ You engage the {npc_def.emoji} {npc_def.name}!\n\n" - message += f"{npc_def.description}\n\n" - message += format_stat_bar("Your HP", "โค๏ธ", player['hp'], player['max_hp']) + "\n" - message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n" - message += "๐ŸŽฏ Your turn! What will you do?" - - keyboard = await keyboards.combat_keyboard(user_id) - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=message, - reply_markup=keyboard, - image_path=npc_def.image_url if npc_def else None - ) - else: - await query.answer("Failed to initiate combat.", show_alert=True) - - -async def handle_inspect_interactable(query, user_id: int, player: dict, data: list): - """Handle inspecting an interactable object.""" - # Check if player is in combat and redirect if so - if await check_and_redirect_if_in_combat(query, user_id, player): - return - - location_id, instance_id = data[1], data[2] - - location = game_world.get_location(location_id) - if not location: - await query.answer("Location not found.", show_alert=True) - return - - interactable = location.get_interactable(instance_id) - if not interactable: - await query.answer("Object not found.", show_alert=False) - return - - # Check if ALL actions are on cooldown - all_on_cooldown = True - for action_id in interactable.actions.keys(): - cooldown_key = f"{instance_id}:{action_id}" - if await api_client.get_cooldown(cooldown_key) == 0: - all_on_cooldown = False - break - - if all_on_cooldown and len(interactable.actions) > 0: - await query.answer( - f"The {interactable.name} has already been searched. Try again later.", - show_alert=False - ) - return - - # Show action menu - await query.answer() - image_path = interactable.image_path if interactable else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=f"You focus on the {interactable.name}. What do you do?", - reply_markup=await keyboards.actions_keyboard(location_id, instance_id), - image_path=image_path - ) - - -async def handle_action(query, user_id: int, player: dict, data: list): - """Handle performing an action on an interactable object.""" - # Check if player is in combat and redirect if so - if await check_and_redirect_if_in_combat(query, user_id, player): - return - - location_id, instance_id, action_id = data[1], data[2], data[3] - cooldown_key = f"{instance_id}:{action_id}" - cooldown = await api_client.get_cooldown(cooldown_key) - - if cooldown > 0: - await query.answer("Someone got to it just before you!", show_alert=False) - return - - location = game_world.get_location(location_id) - if not location: - await query.answer("Location not found.", show_alert=True) - return - - action_obj = location.get_interactable(instance_id).get_action(action_id) - - if player['stamina'] < action_obj.stamina_cost: - await query.answer("You are too tired to do that!", show_alert=False) - return - - await query.answer() - - # Set cooldown - await api_client.set_cooldown(cooldown_key) - - # Resolve action - outcome = logic.resolve_action(player, action_obj) - new_stamina = player['stamina'] - action_obj.stamina_cost - new_hp = player['hp'] - outcome.damage_taken - await api_client.update_player(user_id, {"stamina": new_stamina, "hp": new_hp}) - - # Build detailed action result - result_details = [f"{outcome.text}"] - - if action_obj.stamina_cost > 0: - result_details.append(f"โšก๏ธ Stamina: -{action_obj.stamina_cost}") - - if outcome.damage_taken > 0: - result_details.append(f"โค๏ธ HP: -{outcome.damage_taken}") - - # Add items gained - if outcome.items_reward: - items_text = [] - items_failed = [] - for item_id, quantity in outcome.items_reward.items(): - can_add, reason = await logic.can_add_item_to_inventory(user_id, item_id, quantity) - - if can_add: - await api_client.add_item_to_inventory(user_id, item_id, quantity) - item_def = ITEMS.get(item_id, {}) - emoji = item_def.get('emoji', 'โ”') - item_name = item_def.get('name', item_id) - items_text.append(f"{emoji} {item_name} x{quantity}") - else: - item_def = ITEMS.get(item_id, {}) - item_name = item_def.get('name', item_id) - items_failed.append(f"{item_name} ({reason})") - - if items_text: - result_details.append(f"๐ŸŽ Gained: {', '.join(items_text)}") - if items_failed: - result_details.append(f"โš ๏ธ Couldn't take: {', '.join(items_failed)}") - - final_text = await get_player_status_text(user_id) - final_text += f"\n\nโ”โ”โ” Action Result โ”โ”โ”\n" + "\n".join(result_details) - - # Get location image for the result screen - current_location = game_world.get_location(player['location_id']) - location_image = current_location.image_path if current_location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=final_text, - reply_markup=keyboards.main_menu_keyboard(), - image_path=location_image - ) - - -# ============================================================================ -# NAVIGATION & MOVEMENT HANDLERS -# ============================================================================ - -async def handle_main_menu(query, user_id: int, player: dict, data: list = None): - """Return to main menu.""" - await query.answer() - status_text = await get_player_status_text(user_id) - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=status_text, - reply_markup=keyboards.main_menu_keyboard(), - image_path=location_image - ) - - -async def handle_move_menu(query, user_id: int, player: dict, data: list = None): - """Show movement options menu.""" - # Check if player is in combat and redirect if so - if await check_and_redirect_if_in_combat(query, user_id, player): - return - - await query.answer() - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text="Where do you want to go?", - reply_markup=await keyboards.move_keyboard(player['location_id'], user_id), - image_path=location_image - ) - - -async def handle_move(query, user_id: int, player: dict, data: list): - """Handle player movement to a new location.""" - # Check if player is in combat and redirect if so - if await check_and_redirect_if_in_combat(query, user_id, player): - return - - destination_id = data[1] - - # Use API to move player - from .api_client import api_client - result = await api_client.move_player(player['id'], destination_id) - - if not result.get('success'): - await query.answer(result.get('message', 'Cannot move there!'), show_alert=True) - return - - await query.answer(result.get('message', 'Moving...'), show_alert=False) - - # Refresh player data from API using unique id - player = await api_client.get_player_by_id(user_id) - - # Check for random NPC encounter - from data.npcs import NPCS, get_random_npc_for_location, get_location_encounter_rate - encounter_rate = get_location_encounter_rate(destination_id) - - if random.random() < encounter_rate: - from bot import combat - logger.info(f"Encounter triggered at {destination_id} (rate: {encounter_rate})") - - npc_id = get_random_npc_for_location(destination_id) - - if npc_id: - combat_data = await combat.initiate_combat(user_id, npc_id, destination_id) - - if combat_data: - npc_def = NPCS.get(npc_id) - message = f"โš ๏ธ A {npc_def.emoji} {npc_def.name} appears!\n\n" - message += f"{npc_def.description}\n\n" - message += format_stat_bar("Your HP", "โค๏ธ", player['hp'], player['max_hp']) + "\n" - message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n" - message += "๐ŸŽฏ Your turn! What will you do?" - - keyboard = await keyboards.combat_keyboard(user_id) - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=message, - reply_markup=keyboard, - image_path=npc_def.image_url if npc_def else None - ) - return - - status_text = await get_player_status_text(user_id) - new_location = game_world.get_location(destination_id) - location_image = new_location.image_path if new_location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=status_text, - reply_markup=keyboards.main_menu_keyboard(), - image_path=location_image - ) diff --git a/old/bot/api_client.old.py b/old/bot/api_client.old.py deleted file mode 100644 index 1cc7bbe..0000000 --- a/old/bot/api_client.old.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -API Client for Telegram Bot -Connects bot to FastAPI game server instead of using direct database access -""" - -import os -import httpx -from typing import Optional, Dict, Any - -API_BASE_URL = os.getenv("API_BASE_URL", "http://echoes_of_the_ashes_api:8000") -API_INTERNAL_KEY = os.getenv("API_INTERNAL_KEY", "internal-bot-access-key-change-me") - - -class GameAPIClient: - """Client for interacting with the FastAPI game server""" - - def __init__(self): - self.base_url = API_BASE_URL - self.headers = { - "X-Internal-Key": API_INTERNAL_KEY, - "Content-Type": "application/json" - } - self.client = httpx.AsyncClient(timeout=30.0) - - async def close(self): - """Close the HTTP client""" - await self.client.aclose() - - # ==================== Player Management ==================== - - async def get_player(self, telegram_id: int) -> Optional[Dict[str, Any]]: - """Get player by telegram ID""" - try: - response = await self.client.get( - f"{self.base_url}/api/internal/player/telegram/{telegram_id}", - headers=self.headers - ) - if response.status_code == 404: - return None - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting player: {e}") - return None - - async def create_player(self, telegram_id: int, name: str) -> Optional[Dict[str, Any]]: - """Create a new player""" - try: - response = await self.client.post( - f"{self.base_url}/api/internal/player", - headers=self.headers, - json={"telegram_id": telegram_id, "name": name} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error creating player: {e}") - return None - - async def update_player(self, telegram_id: int, updates: Dict[str, Any]) -> bool: - """Update player data""" - try: - response = await self.client.patch( - f"{self.base_url}/api/internal/player/telegram/{telegram_id}", - headers=self.headers, - json=updates - ) - response.raise_for_status() - return True - except Exception as e: - print(f"Error updating player: {e}") - return False - - # ==================== Location & Movement ==================== - - async def get_location(self, location_id: str) -> Optional[Dict[str, Any]]: - """Get location details""" - try: - response = await self.client.get( - f"{self.base_url}/api/internal/location/{location_id}", - headers=self.headers - ) - if response.status_code == 404: - return None - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting location: {e}") - return None - - async def move_player(self, telegram_id: int, direction: str) -> Optional[Dict[str, Any]]: - """Move player in a direction""" - try: - response = await self.client.post( - f"{self.base_url}/api/internal/player/telegram/{telegram_id}/move", - headers=self.headers, - json={"direction": direction} - ) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - # Return error details - return {"success": False, "error": e.response.json().get("detail", str(e))} - except Exception as e: - print(f"Error moving player: {e}") - return {"success": False, "error": str(e)} - - # ==================== Combat ==================== - - async def start_combat(self, telegram_id: int, npc_id: str) -> Optional[Dict[str, Any]]: - """Start combat with an NPC""" - try: - response = await self.client.post( - f"{self.base_url}/api/internal/combat/start", - headers=self.headers, - json={"telegram_id": telegram_id, "npc_id": npc_id} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error starting combat: {e}") - return None - - async def get_combat(self, telegram_id: int) -> Optional[Dict[str, Any]]: - """Get active combat state""" - try: - response = await self.client.get( - f"{self.base_url}/api/internal/combat/telegram/{telegram_id}", - headers=self.headers - ) - if response.status_code == 404: - return None - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting combat: {e}") - return None - - async def combat_action(self, telegram_id: int, action: str) -> Optional[Dict[str, Any]]: - """Perform a combat action (attack, defend, flee)""" - try: - response = await self.client.post( - f"{self.base_url}/api/internal/combat/telegram/{telegram_id}/action", - headers=self.headers, - json={"action": action} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error performing combat action: {e}") - return None - - # ==================== Inventory ==================== - - async def get_inventory(self, telegram_id: int) -> Optional[Dict[str, Any]]: - """Get player's inventory""" - try: - response = await self.client.get( - f"{self.base_url}/api/internal/player/telegram/{telegram_id}/inventory", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting inventory: {e}") - return None - - async def use_item(self, telegram_id: int, item_db_id: int) -> Optional[Dict[str, Any]]: - """Use an item from inventory""" - try: - response = await self.client.post( - f"{self.base_url}/api/internal/player/telegram/{telegram_id}/use_item", - headers=self.headers, - json={"item_db_id": item_db_id} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error using item: {e}") - return None - - async def equip_item(self, telegram_id: int, item_db_id: int) -> Optional[Dict[str, Any]]: - """Equip/unequip an item""" - try: - response = await self.client.post( - f"{self.base_url}/api/internal/player/telegram/{telegram_id}/equip", - headers=self.headers, - json={"item_db_id": item_db_id} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error equipping item: {e}") - return None - - -# Global API client instance -api_client = GameAPIClient() diff --git a/old/bot/api_client.py b/old/bot/api_client.py deleted file mode 100644 index 38b9a55..0000000 --- a/old/bot/api_client.py +++ /dev/null @@ -1,623 +0,0 @@ -""" -API client for the bot to communicate with the standalone API. -All database operations now go through the API. -""" -import httpx -import os -from typing import Optional, Dict, Any, List - - -class APIClient: - """Client for bot-to-API communication""" - - def __init__(self): - self.api_url = os.getenv("API_BASE_URL", os.getenv("API_URL", "http://echoes_of_the_ashes_api:8000")) - self.internal_key = os.getenv("API_INTERNAL_KEY", "change-this-internal-key") - self.client = httpx.AsyncClient(timeout=30.0) - self.headers = { - "Authorization": f"Bearer {self.internal_key}", - "Content-Type": "application/json" - } - - async def close(self): - """Close the HTTP client""" - await self.client.aclose() - - # Player operations - async def get_player(self, telegram_id: int) -> Optional[Dict[str, Any]]: - """Get player by Telegram ID""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/player/{telegram_id}", - headers=self.headers - ) - if response.status_code == 404: - return None - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting player: {e}") - return None - - async def get_player_by_id(self, player_id: int) -> Optional[Dict[str, Any]]: - """Get player by unique database ID""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/player/by_id/{player_id}", - headers=self.headers - ) - if response.status_code == 404: - return None - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting player by id: {e}") - return None - - async def create_player(self, telegram_id: int, name: str = "Survivor") -> Optional[Dict[str, Any]]: - """Create a new player""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/player", - headers=self.headers, - params={"telegram_id": telegram_id, "name": name} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error creating player: {e}") - return None - - # Movement operations - async def move_player(self, player_id: int, direction: str) -> Dict[str, Any]: - """Move player in a direction""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/player/{player_id}/move", - headers=self.headers, - params={"direction": direction} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error moving player: {e}") - return {"success": False, "message": str(e)} - - # Inspection operations - async def inspect_area(self, player_id: int) -> Dict[str, Any]: - """Inspect current area""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/player/{player_id}/inspect", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error inspecting area: {e}") - return {"success": False, "message": str(e)} - - # Interaction operations - async def interact(self, player_id: int, interactable_id: str, action_id: str) -> Dict[str, Any]: - """Interact with an object""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/player/{player_id}/interact", - headers=self.headers, - params={"interactable_id": interactable_id, "action_id": action_id} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error interacting: {e}") - return {"success": False, "message": str(e)} - - # Inventory operations - async def get_inventory(self, player_id: int) -> Dict[str, Any]: - """Get player inventory""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/player/{player_id}/inventory", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting inventory: {e}") - return {"success": False, "inventory": []} - - async def use_item(self, player_id: int, item_id: str) -> Dict[str, Any]: - """Use an item""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/player/{player_id}/use_item", - headers=self.headers, - params={"item_id": item_id} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error using item: {e}") - return {"success": False, "message": str(e)} - - async def pickup_item(self, player_id: int, item_id: str) -> Dict[str, Any]: - """Pick up an item""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/player/{player_id}/pickup", - headers=self.headers, - params={"item_id": item_id} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error picking up item: {e}") - return {"success": False, "message": str(e)} - - async def drop_item(self, player_id: int, item_id: str, quantity: int = 1) -> Dict[str, Any]: - """Drop an item""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/player/{player_id}/drop_item", - headers=self.headers, - params={"item_id": item_id, "quantity": quantity} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error dropping item: {e}") - return {"success": False, "message": str(e)} - - async def equip_item(self, player_id: int, item_id: str) -> Dict[str, Any]: - """Equip an item""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/player/{player_id}/equip", - headers=self.headers, - params={"item_id": item_id} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error equipping item: {e}") - return {"success": False, "message": str(e)} - - async def unequip_item(self, player_id: int, item_id: str) -> Dict[str, Any]: - """Unequip an item""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/player/{player_id}/unequip", - headers=self.headers, - params={"item_id": item_id} - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error unequipping item: {e}") - return {"success": False, "message": str(e)} - - # Combat operations - async def get_combat(self, player_id: int) -> Optional[Dict[str, Any]]: - """Get active combat for player""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/player/{player_id}/combat", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting combat: {e}") - return None - - async def create_combat(self, player_id: int, npc_id: str, npc_hp: int, npc_max_hp: int, location_id: str, from_wandering: bool = False) -> Optional[Dict[str, Any]]: - """Create new combat""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/combat/create", - headers=self.headers, - params={ - "player_id": player_id, - "npc_id": npc_id, - "npc_hp": npc_hp, - "npc_max_hp": npc_max_hp, - "location_id": location_id, - "from_wandering": from_wandering - } - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error creating combat: {e}") - return None - - async def update_combat(self, player_id: int, updates: Dict[str, Any]) -> bool: - """Update combat state""" - try: - response = await self.client.patch( - f"{self.api_url}/api/internal/combat/{player_id}", - headers=self.headers, - json=updates - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error updating combat: {e}") - return False - - async def end_combat(self, player_id: int) -> bool: - """End combat""" - try: - response = await self.client.delete( - f"{self.api_url}/api/internal/combat/{player_id}", - headers=self.headers - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error ending combat: {e}") - return False - - # Player update operations - async def update_player(self, player_id: int, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Update player fields""" - try: - response = await self.client.patch( - f"{self.api_url}/api/internal/player/{player_id}", - headers=self.headers, - json=updates - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error updating player: {e}") - return None - - # Dropped items operations - async def drop_item_to_world(self, item_id: str, quantity: int, location_id: str) -> bool: - """Drop an item to the world""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/dropped-items", - headers=self.headers, - params={"item_id": item_id, "quantity": quantity, "location_id": location_id} - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error dropping item: {e}") - return False - - async def get_dropped_item(self, dropped_item_id: int) -> Optional[Dict[str, Any]]: - """Get a specific dropped item""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/dropped-items/{dropped_item_id}", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting dropped item: {e}") - return None - - async def get_dropped_items_in_location(self, location_id: str) -> List[Dict[str, Any]]: - """Get all dropped items in a location""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/location/{location_id}/dropped-items", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting dropped items: {e}") - return [] - - async def update_dropped_item(self, dropped_item_id: int, quantity: int) -> bool: - """Update dropped item quantity""" - try: - response = await self.client.patch( - f"{self.api_url}/api/internal/dropped-items/{dropped_item_id}", - headers=self.headers, - params={"quantity": quantity} - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error updating dropped item: {e}") - return False - - async def remove_dropped_item(self, dropped_item_id: int) -> bool: - """Remove a dropped item""" - try: - response = await self.client.delete( - f"{self.api_url}/api/internal/dropped-items/{dropped_item_id}", - headers=self.headers - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error removing dropped item: {e}") - return False - - # Corpse operations - async def create_player_corpse(self, player_name: str, location_id: str, items: str) -> Optional[int]: - """Create a player corpse""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/corpses/player", - headers=self.headers, - params={"player_name": player_name, "location_id": location_id, "items": items} - ) - response.raise_for_status() - result = response.json() - return result.get('corpse_id') - except Exception as e: - print(f"Error creating player corpse: {e}") - return None - - async def get_player_corpse(self, corpse_id: int) -> Optional[Dict[str, Any]]: - """Get a player corpse""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/corpses/player/{corpse_id}", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting player corpse: {e}") - return None - - async def update_player_corpse(self, corpse_id: int, items: str) -> bool: - """Update player corpse items""" - try: - response = await self.client.patch( - f"{self.api_url}/api/internal/corpses/player/{corpse_id}", - headers=self.headers, - params={"items": items} - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error updating player corpse: {e}") - return False - - async def remove_player_corpse(self, corpse_id: int) -> bool: - """Remove a player corpse""" - try: - response = await self.client.delete( - f"{self.api_url}/api/internal/corpses/player/{corpse_id}", - headers=self.headers - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error removing player corpse: {e}") - return False - - async def create_npc_corpse(self, npc_id: str, location_id: str, loot_remaining: str) -> Optional[int]: - """Create an NPC corpse""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/corpses/npc", - headers=self.headers, - params={"npc_id": npc_id, "location_id": location_id, "loot_remaining": loot_remaining} - ) - response.raise_for_status() - result = response.json() - return result.get('corpse_id') - except Exception as e: - print(f"Error creating NPC corpse: {e}") - return None - - async def get_npc_corpse(self, corpse_id: int) -> Optional[Dict[str, Any]]: - """Get an NPC corpse""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/corpses/npc/{corpse_id}", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting NPC corpse: {e}") - return None - - async def update_npc_corpse(self, corpse_id: int, loot_remaining: str) -> bool: - """Update NPC corpse loot""" - try: - response = await self.client.patch( - f"{self.api_url}/api/internal/corpses/npc/{corpse_id}", - headers=self.headers, - params={"loot_remaining": loot_remaining} - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error updating NPC corpse: {e}") - return False - - async def remove_npc_corpse(self, corpse_id: int) -> bool: - """Remove an NPC corpse""" - try: - response = await self.client.delete( - f"{self.api_url}/api/internal/corpses/npc/{corpse_id}", - headers=self.headers - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error removing NPC corpse: {e}") - return False - - # Wandering enemies operations - async def spawn_wandering_enemy(self, npc_id: str, location_id: str, current_hp: int, max_hp: int) -> Optional[int]: - """Spawn a wandering enemy""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/wandering-enemies", - headers=self.headers, - params={"npc_id": npc_id, "location_id": location_id, "current_hp": current_hp, "max_hp": max_hp} - ) - response.raise_for_status() - result = response.json() - return result.get('enemy_id') - except Exception as e: - print(f"Error spawning wandering enemy: {e}") - return None - - async def get_wandering_enemies_in_location(self, location_id: str) -> List[Dict[str, Any]]: - """Get all wandering enemies in a location""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/location/{location_id}/wandering-enemies", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting wandering enemies: {e}") - return [] - - async def remove_wandering_enemy(self, enemy_id: int) -> bool: - """Remove a wandering enemy""" - try: - response = await self.client.delete( - f"{self.api_url}/api/internal/wandering-enemies/{enemy_id}", - headers=self.headers - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error removing wandering enemy: {e}") - return False - - async def get_inventory_item(self, item_db_id: int) -> Optional[Dict[str, Any]]: - """Get a specific inventory item by database ID""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/inventory/item/{item_db_id}", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting inventory item: {e}") - return None - - # Cooldown operations - async def get_cooldown(self, cooldown_key: str) -> int: - """Get remaining cooldown time in seconds""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/cooldown/{cooldown_key}", - headers=self.headers - ) - response.raise_for_status() - result = response.json() - return result.get('remaining_seconds', 0) - except Exception as e: - print(f"Error getting cooldown: {e}") - return 0 - - async def set_cooldown(self, cooldown_key: str, duration_seconds: int = 600) -> bool: - """Set a cooldown""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/cooldown/{cooldown_key}", - headers=self.headers, - params={"duration_seconds": duration_seconds} - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error setting cooldown: {e}") - return False - - # Corpse list operations - async def get_player_corpses_in_location(self, location_id: str) -> List[Dict[str, Any]]: - """Get all player corpses in a location""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/location/{location_id}/corpses/player", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting player corpses: {e}") - return [] - - async def get_npc_corpses_in_location(self, location_id: str) -> List[Dict[str, Any]]: - """Get all NPC corpses in a location""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/location/{location_id}/corpses/npc", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting NPC corpses: {e}") - return [] - - # Image cache operations - async def get_cached_image(self, image_path: str) -> Optional[str]: - """Get cached telegram file ID for an image""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/image-cache/{image_path}", - headers=self.headers - ) - response.raise_for_status() - result = response.json() - return result.get('telegram_file_id') - except Exception as e: - # Not found is expected, not an error - return None - - async def cache_image(self, image_path: str, telegram_file_id: str) -> bool: - """Cache a telegram file ID for an image""" - try: - response = await self.client.post( - f"{self.api_url}/api/internal/image-cache", - headers=self.headers, - params={"image_path": image_path, "telegram_file_id": telegram_file_id} - ) - response.raise_for_status() - result = response.json() - return result.get('success', False) - except Exception as e: - print(f"Error caching image: {e}") - return False - - # Status effects operations - async def get_player_status_effects(self, player_id: int) -> List[Dict[str, Any]]: - """Get player status effects""" - try: - response = await self.client.get( - f"{self.api_url}/api/internal/player/{player_id}/status-effects", - headers=self.headers - ) - response.raise_for_status() - return response.json() - except Exception as e: - print(f"Error getting status effects: {e}") - return [] - - -# Global API client instance -api_client = APIClient() diff --git a/old/bot/background_tasks.py b/old/bot/background_tasks.py deleted file mode 100644 index c4ba318..0000000 --- a/old/bot/background_tasks.py +++ /dev/null @@ -1,201 +0,0 @@ -""" -Background tasks for the bot. -Handles periodic maintenance, regeneration, and processing. -""" -import asyncio -import logging -import time -from bot import database - -logger = logging.getLogger(__name__) - - -async def decay_dropped_items(shutdown_event): - """A background task that periodically cleans up old dropped items.""" - while not shutdown_event.is_set(): - try: - # Wait for 5 minutes before the next cleanup - await asyncio.wait_for(shutdown_event.wait(), timeout=300) - except asyncio.TimeoutError: - start_time = time.time() - logger.info("Running item decay task...") - - # Set decay time to 1 hour (3600 seconds) - decay_seconds = 3600 - timestamp_limit = int(time.time()) - decay_seconds - items_removed = await database.remove_expired_dropped_items(timestamp_limit) - - elapsed = time.time() - start_time - if items_removed > 0: - logger.info(f"Decayed and removed {items_removed} old items in {elapsed:.2f}s") - - -async def regenerate_stamina(shutdown_event): - """A background task that periodically regenerates stamina for all players.""" - while not shutdown_event.is_set(): - try: - # Wait for 5 minutes before the next regeneration cycle - await asyncio.wait_for(shutdown_event.wait(), timeout=300) - except asyncio.TimeoutError: - start_time = time.time() - logger.info("Running stamina regeneration...") - - players_updated = await database.regenerate_all_players_stamina() - - elapsed = time.time() - start_time - if players_updated > 0: - logger.info(f"Regenerated stamina for {players_updated} players in {elapsed:.2f}s") - - # Alert if regeneration is taking too long (potential scaling issue) - if elapsed > 5.0: - logger.warning(f"โš ๏ธ Stamina regeneration took {elapsed:.2f}s (threshold: 5s) - check database load!") - - -async def check_combat_timers(shutdown_event): - """A background task that checks for idle combat turns and auto-attacks.""" - while not shutdown_event.is_set(): - try: - # Wait for 30 seconds before next check - await asyncio.wait_for(shutdown_event.wait(), timeout=30) - except asyncio.TimeoutError: - start_time = time.time() - # Check for combats idle for more than 5 minutes (300 seconds) - idle_threshold = time.time() - 300 - idle_combats = await database.get_all_idle_combats(idle_threshold) - - if idle_combats: - logger.info(f"Processing {len(idle_combats)} idle combats...") - - for combat in idle_combats: - try: - from bot import combat as combat_logic - # Force end player's turn and let NPC attack - if combat['turn'] == 'player': - await database.update_combat(combat['player_id'], { - 'turn': 'npc', - 'turn_started_at': time.time() - }) - # NPC attacks - await combat_logic.npc_attack(combat['player_id']) - except Exception as e: - logger.error(f"Error processing idle combat: {e}") - - # Log performance for monitoring - if idle_combats: - elapsed = time.time() - start_time - logger.info(f"Processed {len(idle_combats)} idle combats in {elapsed:.2f}s") - - # Warn if taking too long (potential scaling issue) - if elapsed > 10.0: - logger.warning(f"โš ๏ธ Combat timer check took {elapsed:.2f}s (threshold: 10s) - consider batching!") - - -async def decay_corpses(shutdown_event): - """A background task that removes old corpses.""" - while not shutdown_event.is_set(): - try: - # Wait for 10 minutes before next cleanup - await asyncio.wait_for(shutdown_event.wait(), timeout=600) - except asyncio.TimeoutError: - start_time = time.time() - logger.info("Running corpse decay...") - - # Player corpses decay after 24 hours - player_corpse_limit = time.time() - (24 * 3600) - player_corpses_removed = await database.remove_expired_player_corpses(player_corpse_limit) - - # NPC corpses decay after 2 hours - npc_corpse_limit = time.time() - (2 * 3600) - npc_corpses_removed = await database.remove_expired_npc_corpses(npc_corpse_limit) - - elapsed = time.time() - start_time - if player_corpses_removed > 0 or npc_corpses_removed > 0: - logger.info(f"Decayed {player_corpses_removed} player corpses and {npc_corpses_removed} NPC corpses in {elapsed:.2f}s") - - -async def process_status_effects(shutdown_event): - """ - A background task that applies damage from persistent status effects. - Runs every 5 minutes to process status effect ticks. - """ - while not shutdown_event.is_set(): - try: - # Wait for 5 minutes before next processing cycle - await asyncio.wait_for(shutdown_event.wait(), timeout=300) - except asyncio.TimeoutError: - start_time = time.time() - logger.info("Running status effects processor...") - - try: - # Decrement all status effect ticks and get affected players - affected_players = await database.decrement_all_status_effect_ticks() - - if not affected_players: - elapsed = time.time() - start_time - logger.info(f"No active status effects to process ({elapsed:.3f}s)") - continue - - # Process each affected player - deaths = 0 - damage_dealt = 0 - - for player_id in affected_players: - try: - # Get current status effects (after decrement) - effects = await database.get_player_status_effects(player_id) - - if not effects: - continue - - # Calculate total damage - from bot.status_utils import calculate_status_damage - total_damage = calculate_status_damage(effects) - - if total_damage > 0: - damage_dealt += total_damage - player = await database.get_player(player_id) - - if not player or player['is_dead']: - continue - - new_hp = max(0, player['hp'] - total_damage) - - # Check if player died from status effects - if new_hp <= 0: - await database.update_player(player_id, {'hp': 0, 'is_dead': True}) - deaths += 1 - - # Create player corpse - inventory = await database.get_inventory(player_id) - await database.create_player_corpse( - player_name=player['name'], - location_id=player['location_id'], - items=inventory - ) - - # Remove status effects from dead player - await database.remove_all_status_effects(player_id) - - logger.info(f"Player {player['name']} (ID: {player_id}) died from status effects") - else: - # Apply damage - await database.update_player(player_id, {'hp': new_hp}) - - except Exception as e: - logger.error(f"Error processing status effects for player {player_id}: {e}") - - elapsed = time.time() - start_time - logger.info( - f"Processed status effects for {len(affected_players)} players " - f"({damage_dealt} total damage, {deaths} deaths) in {elapsed:.3f}s" - ) - - # Warn if taking too long (potential scaling issue) - if elapsed > 5.0: - logger.warning( - f"โš ๏ธ Status effects processing took {elapsed:.3f}s (threshold: 5s) " - f"- {len(affected_players)} players affected" - ) - - except Exception as e: - logger.error(f"Error in status effects processor: {e}") diff --git a/old/bot/combat.py b/old/bot/combat.py deleted file mode 100644 index 97b7427..0000000 --- a/old/bot/combat.py +++ /dev/null @@ -1,527 +0,0 @@ -""" -Combat system logic for turn-based NPC encounters. -""" - -import random -import json -import time -from typing import Dict, List, Tuple, Optional -from bot.api_client import api_client -from bot.utils import format_stat_bar -from data.npcs import NPCS, STATUS_EFFECTS -from data.items import ITEMS - - -# XP curve for leveling -def xp_for_level(level: int) -> int: - """Calculate XP needed to reach a level.""" - if level <= 1: - return 0 # Level 1 starts at 0 XP - return int(100 * (level ** 1.5)) - - -async def calculate_player_damage(player: dict) -> int: - """Calculate player's damage output based on stats and equipped weapon.""" - base_damage = 5 - strength_bonus = player['strength'] // 2 - level_bonus = player['level'] - - # Check for equipped weapon - inventory = await api_client.get_inventory(player['telegram_id']) - weapon_damage = 0 - - for item in inventory: - if item.get('is_equipped'): - item_def = ITEMS.get(item['item_id'], {}) - if item_def.get('type') == 'weapon': - # Get weapon damage range - damage_min = item_def.get('damage_min', 0) - damage_max = item_def.get('damage_max', 0) - weapon_damage = random.randint(damage_min, damage_max) - break - - # Random variance - variance = random.randint(-2, 2) - - return max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance) - - -def calculate_npc_damage(npc_def, npc_hp: int, npc_max_hp: int) -> int: - """Calculate NPC's damage output.""" - base_damage = random.randint(npc_def.damage_min, npc_def.damage_max) - - # Enraged bonus if low HP - hp_percent = npc_hp / npc_max_hp - if hp_percent < 0.3: - base_damage = int(base_damage * 1.5) - - return max(1, base_damage) - - -async def initiate_combat(player_id: int, npc_id: str, location_id: str, from_wandering_enemy: bool = False) -> Dict: - """ - Start a new combat encounter. - Args: - player_id: Telegram user ID - npc_id: NPC definition ID - location_id: Where combat is happening - from_wandering_enemy: If True, enemy will respawn if player flees or dies - Returns combat state dict. - """ - npc_def = NPCS.get(npc_id) - if not npc_def: - return None - - # Randomize NPC HP - npc_hp = random.randint(npc_def.hp_min, npc_def.hp_max) - - # Create combat in database - combat_id = await api_client.create_combat( - player_id=player_id, - npc_id=npc_id, - npc_hp=npc_hp, - npc_max_hp=npc_hp, - location_id=location_id, - from_wandering_enemy=from_wandering_enemy - ) - - return await api_client.get_combat(player_id) - - -async def player_attack(player_id: int) -> Tuple[str, bool, bool]: - """ - Player attacks the NPC. - Returns: (message, npc_died, player_turn_ended) - """ - combat = await api_client.get_combat(player_id) - if not combat or combat['turn'] != 'player': - return ("It's not your turn!", False, False) - - player = await api_client.get_player(player_id) - npc_def = NPCS.get(combat['npc_id']) - - if not player or not npc_def: - return ("Combat error!", False, False) - - # Check if player is stunned - player_effects = json.loads(combat['player_status_effects']) - is_stunned = any(effect['name'] == 'Stunned' for effect in player_effects) - if is_stunned: - # Update status effects - player_effects = update_status_effects(player_effects) - await api_client.update_combat(player_id, { - 'turn': 'npc', - 'turn_started_at': time.time(), - 'player_status_effects': json.dumps(player_effects) - }) - return ("โš ๏ธ You're stunned and cannot attack! The enemy seizes the opportunity!", False, True) - - # Calculate damage - raw_damage = await calculate_player_damage(player) - actual_damage = max(1, raw_damage - npc_def.defense) - - new_npc_hp = max(0, combat['npc_hp'] - actual_damage) - - # Check for critical hit (10% chance) - is_crit = random.random() < 0.1 - if is_crit: - actual_damage = int(actual_damage * 1.5) - new_npc_hp = max(0, combat['npc_hp'] - actual_damage) - - message = "โ”โ”โ” YOUR TURN โ”โ”โ”\n" - message += f"โš”๏ธ You attack the {npc_def.name} for {actual_damage} damage!" - if is_crit: - message += " ๐Ÿ’ฅ CRITICAL HIT!" - - # Check for status effect infliction (5% chance to stun) - npc_effects = json.loads(combat['npc_status_effects']) - if random.random() < 0.05: - npc_effects.append({ - 'name': 'Stunned', - 'turns_remaining': 1, - 'damage_per_turn': 0 - }) - message += f"\n๐ŸŒŸ You stunned the {npc_def.name}!" - - # Apply status effect damage to player - player_effects, status_damage, status_messages = apply_status_effects(player_effects) - if status_damage > 0: - new_player_hp = max(0, player['hp'] - status_damage) - await api_client.update_player(player_id, {'hp': new_player_hp}) - message += f"\n{status_messages}" - - if new_player_hp <= 0: - await handle_player_death(player_id) - return (message + "\n\n๐Ÿ’€ You have died from your wounds...", True, True) - - # Check if NPC died - if new_npc_hp <= 0: - await api_client.update_combat(player_id, { - 'npc_hp': 0, - 'npc_status_effects': json.dumps(npc_effects), - 'player_status_effects': json.dumps(player_effects) - }) - - # Handle victory - victory_msg = await handle_npc_death(player_id, combat, npc_def) - return (message + "\n\n" + victory_msg, True, True) - - # Update combat - switch to NPC turn - await api_client.update_combat(player_id, { - 'npc_hp': new_npc_hp, - 'turn': 'npc', - 'turn_started_at': time.time(), - 'npc_status_effects': json.dumps(npc_effects), - 'player_status_effects': json.dumps(player_effects) - }) - - # Show both health bars after player's turn - message += "\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n" - message += format_stat_bar("Your HP", "โค๏ธ", player['hp'], player['max_hp']) + "\n" - message += format_stat_bar(npc_def.name, npc_def.emoji, new_npc_hp, combat['npc_max_hp']) - - return (message, False, True) - - - -async def npc_attack(player_id: int) -> Tuple[str, bool]: - """ - NPC attacks the player. - Returns: (message, player_died) - """ - combat = await api_client.get_combat(player_id) - if not combat or combat['turn'] != 'npc': - return ("", False) - - player = await api_client.get_player(player_id) - npc_def = NPCS.get(combat['npc_id']) - - if not player or not npc_def: - return ("Combat error!", False) - - # Check if NPC is stunned - npc_effects = json.loads(combat['npc_status_effects']) - is_stunned = any(effect['name'] == 'Stunned' for effect in npc_effects) - if is_stunned: - # Update status effects - npc_effects = update_status_effects(npc_effects) - await api_client.update_combat(player_id, { - 'turn': 'player', - 'turn_started_at': time.time(), - 'npc_status_effects': json.dumps(npc_effects) - }) - return (f"โš ๏ธ The {npc_def.name} is stunned and cannot attack!", False) - - # Calculate damage - damage = calculate_npc_damage(npc_def, combat['npc_hp'], combat['npc_max_hp']) - - # Apply damage to player - new_player_hp = max(0, player['hp'] - damage) - await api_client.update_player(player_id, {'hp': new_player_hp}) - - message = "โ”โ”โ” ENEMY TURN โ”โ”โ”\n" - message += f"๐Ÿ’ฅ The {npc_def.name} attacks you for {damage} damage!" - - # Check for status effect infliction - player_effects = json.loads(combat['player_status_effects']) - if random.random() < npc_def.status_inflict_chance: - # Bleeding is most common - player_effects.append({ - 'name': 'Bleeding', - 'turns_remaining': 3, - 'damage_per_turn': 2 - }) - message += "\n๐Ÿฉธ You're bleeding!" - - # Apply status effect damage to NPC - npc_effects, status_damage, status_messages = apply_status_effects(npc_effects) - if status_damage > 0: - new_npc_hp = max(0, combat['npc_hp'] - status_damage) - await api_client.update_combat(player_id, {'npc_hp': new_npc_hp}) - message += f"\n{status_messages}" - - if new_npc_hp <= 0: - victory_msg = await handle_npc_death(player_id, combat, npc_def) - return (message + "\n\n" + victory_msg, False) - - # Check if player died - if new_player_hp <= 0: - await handle_player_death(player_id) - return (message + "\n\n๐Ÿ’€ You have been slain...", True) - - # Update combat - switch to player turn - await api_client.update_combat(player_id, { - 'turn': 'player', - 'turn_started_at': time.time(), - 'player_status_effects': json.dumps(player_effects), - 'npc_status_effects': json.dumps(npc_effects) - }) - - # Show both health bars after enemy's turn - message += "\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n" - message += format_stat_bar("Your HP", "โค๏ธ", new_player_hp, player['max_hp']) + "\n" - message += format_stat_bar(npc_def.name, npc_def.emoji, combat['npc_hp'], combat['npc_max_hp']) - - return (message, False) - - -async def flee_attempt(player_id: int) -> Tuple[str, bool, bool]: - """ - Player attempts to flee from combat. - Returns: (message, fled_successfully, turn_ended) - """ - combat = await api_client.get_combat(player_id) - if not combat or combat['turn'] != 'player': - return ("It's not your turn!", False, False) - - player = await api_client.get_player(player_id) - npc_def = NPCS.get(combat['npc_id']) - - # Base flee chance is 50%, modified by agility - flee_chance = 0.5 + (player['agility'] / 100) - - if random.random() < flee_chance: - # Success! Check if we need to respawn the wandering enemy - if combat.get('from_wandering_enemy', False): - # Respawn the enemy at the same location with full HP - await api_client.spawn_wandering_enemy( - npc_id=combat['npc_id'], - location_id=combat['location_id'], - current_hp=npc_def.hp, - max_hp=npc_def.hp - ) - - await api_client.end_combat(player_id) - return (f"๐Ÿƒ You successfully flee from the {npc_def.name}!", True, True) - else: - # Failed - lose turn and NPC attacks - message = f"โŒ You failed to escape! The {npc_def.name} takes advantage!" - - # NPC gets a free attack - await api_client.update_combat(player_id, { - 'turn': 'npc', - 'turn_started_at': time.time() - }) - - return (message, False, True) - - -def update_status_effects(effects: List[Dict]) -> List[Dict]: - """Decrease turn counters on status effects.""" - new_effects = [] - for effect in effects: - effect['turns_remaining'] -= 1 - if effect['turns_remaining'] > 0: - new_effects.append(effect) - return new_effects - - -def apply_status_effects(effects: List[Dict]) -> Tuple[List[Dict], int, str]: - """ - Apply status effect damage with stacking. - Returns: (updated_effects, total_damage, message) - """ - from bot.status_utils import stack_status_effects - - if not effects: - return effects, 0, "" - - # Convert effect format if needed (name -> effect_name, damage_per_turn -> damage_per_tick) - normalized_effects = [] - for effect in effects: - normalized = { - 'effect_name': effect.get('name', effect.get('effect_name', 'Unknown')), - 'effect_icon': effect.get('icon', effect.get('effect_icon', 'โ“')), - 'damage_per_tick': effect.get('damage_per_turn', effect.get('damage_per_tick', 0)), - 'ticks_remaining': effect.get('turns_remaining', effect.get('ticks_remaining', 0)) - } - normalized_effects.append(normalized) - - # Stack effects - stacked = stack_status_effects(normalized_effects) - - total_damage = 0 - messages = [] - - for name, data in stacked.items(): - if data['total_damage'] > 0: - total_damage += data['total_damage'] - # Show stacked damage - if data['stacks'] > 1: - messages.append(f"{data['icon']} {name.replace('_', ' ').title()}: -{data['total_damage']} HP (ร—{data['stacks']})") - else: - messages.append(f"{data['icon']} {name.replace('_', ' ').title()}: -{data['total_damage']} HP") - - return effects, total_damage, "\n".join(messages) - - -async def handle_npc_death(player_id: int, combat: Dict, npc_def) -> str: - """Handle NPC death - give XP, drop loot, create corpse.""" - player = await api_client.get_player(player_id) - - # Give XP - new_xp = player['xp'] + npc_def.xp_reward - level_up_msg = "" - - # Check for level up - current_level = player['level'] - xp_needed = xp_for_level(current_level + 1) - - if new_xp >= xp_needed: - new_level = current_level + 1 - # Give stat points instead of auto-allocating - # Players get 5 points per level to spend as they wish - points_gained = 5 - new_unspent_points = player.get('unspent_points', 0) + points_gained - - await api_client.update_player(player_id, { - 'xp': new_xp, - 'level': new_level, - 'hp': player['max_hp'], # Heal on level up - 'stamina': player['max_stamina'], # Restore stamina on level up - 'unspent_points': new_unspent_points - }) - - level_up_msg = f"\n\n๐ŸŽ‰ LEVEL UP! You are now level {new_level}!" - level_up_msg += f"\nโญ You have {points_gained} stat points to spend!" - level_up_msg += f"\nโค๏ธ Fully healed and stamina restored!" - level_up_msg += f"\n\n๐Ÿ’ก Check your profile to spend your points!" - else: - await api_client.update_player(player_id, {'xp': new_xp}) - - # Drop loot - loot_msg = "\n\n๐Ÿ’ฐ Loot dropped:" - loot_items = [] - for loot_item in npc_def.loot_table: - if random.random() < loot_item.drop_chance: - quantity = random.randint(loot_item.quantity_min, loot_item.quantity_max) - await api_client.drop_item_to_world( - loot_item.item_id, - quantity, - combat['location_id'] - ) - item_def = ITEMS.get(loot_item.item_id, {}) - loot_msg += f"\n{item_def.get('emoji', 'โ”')} {item_def.get('name', 'Unknown')} x{quantity}" - loot_items.append(loot_item.item_id) - - if not loot_items: - loot_msg += "\nNothing..." - - # Create corpse if it has corpse loot - if npc_def.corpse_loot: - corpse_loot_json = json.dumps([{ - 'item_id': cl.item_id, - 'quantity_min': cl.quantity_min, - 'quantity_max': cl.quantity_max, - 'required_tool': cl.required_tool - } for cl in npc_def.corpse_loot]) - - await api_client.create_npc_corpse( - npc_id=combat['npc_id'], - location_id=combat['location_id'], - loot_remaining=corpse_loot_json - ) - loot_msg += f"\n\n{npc_def.emoji} The corpse can be scavenged for more resources." - - # End combat - await api_client.end_combat(player_id) - - message = f"๐Ÿ† Victory! {npc_def.death_message}" - message += f"\n+{npc_def.xp_reward} XP" - message += level_up_msg - message += loot_msg - - return message - - -async def handle_player_death(player_id: int): - """Handle player death - create corpse bag with all items.""" - player = await api_client.get_player(player_id) - inventory_items = await api_client.get_inventory(player_id) - - # Check if combat was with a wandering enemy that should respawn - combat = await api_client.get_combat(player_id) - if combat and combat.get('from_wandering_enemy', False): - # Respawn the enemy at the same location with full HP - npc_def = NPCS.get(combat['npc_id']) - await api_client.spawn_wandering_enemy( - npc_id=combat['npc_id'], - location_id=combat['location_id'], - current_hp=npc_def.hp, - max_hp=npc_def.hp - ) - - # Create corpse bag if player has items - if inventory_items: - items_json = json.dumps([{ - 'item_id': item['item_id'], - 'quantity': item['quantity'] - } for item in inventory_items]) - - await api_client.create_player_corpse( - player_name=player['name'], - location_id=player['location_id'], - items=items_json - ) - - # Remove all items from player - for item in inventory_items: - await api_client.remove_item_from_inventory(item['id'], item['quantity']) - - # Mark player as dead and end any combat - await api_client.update_player(player_id, {'is_dead': True, 'hp': 0}) - await api_client.end_combat(player_id) - - -async def use_item_in_combat(player_id: int, item_db_id: int) -> Tuple[str, bool]: - """ - Use a consumable item during combat. - Returns: (message, turn_ended) - """ - combat = await api_client.get_combat(player_id) - if not combat or combat['turn'] != 'player': - return ("It's not your turn!", False) - - item_data = await api_client.get_inventory_item(item_db_id) - if not item_data or item_data['player_id'] != player_id: - return ("You don't have that item!", False) - - item_def = ITEMS.get(item_data['item_id']) - if not item_def or item_def.get('type') != 'consumable': - return ("That item cannot be used in combat!", False) - - player = await api_client.get_player(player_id) - - # Apply consumable effects - message = f"๐Ÿ’Š Used {item_def['name']}!" - - hp_restore = item_def.get('hp_restore', 0) - stamina_restore = item_def.get('stamina_restore', 0) - - updates = {} - if hp_restore > 0: - new_hp = min(player['hp'] + hp_restore, player['max_hp']) - updates['hp'] = new_hp - message += f"\nโค๏ธ +{hp_restore} HP" - - if stamina_restore > 0: - new_stamina = min(player['stamina'] + stamina_restore, player['max_stamina']) - updates['stamina'] = new_stamina - message += f"\nโšก +{stamina_restore} Stamina" - - if updates: - await api_client.update_player(player_id, updates) - - # Remove item from inventory - if item_data['quantity'] > 1: - await api_client.update_inventory_item(item_db_id, item_data['quantity'] - 1) - else: - await api_client.remove_item_from_inventory(item_db_id, 1) - - # Using an item ends your turn - await api_client.update_combat(player_id, { - 'turn': 'npc', - 'turn_started_at': time.time() - }) - - return (message, True) diff --git a/old/bot/combat_handlers.py b/old/bot/combat_handlers.py deleted file mode 100644 index a529530..0000000 --- a/old/bot/combat_handlers.py +++ /dev/null @@ -1,165 +0,0 @@ -""" -Combat-related action handlers. -""" -import logging -from . import keyboards -from .api_client import api_client -from .utils import format_stat_bar -from data.world_loader import game_world - -logger = logging.getLogger(__name__) - - -async def handle_combat_attack(query, user_id: int, player: dict, data: list = None): - """Handle player attack action in combat.""" - from bot import combat - await query.answer() - - message, npc_died, turn_ended = await combat.player_attack(user_id) - - if npc_died: - # Combat ended - return to main menu - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=message, - reply_markup=keyboards.main_menu_keyboard(), - image_path=location_image - ) - elif turn_ended: - # NPC's turn - auto-attack - npc_message, player_died = await combat.npc_attack(user_id) - message += "\n\n" + npc_message - - if player_died: - from .handlers import send_or_edit_with_image - await send_or_edit_with_image(query, text=message, reply_markup=None) - else: - combat_data = await api_client.get_combat(user_id) - if combat_data: - from data.npcs import NPCS - npc_def = NPCS.get(combat_data['npc_id']) - keyboard = await keyboards.combat_keyboard(user_id) - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=message, - reply_markup=keyboard, - image_path=npc_def.image_url if npc_def else None - ) - else: - await query.answer(message, show_alert=False) - - -async def handle_combat_flee(query, user_id: int, player: dict, data: list = None): - """Handle flee attempt from combat.""" - from bot import combat - await query.answer() - - message, fled, turn_ended = await combat.flee_attempt(user_id) - - if fled: - # Successfully fled - return to main menu - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=message, - reply_markup=keyboards.main_menu_keyboard(), - image_path=location_image - ) - elif turn_ended: - # Failed to flee - NPC attacks - npc_message, player_died = await combat.npc_attack(user_id) - message += "\n\n" + npc_message - - if player_died: - from .handlers import send_or_edit_with_image - await send_or_edit_with_image(query, text=message, reply_markup=None) - else: - combat_data = await api_client.get_combat(user_id) - if combat_data: - from data.npcs import NPCS - npc_def = NPCS.get(combat_data['npc_id']) - keyboard = await keyboards.combat_keyboard(user_id) - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=message, - reply_markup=keyboard, - image_path=npc_def.image_url if npc_def else None - ) - else: - await query.answer(message, show_alert=False) - - -async def handle_combat_use_item_menu(query, user_id: int, player: dict, data: list = None): - """Show menu of usable items during combat.""" - await query.answer() - - -async def handle_combat_use_item(query, user_id: int, player: dict, data: list): - """Use an item during combat.""" - from bot import combat - item_db_id = int(data[1]) - - message, turn_ended = await combat.use_item_in_combat(user_id, item_db_id) - await query.answer(message, show_alert=False) - - if turn_ended: - # NPC's turn - npc_message, player_died = await combat.npc_attack(user_id) - - if player_died: - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=message + "\n\n" + npc_message, - reply_markup=None - ) - else: - combat_data = await api_client.get_combat(user_id) - if combat_data: - from data.npcs import NPCS - npc_def = NPCS.get(combat_data['npc_id']) - keyboard = await keyboards.combat_keyboard(user_id) - full_message = message + "\n\n" + npc_message + "\n\n๐ŸŽฏ Your turn!" - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=full_message, - reply_markup=keyboard, - image_path=npc_def.image_url if npc_def else None - ) - - -async def handle_combat_back(query, user_id: int, player: dict, data: list = None): - """Return to combat menu from item selection.""" - await query.answer() - combat_data = await api_client.get_combat(user_id) - - if combat_data: - from data.npcs import NPCS - npc_def = NPCS.get(combat_data['npc_id']) - keyboard = await keyboards.combat_keyboard(user_id) - - message = f"โš”๏ธ Combat with {npc_def.emoji} {npc_def.name}!\n" - message += format_stat_bar("Your HP", "โค๏ธ", player['hp'], player['max_hp']) + "\n" - message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n" - message += "๐ŸŽฏ Your turn!" if combat_data['turn'] == 'player' else "โณ Enemy's turn..." - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=message, - reply_markup=keyboard, - image_path=npc_def.image_url if npc_def else None - ) diff --git a/old/bot/commands.py b/old/bot/commands.py deleted file mode 100644 index dbd2974..0000000 --- a/old/bot/commands.py +++ /dev/null @@ -1,109 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Command handlers for the Telegram bot. -Handles slash commands like /start, /export_map, /spawn_stats. -""" -import logging -import os -import json -from io import BytesIO -from telegram import Update -from telegram.ext import ContextTypes -from . import keyboards -from .api_client import api_client -from .utils import admin_only -from .action_handlers import get_player_status_text -from data.world_loader import game_world - -logger = logging.getLogger(__name__) - - -async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle /start command - initialize or show player status.""" - from .api_client import api_client - - user = update.effective_user - player = await api_client.get_player(user.id) - - if not player: - player = await api_client.create_player(user.id, user.first_name) - await update.message.reply_html( - f"Welcome, {user.mention_html()}! Your story is just beginning." - ) - - # Get player status and location image - player = await api_client.get_player(user.id) - status_text = await get_player_status_text(user.id) - location = game_world.get_location(player['location_id']) - - # Send with image if available - if location and location.image_path: - cached_file_id = await api_client.get_cached_image(location.image_path) - if cached_file_id: - await update.message.reply_photo( - photo=cached_file_id, - caption=status_text, - reply_markup=keyboards.main_menu_keyboard(), - parse_mode='HTML' - ) - elif os.path.exists(location.image_path): - with open(location.image_path, 'rb') as img_file: - msg = await update.message.reply_photo( - photo=img_file, - caption=status_text, - reply_markup=keyboards.main_menu_keyboard(), - parse_mode='HTML' - ) - if msg.photo: - await api_client.cache_image(location.image_path, msg.photo[-1].file_id) - else: - await update.message.reply_html( - status_text, - reply_markup=keyboards.main_menu_keyboard() - ) - else: - await update.message.reply_html( - status_text, - reply_markup=keyboards.main_menu_keyboard() - ) - - -@admin_only -async def export_map(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Export map data as JSON for external visualization.""" - from data.world_loader import export_map_data - - map_data = export_map_data() - json_str = json.dumps(map_data, indent=2) - - # Send as text file - file = BytesIO(json_str.encode('utf-8')) - file.name = "map_data.json" - - await update.message.reply_document( - document=file, - filename="map_data.json", - caption="๐Ÿ—บ๏ธ Game Map Data\n\nThis JSON file contains all locations, coordinates, and connections.\nYou can use it to visualize the game map in external tools." - ) - - -@admin_only -async def spawn_stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Show wandering enemy spawn statistics (debug command).""" - from bot.spawn_manager import get_spawn_stats - - stats = await get_spawn_stats() - - text = "๐Ÿ“Š Wandering Enemy Statistics\n\n" - text += f"Total Active Enemies: {stats['total_active']}\n\n" - - if stats['by_location']: - text += "Enemies by Location:\n" - for loc_id, count in stats['by_location'].items(): - location = game_world.get_location(loc_id) - loc_name = location.name if location else loc_id - text += f"โ€ข {loc_name}: {count}\n" - else: - text += "No wandering enemies currently active." - - await update.message.reply_html(text) diff --git a/old/bot/corpse_handlers.py b/old/bot/corpse_handlers.py deleted file mode 100644 index 6fdd205..0000000 --- a/old/bot/corpse_handlers.py +++ /dev/null @@ -1,235 +0,0 @@ -""" -Corpse looting handlers (player and NPC corpses). -""" -import logging -import json -import random -from . import keyboards, logic -from .api_client import api_client -from data.world_loader import game_world -from data.items import ITEMS - -logger = logging.getLogger(__name__) - - -async def handle_loot_player_corpse(query, user_id: int, player: dict, data: list): - """Show player corpse loot menu.""" - corpse_id = int(data[1]) - corpse = await api_client.get_player_corpse(corpse_id) - - if not corpse: - await query.answer("Corpse not found.", show_alert=False) - return - - items = json.loads(corpse['items']) - keyboard = keyboards.player_corpse_loot_keyboard(corpse_id, items) - - location = game_world.get_location(player['location_id']) - image_path = location.image_path if location else None - - await query.answer() - text = f"๐ŸŽ’ {corpse['player_name']}'s bag\n\nYou find the remains of another survivor..." - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboard, - image_path=image_path - ) - - -async def handle_take_corpse_item(query, user_id: int, player: dict, data: list): - """Take an item from a player corpse.""" - corpse_id = int(data[1]) - item_index = int(data[2]) - - corpse = await api_client.get_player_corpse(corpse_id) - if not corpse: - await query.answer("Corpse not found.", show_alert=False) - return - - items = json.loads(corpse['items']) - if item_index >= len(items): - await query.answer("Item not found.", show_alert=False) - return - - item_data = items[item_index] - item_def = ITEMS.get(item_data['item_id'], {}) - - # Check inventory capacity - can_add, reason = await logic.can_add_item_to_inventory( - user_id, item_data['item_id'], item_data['quantity'] - ) - - if not can_add: - await query.answer(reason, show_alert=False) - return - - # Add to inventory - await api_client.add_item_to_inventory(user_id, item_data['item_id'], item_data['quantity']) - - # Remove from corpse - items.pop(item_index) - - if items: - await api_client.update_player_corpse(corpse_id, json.dumps(items)) - keyboard = keyboards.player_corpse_loot_keyboard(corpse_id, items) - - location = game_world.get_location(player['location_id']) - image_path = location.image_path if location else None - - await query.answer(f"Took {item_def.get('name', 'Unknown')}.", show_alert=False) - text = f"๐ŸŽ’ {corpse['player_name']}'s bag" - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboard, - image_path=image_path - ) - else: - # Bag is empty, remove it - await api_client.remove_player_corpse(corpse_id) - await query.answer( - f"Took {item_def.get('name', 'Unknown')}. The bag is now empty.", - show_alert=False - ) - - location = game_world.get_location(player['location_id']) - dropped_items = await api_client.get_dropped_items_in_location(player['location_id']) - wandering_enemies = await api_client.get_wandering_enemies_in_location(player['location_id']) - keyboard = await keyboards.inspect_keyboard(player['location_id'], dropped_items, wandering_enemies) - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text="You scan the area. You notice...", - reply_markup=keyboard, - image_path=location.image_path if location else None - ) - - -async def handle_scavenge_npc_corpse(query, user_id: int, player: dict, data: list): - """Show NPC corpse scavenging menu.""" - corpse_id = int(data[1]) - corpse = await api_client.get_npc_corpse(corpse_id) - - if not corpse: - await query.answer("Corpse not found.", show_alert=False) - return - - from data.npcs import NPCS - npc_def = NPCS.get(corpse['npc_id']) - loot_items = json.loads(corpse['loot_remaining']) - keyboard = keyboards.npc_corpse_scavenge_keyboard(corpse_id, loot_items) - - location = game_world.get_location(player['location_id']) - image_path = location.image_path if location else None - - await query.answer() - text = f"๐Ÿ”ช {npc_def.emoji} {npc_def.name} Corpse\n\n{npc_def.description}" - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboard, - image_path=image_path - ) - - -async def handle_scavenge_corpse_item(query, user_id: int, player: dict, data: list): - """Scavenge a specific item from an NPC corpse.""" - corpse_id = int(data[1]) - loot_index = int(data[2]) - - corpse = await api_client.get_npc_corpse(corpse_id) - if not corpse: - await query.answer("Corpse not found.", show_alert=False) - return - - loot_items = json.loads(corpse['loot_remaining']) - if loot_index >= len(loot_items): - await query.answer("Nothing to scavenge here.", show_alert=False) - return - - loot_data = loot_items[loot_index] - required_tool = loot_data.get('required_tool') - - # Check if player has required tool - if required_tool: - inventory_items = await api_client.get_inventory(user_id) - has_tool = any(item['item_id'] == required_tool for item in inventory_items) - - if not has_tool: - tool_def = ITEMS.get(required_tool, {}) - await query.answer( - f"You need a {tool_def.get('name', 'tool')} to scavenge this.", - show_alert=False - ) - return - - # Determine quantity - quantity = random.randint(loot_data['quantity_min'], loot_data['quantity_max']) - item_def = ITEMS.get(loot_data['item_id'], {}) - - # Check inventory capacity - can_add, reason = await logic.can_add_item_to_inventory( - user_id, loot_data['item_id'], quantity - ) - - if not can_add: - await query.answer(reason, show_alert=False) - return - - # Add to inventory - await api_client.add_item_to_inventory(user_id, loot_data['item_id'], quantity) - - # Remove from corpse - loot_items.pop(loot_index) - - if loot_items: - await api_client.update_npc_corpse(corpse_id, json.dumps(loot_items)) - keyboard = keyboards.npc_corpse_scavenge_keyboard(corpse_id, loot_items) - - location = game_world.get_location(player['location_id']) - image_path = location.image_path if location else None - - await query.answer( - f"Scavenged {quantity}x {item_def.get('name', 'Unknown')}.", - show_alert=False - ) - - from data.npcs import NPCS - npc_def = NPCS.get(corpse['npc_id']) - text = f"๐Ÿ”ช {npc_def.emoji} {npc_def.name} Corpse" - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboard, - image_path=image_path - ) - else: - # Nothing left, remove corpse - await api_client.remove_npc_corpse(corpse_id) - await query.answer( - f"Scavenged {quantity}x {item_def.get('name', 'Unknown')}. Nothing left on the corpse.", - show_alert=False - ) - - location = game_world.get_location(player['location_id']) - dropped_items = await api_client.get_dropped_items_in_location(player['location_id']) - wandering_enemies = await api_client.get_wandering_enemies_in_location(player['location_id']) - keyboard = await keyboards.inspect_keyboard(player['location_id'], dropped_items, wandering_enemies) - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text="You scan the area. You notice...", - reply_markup=keyboard, - image_path=location.image_path if location else None - ) diff --git a/old/bot/database.py b/old/bot/database.py deleted file mode 100644 index ebbbcca..0000000 --- a/old/bot/database.py +++ /dev/null @@ -1,729 +0,0 @@ -import time -import os -from typing import Set -from sqlalchemy.ext.asyncio import create_async_engine -from sqlalchemy import ( - MetaData, Table, Column, Integer, String, Boolean, ForeignKey, Float, UniqueConstraint, -) - -DB_USER, DB_PASS, DB_NAME, DB_HOST, DB_PORT = os.getenv("POSTGRES_USER"), os.getenv("POSTGRES_PASSWORD"), os.getenv("POSTGRES_DB"), os.getenv("POSTGRES_HOST"), os.getenv("POSTGRES_PORT") -DATABASE_URL = f"postgresql+psycopg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}" -engine = create_async_engine(DATABASE_URL) -metadata = MetaData() - -# ... (players, inventory, dropped_items tables are unchanged) ... -players = Table( - "players", - metadata, - Column("telegram_id", Integer, primary_key=True), - Column("id", Integer, unique=True, autoincrement=True), # Web users ID - Column("username", String(50), unique=True, nullable=True), # Web users username - Column("password_hash", String(255), nullable=True), # Web users password hash - Column("name", String, default="Survivor"), - Column("hp", Integer, default=100), - Column("max_hp", Integer, default=100), - Column("stamina", Integer, default=20), - Column("max_stamina", Integer, default=20), - Column("strength", Integer, default=5), - Column("agility", Integer, default=5), - Column("endurance", Integer, default=5), - Column("intellect", Integer, default=5), - Column("location_id", String, default="start_point"), - Column("is_dead", Boolean, default=False), - Column("level", Integer, default=1), - Column("xp", Integer, default=0), - Column("unspent_points", Integer, default=0) -) -inventory = Table("inventory", metadata, Column("id", Integer, primary_key=True, autoincrement=True), Column("player_id", Integer, ForeignKey("players.telegram_id", ondelete="CASCADE")), Column("item_id", String), Column("quantity", Integer, default=1), Column("is_equipped", Boolean, default=False)) -dropped_items = Table("dropped_items", metadata, Column("id", Integer, primary_key=True, autoincrement=True), Column("item_id", String), Column("quantity", Integer, default=1), Column("location_id", String), Column("drop_timestamp", Float)) - -# Combat-related tables -active_combats = Table( - "active_combats", - metadata, - Column("id", Integer, primary_key=True, autoincrement=True), - Column("player_id", Integer, ForeignKey("players.telegram_id", ondelete="CASCADE"), unique=True), - Column("npc_id", String, nullable=False), - Column("npc_hp", Integer, nullable=False), - Column("npc_max_hp", Integer, nullable=False), - Column("turn", String, nullable=False), # "player" or "npc" - Column("turn_started_at", Float, nullable=False), - Column("player_status_effects", String, default=""), # JSON string - Column("npc_status_effects", String, default=""), # JSON string - Column("location_id", String, nullable=False), - Column("from_wandering_enemy", Boolean, default=False), # If True, respawn on flee/death -) - -player_corpses = Table( - "player_corpses", - metadata, - Column("id", Integer, primary_key=True, autoincrement=True), - Column("player_name", String, nullable=False), - Column("location_id", String, nullable=False), - Column("items", String, nullable=False), # JSON string of items - Column("death_timestamp", Float, nullable=False), -) - -npc_corpses = Table( - "npc_corpses", - metadata, - Column("id", Integer, primary_key=True, autoincrement=True), - Column("npc_id", String, nullable=False), - Column("location_id", String, nullable=False), - Column("loot_remaining", String, nullable=False), # JSON string - Column("death_timestamp", Float, nullable=False), -) - -interactable_cooldowns = Table( - "interactable_cooldowns", - metadata, - Column("id", Integer, primary_key=True, autoincrement=True), - Column("interactable_instance_id", String, nullable=False, unique=True), # Renamed for clarity - Column("expiry_timestamp", Float, nullable=False), -) - -# Table to cache Telegram file IDs for images -image_cache = Table( - "image_cache", - metadata, - Column("id", Integer, primary_key=True, autoincrement=True), - Column("image_path", String, nullable=False, unique=True), # Local file path - Column("telegram_file_id", String, nullable=False), # Telegram's file_id for reuse - Column("uploaded_at", Float, nullable=False), -) - -# Wandering enemies table - managed by spawn system -wandering_enemies = Table( - "wandering_enemies", - metadata, - Column("id", Integer, primary_key=True, autoincrement=True), - Column("npc_id", String, nullable=False), - Column("location_id", String, nullable=False), - Column("spawn_timestamp", Float, nullable=False), - Column("despawn_timestamp", Float, nullable=False), # When this enemy should despawn -) - -# Persistent status effects table -player_status_effects = Table( - "player_status_effects", - metadata, - Column("id", Integer, primary_key=True, autoincrement=True), - Column("player_id", Integer, ForeignKey("players.telegram_id", ondelete="CASCADE"), nullable=False), - Column("effect_name", String(50), nullable=False), - Column("effect_icon", String(10), nullable=False), - Column("damage_per_tick", Integer, nullable=False, default=0), - Column("ticks_remaining", Integer, nullable=False), - Column("applied_at", Float, nullable=False), -) - -async def create_tables(): - async with engine.begin() as conn: - await conn.run_sync(metadata.create_all) - -# ... (All other database functions are unchanged except the cooldown ones) ... -async def get_player(telegram_id: int = None, player_id: int = None, username: str = None): - """Get player by telegram_id, player_id (web users), or username.""" - async with engine.connect() as conn: - if telegram_id is not None: - result = await conn.execute(players.select().where(players.c.telegram_id == telegram_id)) - elif player_id is not None: - result = await conn.execute(players.select().where(players.c.id == player_id)) - elif username is not None: - result = await conn.execute(players.select().where(players.c.username == username)) - else: - return None - row = result.first() - return row._asdict() if row else None - -async def create_player(telegram_id: int = None, name: str = "Survivor", username: str = None, password_hash: str = None): - """Create a player (Telegram or web user).""" - async with engine.connect() as conn: - values = { - "name": name, - "telegram_id": telegram_id, - "username": username, - "password_hash": password_hash, - } - result = await conn.execute(players.insert().values(**values)) - await conn.commit() - - # For telegram users, the primary key is telegram_id - # For web users, we need to get the auto-generated id - if telegram_id: - # Add starting inventory for Telegram users - await conn.execute(inventory.insert().values(player_id=telegram_id, item_id="tattered_rucksack", is_equipped=True)) - await conn.commit() - - # Return the created player - if telegram_id: - return await get_player(telegram_id=telegram_id) - elif username: - return await get_player(username=username) - -async def update_player(telegram_id: int = None, player_id: int = None, updates: dict = None): - """Update player by telegram_id (Telegram users) or player_id (web users).""" - if updates is None: - updates = {} - async with engine.connect() as conn: - if telegram_id is not None: - await conn.execute(players.update().where(players.c.telegram_id == telegram_id).values(**updates)) - elif player_id is not None: - await conn.execute(players.update().where(players.c.id == player_id).values(**updates)) - else: - raise ValueError("Must provide either telegram_id or player_id") - await conn.commit() -async def get_inventory(player_id: int): - async with engine.connect() as conn: - result = await conn.execute(inventory.select().where(inventory.c.player_id == player_id)) - return [row._asdict() for row in result.fetchall()] -async def get_inventory_item(item_db_id: int): - async with engine.connect() as conn: - result = await conn.execute(inventory.select().where(inventory.c.id == item_db_id)) - row = result.first() - return row._asdict() if row else None -async def add_item_to_inventory(player_id: int, item_id: str, quantity: int = 1): - async with engine.connect() as conn: - result = await conn.execute(inventory.select().where(inventory.c.player_id == player_id, inventory.c.item_id == item_id)) - existing_item = result.first() - if existing_item: stmt = inventory.update().where(inventory.c.id == existing_item.id).values(quantity=inventory.c.quantity + quantity) - else: stmt = inventory.insert().values(player_id=player_id, item_id=item_id, quantity=quantity) - await conn.execute(stmt) - await conn.commit() - -async def add_equipped_item_to_inventory(player_id: int, item_id: str) -> int: - """Add a single equipped item to inventory and return its ID.""" - async with engine.connect() as conn: - stmt = inventory.insert().values( - player_id=player_id, - item_id=item_id, - quantity=1, - is_equipped=True - ) - result = await conn.execute(stmt) - await conn.commit() - return result.inserted_primary_key[0] - -async def update_inventory_item(item_db_id: int, quantity: int = None, is_equipped: bool = None): - """Update inventory item properties.""" - async with engine.connect() as conn: - updates = {} - if quantity is not None: - updates['quantity'] = quantity - if is_equipped is not None: - updates['is_equipped'] = is_equipped - - if updates: - stmt = inventory.update().where(inventory.c.id == item_db_id).values(**updates) - await conn.execute(stmt) - await conn.commit() - -async def remove_item_from_inventory(item_db_id: int, quantity: int = 1): - async with engine.connect() as conn: - result = await conn.execute(inventory.select().where(inventory.c.id == item_db_id)) - item_data = result.first() - if not item_data: return - if item_data.quantity > quantity: stmt = inventory.update().where(inventory.c.id == item_db_id).values(quantity=inventory.c.quantity - quantity) - else: stmt = inventory.delete().where(inventory.c.id == item_db_id) - await conn.execute(stmt) - await conn.commit() -async def drop_item_to_world(item_id: str, quantity: int, location_id: str): - """Drop item to world. Combines with existing stacks of same item in same location.""" - async with engine.connect() as conn: - # Check if this item already exists in this location - result = await conn.execute( - dropped_items.select().where( - (dropped_items.c.item_id == item_id) & - (dropped_items.c.location_id == location_id) - ) - ) - existing_item = result.first() - - if existing_item: - # Stack exists, add to it - new_quantity = existing_item.quantity + quantity - stmt = dropped_items.update().where(dropped_items.c.id == existing_item.id).values( - quantity=new_quantity, - drop_timestamp=time.time() # Update timestamp - ) - else: - # Create new stack - stmt = dropped_items.insert().values( - item_id=item_id, - quantity=quantity, - location_id=location_id, - drop_timestamp=time.time() - ) - - await conn.execute(stmt) - await conn.commit() -async def get_dropped_items_in_location(location_id: str): - async with engine.connect() as conn: - result = await conn.execute(dropped_items.select().where(dropped_items.c.location_id == location_id).limit(10)) - return [row._asdict() for row in result.fetchall()] -async def get_dropped_item(dropped_item_id: int): - async with engine.connect() as conn: - result = await conn.execute(dropped_items.select().where(dropped_items.c.id == dropped_item_id)) - row = result.first() - return row._asdict() if row else None -async def remove_dropped_item(dropped_item_id: int): - async with engine.connect() as conn: - await conn.execute(dropped_items.delete().where(dropped_items.c.id == dropped_item_id)) - await conn.commit() - -async def update_dropped_item(dropped_item_id: int, new_quantity: int): - """Update the quantity of a dropped item.""" - async with engine.connect() as conn: - stmt = dropped_items.update().where(dropped_items.c.id == dropped_item_id).values(quantity=new_quantity) - await conn.execute(stmt) - await conn.commit() - -async def remove_expired_dropped_items(timestamp_limit: float) -> int: - async with engine.connect() as conn: - stmt = dropped_items.delete().where(dropped_items.c.drop_timestamp < timestamp_limit) - result = await conn.execute(stmt) - await conn.commit() - return result.rowcount - -async def regenerate_all_players_stamina() -> int: - """ - Regenerate stamina for all active players using a single optimized query. - - Recovery formula: - - Base recovery: 1 stamina per cycle (5 minutes) - - Endurance bonus: +1 stamina per 10 endurance points - - Example: 5 endurance = 1 stamina, 15 endurance = 2 stamina, 25 endurance = 3 stamina - - Only regenerates up to max_stamina - - Only regenerates for living players - - PERFORMANCE: Single SQL query, scales to 100K+ players efficiently. - """ - from sqlalchemy import text - - async with engine.connect() as conn: - # Single UPDATE query with database-side calculation - # Much more efficient than fetching all players and updating individually - stmt = text(""" - UPDATE players - SET stamina = LEAST( - stamina + 1 + (endurance / 10), - max_stamina - ) - WHERE is_dead = FALSE - AND stamina < max_stamina - """) - - result = await conn.execute(stmt) - await conn.commit() - return result.rowcount - -COOLDOWN_DURATION = 300 -async def set_cooldown(instance_id: str): - expiry_time = time.time() + COOLDOWN_DURATION - async with engine.connect() as conn: - update_stmt = interactable_cooldowns.update().where(interactable_cooldowns.c.interactable_instance_id == instance_id).values(expiry_timestamp=expiry_time) - result = await conn.execute(update_stmt) - if result.rowcount == 0: - insert_stmt = interactable_cooldowns.insert().values(interactable_instance_id=instance_id, expiry_timestamp=expiry_time) - await conn.execute(insert_stmt) - await conn.commit() - -# --- Combat Functions --- -async def create_combat(player_id: int, npc_id: str, npc_hp: int, npc_max_hp: int, location_id: str, from_wandering_enemy: bool = False): - """Start a new combat encounter.""" - async with engine.connect() as conn: - stmt = active_combats.insert().values( - player_id=player_id, - npc_id=npc_id, - npc_hp=npc_hp, - npc_max_hp=npc_max_hp, - turn="player", - turn_started_at=time.time(), - location_id=location_id, - player_status_effects="[]", - npc_status_effects="[]", - from_wandering_enemy=from_wandering_enemy - ) - result = await conn.execute(stmt) - await conn.commit() - return result.inserted_primary_key[0] - -async def get_combat(player_id: int): - """Get active combat for a player.""" - async with engine.connect() as conn: - stmt = active_combats.select().where(active_combats.c.player_id == player_id) - result = await conn.execute(stmt) - row = result.first() - return row._asdict() if row else None - -async def update_combat(player_id: int, updates: dict): - """Update combat state.""" - async with engine.connect() as conn: - stmt = active_combats.update().where(active_combats.c.player_id == player_id).values(**updates) - await conn.execute(stmt) - await conn.commit() - -async def end_combat(player_id: int): - """Remove active combat.""" - async with engine.connect() as conn: - stmt = active_combats.delete().where(active_combats.c.player_id == player_id) - await conn.execute(stmt) - await conn.commit() - -async def get_all_idle_combats(idle_threshold: float): - """Get all combats where the turn has been idle too long.""" - async with engine.connect() as conn: - stmt = active_combats.select().where(active_combats.c.turn_started_at < idle_threshold) - result = await conn.execute(stmt) - return [row._asdict() for row in result.fetchall()] - -async def create_player_corpse(player_name: str, location_id: str, items: str): - """Create a player corpse bag.""" - async with engine.connect() as conn: - stmt = player_corpses.insert().values( - player_name=player_name, - location_id=location_id, - items=items, - death_timestamp=time.time() - ) - await conn.execute(stmt) - await conn.commit() - -async def get_player_corpses_in_location(location_id: str): - """Get all player corpses in a location.""" - async with engine.connect() as conn: - stmt = player_corpses.select().where(player_corpses.c.location_id == location_id) - result = await conn.execute(stmt) - return [row._asdict() for row in result.fetchall()] - -async def get_player_corpse(corpse_id: int): - """Get a specific player corpse.""" - async with engine.connect() as conn: - stmt = player_corpses.select().where(player_corpses.c.id == corpse_id) - result = await conn.execute(stmt) - row = result.first() - return row._asdict() if row else None - -async def update_player_corpse(corpse_id: int, items: str): - """Update items in a player corpse.""" - async with engine.connect() as conn: - stmt = player_corpses.update().where(player_corpses.c.id == corpse_id).values(items=items) - await conn.execute(stmt) - await conn.commit() - -async def remove_player_corpse(corpse_id: int): - """Remove a player corpse.""" - async with engine.connect() as conn: - stmt = player_corpses.delete().where(player_corpses.c.id == corpse_id) - await conn.execute(stmt) - await conn.commit() - -async def remove_expired_player_corpses(timestamp_limit: float) -> int: - """Remove old player corpses.""" - async with engine.connect() as conn: - stmt = player_corpses.delete().where(player_corpses.c.death_timestamp < timestamp_limit) - result = await conn.execute(stmt) - await conn.commit() - return result.rowcount - -async def create_npc_corpse(npc_id: str, location_id: str, loot_remaining: str): - """Create an NPC corpse for scavenging.""" - async with engine.connect() as conn: - stmt = npc_corpses.insert().values( - npc_id=npc_id, - location_id=location_id, - loot_remaining=loot_remaining, - death_timestamp=time.time() - ) - result = await conn.execute(stmt) - await conn.commit() - return result.inserted_primary_key[0] - -async def get_npc_corpses_in_location(location_id: str): - """Get all NPC corpses in a location.""" - async with engine.connect() as conn: - stmt = npc_corpses.select().where(npc_corpses.c.location_id == location_id) - result = await conn.execute(stmt) - return [row._asdict() for row in result.fetchall()] - -async def get_npc_corpse(corpse_id: int): - """Get a specific NPC corpse.""" - async with engine.connect() as conn: - stmt = npc_corpses.select().where(npc_corpses.c.id == corpse_id) - result = await conn.execute(stmt) - row = result.first() - return row._asdict() if row else None - -async def update_npc_corpse(corpse_id: int, loot_remaining: str): - """Update loot in an NPC corpse.""" - async with engine.connect() as conn: - stmt = npc_corpses.update().where(npc_corpses.c.id == corpse_id).values(loot_remaining=loot_remaining) - await conn.execute(stmt) - await conn.commit() - -async def remove_npc_corpse(corpse_id: int): - """Remove an NPC corpse.""" - async with engine.connect() as conn: - stmt = npc_corpses.delete().where(npc_corpses.c.id == corpse_id) - await conn.execute(stmt) - await conn.commit() - -async def remove_expired_npc_corpses(timestamp_limit: float) -> int: - """Remove old NPC corpses.""" - async with engine.connect() as conn: - stmt = npc_corpses.delete().where(npc_corpses.c.death_timestamp < timestamp_limit) - result = await conn.execute(stmt) - await conn.commit() - return result.rowcount - -async def get_cooldown(instance_id: str) -> int: - async with engine.connect() as conn: - stmt = interactable_cooldowns.select().where(interactable_cooldowns.c.interactable_instance_id == instance_id) - result = await conn.execute(stmt) - cooldown = result.first() - if cooldown and cooldown.expiry_timestamp > time.time(): - return int(cooldown.expiry_timestamp - time.time()) - return 0 - -async def get_cooldowns_for_location(location_id: str) -> Set[str]: - """Get all active cooldown instance IDs for a location by checking the prefix.""" - async with engine.connect() as conn: - stmt = interactable_cooldowns.select().where( - interactable_cooldowns.c.interactable_instance_id.startswith(location_id + "_"), - interactable_cooldowns.c.expiry_timestamp > time.time() - ) - result = await conn.execute(stmt) - return {row.interactable_instance_id for row in result.fetchall()} - -# --- Image Cache Functions --- -async def get_cached_image(image_path: str): - """Get the Telegram file_id for a cached image.""" - async with engine.connect() as conn: - stmt = image_cache.select().where(image_cache.c.image_path == image_path) - result = await conn.execute(stmt) - row = result.first() - return row.telegram_file_id if row else None - -async def cache_image(image_path: str, telegram_file_id: str): - """Store a Telegram file_id for an image path.""" - async with engine.connect() as conn: - # Check if already exists - stmt = image_cache.select().where(image_cache.c.image_path == image_path) - result = await conn.execute(stmt) - existing = result.first() - - if existing: - # Update existing entry - update_stmt = image_cache.update().where( - image_cache.c.image_path == image_path - ).values(telegram_file_id=telegram_file_id, uploaded_at=time.time()) - await conn.execute(update_stmt) - else: - # Insert new entry - insert_stmt = image_cache.insert().values( - image_path=image_path, - telegram_file_id=telegram_file_id, - uploaded_at=time.time() - ) - await conn.execute(insert_stmt) - await conn.commit() - - -# --- Wandering Enemies Functions --- -async def spawn_wandering_enemy(npc_id: str, location_id: str, lifetime_seconds: int = 600): - """Spawn a wandering enemy at a location. Lifetime defaults to 10 minutes.""" - async with engine.connect() as conn: - current_time = time.time() - despawn_time = current_time + lifetime_seconds - - await conn.execute(wandering_enemies.insert().values( - npc_id=npc_id, - location_id=location_id, - spawn_timestamp=current_time, - despawn_timestamp=despawn_time - )) - await conn.commit() - - -async def get_wandering_enemies_in_location(location_id: str): - """Get all active wandering enemies at a location.""" - async with engine.connect() as conn: - current_time = time.time() - stmt = wandering_enemies.select().where( - wandering_enemies.c.location_id == location_id, - wandering_enemies.c.despawn_timestamp > current_time - ) - result = await conn.execute(stmt) - return [row._asdict() for row in result.fetchall()] - - -async def remove_wandering_enemy(enemy_id: int): - """Remove a wandering enemy (when engaged in combat or manually despawned).""" - async with engine.connect() as conn: - await conn.execute(wandering_enemies.delete().where(wandering_enemies.c.id == enemy_id)) - await conn.commit() - - -async def cleanup_expired_wandering_enemies(): - """Remove all expired wandering enemies.""" - async with engine.connect() as conn: - current_time = time.time() - result = await conn.execute( - wandering_enemies.delete().where(wandering_enemies.c.despawn_timestamp <= current_time) - ) - await conn.commit() - return result.rowcount # Number of enemies despawned - - -async def get_wandering_enemy_count_in_location(location_id: str) -> int: - """Count active wandering enemies at a location.""" - async with engine.connect() as conn: - current_time = time.time() - from sqlalchemy import func - stmt = wandering_enemies.select().where( - wandering_enemies.c.location_id == location_id, - wandering_enemies.c.despawn_timestamp > current_time - ) - result = await conn.execute(stmt) - return len(result.fetchall()) - - -async def get_all_active_wandering_enemies(): - """Get all active wandering enemies across all locations.""" - async with engine.connect() as conn: - current_time = time.time() - stmt = wandering_enemies.select().where( - wandering_enemies.c.despawn_timestamp > current_time - ) - result = await conn.execute(stmt) - return [row._asdict() for row in result.fetchall()] - - -# ============================================================================ -# STATUS EFFECTS -# ============================================================================ - -async def get_player_status_effects(player_id: int): - """Get all active status effects for a player.""" - async with engine.connect() as conn: - stmt = player_status_effects.select().where( - player_status_effects.c.player_id == player_id, - player_status_effects.c.ticks_remaining > 0 - ) - result = await conn.execute(stmt) - return [row._asdict() for row in result.fetchall()] - - -async def add_status_effect(player_id: int, effect_name: str, effect_icon: str, - damage_per_tick: int, ticks_remaining: int): - """Add a new status effect to a player.""" - async with engine.connect() as conn: - await conn.execute( - player_status_effects.insert().values( - player_id=player_id, - effect_name=effect_name, - effect_icon=effect_icon, - damage_per_tick=damage_per_tick, - ticks_remaining=ticks_remaining, - applied_at=time.time() - ) - ) - await conn.commit() - - -async def update_status_effect_ticks(effect_id: int, ticks_remaining: int): - """Update the remaining ticks for a status effect.""" - async with engine.connect() as conn: - await conn.execute( - player_status_effects.update().where( - player_status_effects.c.id == effect_id - ).values(ticks_remaining=ticks_remaining) - ) - await conn.commit() - - -async def remove_status_effect(effect_id: int): - """Remove a specific status effect.""" - async with engine.connect() as conn: - await conn.execute( - player_status_effects.delete().where(player_status_effects.c.id == effect_id) - ) - await conn.commit() - - -async def remove_all_status_effects(player_id: int): - """Remove all status effects from a player.""" - async with engine.connect() as conn: - await conn.execute( - player_status_effects.delete().where(player_status_effects.c.player_id == player_id) - ) - await conn.commit() - - -async def remove_status_effects_by_name(player_id: int, effect_name: str, count: int = 1): - """ - Remove a specific number of status effects by name for a player. - Used for treatment items that cure specific effects. - Returns the number of effects actually removed. - """ - async with engine.connect() as conn: - # Get the effects to remove - stmt = player_status_effects.select().where( - player_status_effects.c.player_id == player_id, - player_status_effects.c.effect_name == effect_name, - player_status_effects.c.ticks_remaining > 0 - ).limit(count) - result = await conn.execute(stmt) - effects_to_remove = result.fetchall() - - # Remove them - effect_ids = [row.id for row in effects_to_remove] - if effect_ids: - await conn.execute( - player_status_effects.delete().where( - player_status_effects.c.id.in_(effect_ids) - ) - ) - await conn.commit() - - return len(effect_ids) - - -async def get_all_players_with_status_effects(): - """Get all player IDs that have active status effects (for background processing).""" - async with engine.connect() as conn: - from sqlalchemy import distinct - stmt = player_status_effects.select().with_only_columns( - distinct(player_status_effects.c.player_id) - ).where(player_status_effects.c.ticks_remaining > 0) - result = await conn.execute(stmt) - return [row[0] for row in result.fetchall()] - - -async def decrement_all_status_effect_ticks(): - """ - Decrement ticks for all active status effects and return affected player IDs. - Used by background processor. - """ - async with engine.connect() as conn: - # Get player IDs with effects before updating - from sqlalchemy import distinct - stmt = player_status_effects.select().with_only_columns( - distinct(player_status_effects.c.player_id) - ).where(player_status_effects.c.ticks_remaining > 0) - result = await conn.execute(stmt) - affected_players = [row[0] for row in result.fetchall()] - - # Decrement ticks - await conn.execute( - player_status_effects.update().where( - player_status_effects.c.ticks_remaining > 0 - ).values(ticks_remaining=player_status_effects.c.ticks_remaining - 1) - ) - - # Remove expired effects - await conn.execute( - player_status_effects.delete().where(player_status_effects.c.ticks_remaining <= 0) - ) - - await conn.commit() - return affected_players diff --git a/old/bot/handlers.py b/old/bot/handlers.py deleted file mode 100644 index 6ee39db..0000000 --- a/old/bot/handlers.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -Main handlers for the Telegram bot. -This module contains the core button callback routing. -All other functionality is organized in separate modules: -- action_handlers.py - World interaction handlers -- inventory_handlers.py - Inventory management -- combat_handlers.py - Combat actions -- profile_handlers.py - Character stats -- corpse_handlers.py - Looting system -- pickup_handlers.py - Item collection -- message_utils.py - Message sending/editing utilities -- commands.py - Slash command handlers -""" -import logging -from telegram import Update -from telegram.ext import ContextTypes -from .message_utils import send_or_edit_with_image - -# Import organized action handlers -from .action_handlers import ( - handle_inspect_area, - handle_attack_wandering, - handle_inspect_interactable, - handle_action, - handle_main_menu, - handle_move_menu, - handle_move -) -from .inventory_handlers import ( - handle_inventory_menu, - handle_inventory_item, - handle_inventory_use, - handle_inventory_drop, - handle_inventory_equip, - handle_inventory_unequip -) -from .pickup_handlers import ( - handle_pickup_menu, - handle_pickup -) -from .combat_handlers import ( - handle_combat_attack, - handle_combat_flee, - handle_combat_use_item_menu, - handle_combat_use_item, - handle_combat_back -) -from .profile_handlers import ( - handle_profile, - handle_spend_points_menu, - handle_spend_point -) -from .corpse_handlers import ( - handle_loot_player_corpse, - handle_take_corpse_item, - handle_scavenge_npc_corpse, - handle_scavenge_corpse_item -) - -# Import command handlers (for main.py to register) -from .commands import start, export_map, spawn_stats - -logger = logging.getLogger(__name__) - - -# ============================================================================ -# HANDLER REGISTRY -# ============================================================================ - -# Map of action types to their handler functions -# All handlers have signature: async def handle_*(query, user_id, player, data=None) -HANDLER_MAP = { - # Inspection & World Interaction - 'inspect_area': handle_inspect_area, - 'inspect_area_menu': handle_inspect_area, - 'attack_wandering': handle_attack_wandering, - 'inspect': handle_inspect_interactable, - 'action': handle_action, - - # Navigation & Menu - 'main_menu': handle_main_menu, - 'move_menu': handle_move_menu, - 'move': handle_move, - - # Profile & Stats - 'profile': handle_profile, - 'spend_points_menu': handle_spend_points_menu, - 'spend_point': handle_spend_point, - - # Inventory Management - 'inventory_menu': handle_inventory_menu, - 'inventory_item': handle_inventory_item, - 'inventory_use': handle_inventory_use, - 'inventory_drop': handle_inventory_drop, - 'inventory_equip': handle_inventory_equip, - 'inventory_unequip': handle_inventory_unequip, - - # Item Pickup - 'pickup_menu': handle_pickup_menu, - 'pickup': handle_pickup, - - # Combat Actions - 'combat_attack': handle_combat_attack, - 'combat_flee': handle_combat_flee, - 'combat_use_item_menu': handle_combat_use_item_menu, - 'combat_use_item': handle_combat_use_item, - 'combat_back': handle_combat_back, - - # Corpse Looting - 'loot_player_corpse': handle_loot_player_corpse, - 'take_corpse_item': handle_take_corpse_item, - 'scavenge_npc_corpse': handle_scavenge_npc_corpse, - 'scavenge_corpse_item': handle_scavenge_corpse_item, -} - - -# ============================================================================ -# BUTTON CALLBACK ROUTER -# ============================================================================ - -async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """ - Main router for button callbacks. - Delegates to specific handler functions based on action type. - All handlers have a unified signature: (query, user_id, player, data=None) - - Note: user_id passed to handlers is actually the player's unique DB id (not telegram_id) - """ - from .api_client import api_client - - query = update.callback_query - telegram_id = query.from_user.id - data = query.data.split(':') - action_type = data[0] - - # Get player by telegram_id and translate to unique id - player = await api_client.get_player(telegram_id) - if not player or player['is_dead']: - await query.answer() - await send_or_edit_with_image( - query, - text="๐Ÿ’€ Your journey has ended. You died in the wasteland. Create a new character with /start to begin again.", - reply_markup=None - ) - return - - # From now on, use player's unique database id - user_id = player['id'] - - # Check if player is in combat - restrict most actions - combat = await api_client.get_combat(user_id) - allowed_in_combat = { - 'combat_attack', 'combat_flee', 'combat_use_item_menu', - 'combat_use_item', 'combat_back', 'no_op' - } - if combat and action_type not in allowed_in_combat: - await query.answer("You're in combat! Focus on the fight!", show_alert=False) - return - - # Route to appropriate handler - if action_type == 'no_op': - await query.answer() - return - - handler = HANDLER_MAP.get(action_type) - if handler: - try: - await handler(query, user_id, player, data) - except Exception as e: - logger.error(f"Error handling button action {action_type}: {e}", exc_info=True) - await query.answer("An error occurred. Please try again.", show_alert=True) - else: - logger.warning(f"Unknown action type: {action_type}") - await query.answer("Unknown action", show_alert=False) diff --git a/old/bot/inventory_handlers.py b/old/bot/inventory_handlers.py deleted file mode 100644 index 376c991..0000000 --- a/old/bot/inventory_handlers.py +++ /dev/null @@ -1,338 +0,0 @@ -""" -Inventory-related action handlers. -""" -import logging -from telegram import InlineKeyboardButton, InlineKeyboardMarkup -from . import keyboards, logic -from data.world_loader import game_world -from data.items import ITEMS - -logger = logging.getLogger(__name__) - - -async def handle_inventory_menu(query, user_id: int, player: dict, data: list = None): - """Display player inventory with item management options.""" - from .utils import format_stat_bar - from .api_client import api_client - await query.answer() - - # Get inventory from API - inv_result = await api_client.get_inventory(player['id']) - inventory_items = inv_result.get('inventory', []) - current_weight, current_volume = logic.calculate_inventory_load(inventory_items) - max_weight, max_volume = logic.get_player_capacity(inventory_items, player) - - text = "๐ŸŽ’ Your Inventory:\n" - text += f"{format_stat_bar('HP', 'โค๏ธ', player['hp'], player['max_hp'])}\n" - text += f"{format_stat_bar('Stamina', 'โšก', player['stamina'], player['max_stamina'])}\n" - text += f"๐Ÿ“Š Weight: {current_weight}/{max_weight} kg\n" - text += f"๐Ÿ“ฆ Volume: {current_volume}/{max_volume} vol\n" - - if not inventory_items: - text += "\nYour inventory is empty." - - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboards.inventory_keyboard(inventory_items), - image_path=location_image - ) - - -async def handle_inventory_item(query, user_id: int, player: dict, data: list): - """Show details for a specific inventory item. - - Note: item_db_id is the inventory row id from the API response. - We need to get the full inventory and find the item by id. - """ - from .api_client import api_client - - await query.answer() - item_db_id = int(data[1]) - - # Get inventory from API - inv_result = await api_client.get_inventory(user_id) - inventory_items = inv_result.get('inventory', []) - - # Find the specific item - item = next((i for i in inventory_items if i['id'] == item_db_id), None) - if not item: - await query.answer("Item not found in inventory", show_alert=True) - return - - emoji = item.get('emoji', 'โ”') - - # Build item details text - text = f"{emoji} {item.get('name', 'Unknown')}\n" - - description = item.get('description') - if description: - text += f"{description}\n\n" - else: - text += "\n" - - text += f"Weight: {item.get('weight', 0)} kg | Volume: {item.get('volume', 0)} vol\n" - - # Add weapon stats if applicable - if item.get('type') == 'weapon': - text += f"Damage: {item.get('damage_min', 0)}-{item.get('damage_max', 0)}\n" - - # Add consumable effects if applicable - if item.get('type') == 'consumable': - effects = [] - if item.get('hp_restore'): - effects.append(f"โค๏ธ +{item.get('hp_restore')} HP") - if item.get('stamina_restore'): - effects.append(f"โšก +{item.get('stamina_restore')} Stamina") - if effects: - text += f"Effects: {', '.join(effects)}\n" - - # Add equipped status - if item.get('is_equipped'): - text += "\nโœ… Currently Equipped" - - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboards.inventory_item_actions_keyboard( - item_db_id, item, item.get('is_equipped', False), item['quantity'] - ), - image_path=location_image - ) - - -async def handle_inventory_use(query, user_id: int, player: dict, data: list): - """Use a consumable item from inventory.""" - from .utils import format_stat_bar - from .api_client import api_client - - item_db_id = int(data[1]) - - # Get inventory from API to find the item - inv_result = await api_client.get_inventory(user_id) - inventory_items = inv_result.get('inventory', []) - item = next((i for i in inventory_items if i['id'] == item_db_id), None) - - if not item: - await query.answer("Item not found.", show_alert=False) - return - - if item.get('type') != 'consumable': - await query.answer("This item cannot be used.", show_alert=False) - return - - await query.answer() - - # Use the API to use the item - result = await api_client.use_item(user_id, item['item_id']) - - if not result.get('success'): - await query.answer(result.get('message', 'Failed to use item'), show_alert=True) - return - - # Refresh player data to get updated stats - player = await api_client.get_player_by_id(user_id) - - # Get updated inventory - inv_result = await api_client.get_inventory(user_id) - inventory_items = inv_result.get('inventory', []) - current_weight, current_volume = logic.calculate_inventory_load(inventory_items) - max_weight, max_volume = logic.get_player_capacity(inventory_items, player) - - # Build status section with HP/Stamina bars - text = "๐ŸŽ’ Your Inventory:\n" - text += f"{format_stat_bar('HP', 'โค๏ธ', player['hp'], player['max_hp'])}\n" - text += f"{format_stat_bar('Stamina', 'โšก', player['stamina'], player['max_stamina'])}\n" - text += f"๐Ÿ“Š Weight: {current_weight}/{max_weight} kg\n" - text += f"๐Ÿ“ฆ Volume: {current_volume}/{max_volume} vol\n" - text += "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n" - - # Build result message from API response - text += result.get('message', 'Item used.') - - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboards.inventory_keyboard(inventory_items), - image_path=location_image - ) - - -async def handle_inventory_drop(query, user_id: int, player: dict, data: list): - """Drop an item from inventory to the world.""" - from .api_client import api_client - - item_db_id = int(data[1]) - drop_amount_str = data[2] if len(data) > 2 else None - - # Get inventory to find the item - inv_result = await api_client.get_inventory(user_id) - inventory_items = inv_result.get('inventory', []) - item = next((i for i in inventory_items if i['id'] == item_db_id), None) - - if not item: - await query.answer("Item not found.", show_alert=False) - return - - # Determine how much to drop - if drop_amount_str is None or drop_amount_str == "all": - drop_amount = item['quantity'] - else: - drop_amount = min(int(drop_amount_str), item['quantity']) - - # Use API to drop item - result = await api_client.drop_item(user_id, item['item_id'], drop_amount) - - if result.get('success'): - await query.answer(result.get('message', f"Dropped {drop_amount}x {item['name']}"), show_alert=False) - else: - await query.answer(result.get('message', 'Failed to drop item'), show_alert=True) - return - - # Get updated inventory - inv_result = await api_client.get_inventory(user_id) - inventory_items = inv_result.get('inventory', []) - current_weight, current_volume = logic.calculate_inventory_load(inventory_items) - max_weight, max_volume = logic.get_player_capacity(inventory_items, player) - - text = "๐ŸŽ’ Your Inventory:\n" - text += f"๐Ÿ“Š Weight: {current_weight}/{max_weight} kg\n" - text += f"๐Ÿ“ฆ Volume: {current_volume}/{max_volume} vol\n\n" - - if not inventory_items: - text += "It's empty." - - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboards.inventory_keyboard(inventory_items), - image_path=location_image - ) - - -async def handle_inventory_equip(query, user_id: int, player: dict, data: list): - """Equip an item from inventory.""" - from .api_client import api_client - - item_db_id = int(data[1]) - - # Get inventory to find the item - inv_result = await api_client.get_inventory(user_id) - inventory_items = inv_result.get('inventory', []) - item = next((i for i in inventory_items if i['id'] == item_db_id), None) - - if not item: - await query.answer("Item not found.", show_alert=False) - return - - if not item.get('equippable'): - await query.answer("This item cannot be equipped.", show_alert=False) - return - - # Use API to equip item - result = await api_client.equip_item(user_id, item['item_id']) - - if not result.get('success'): - await query.answer(result.get('message', 'Failed to equip item'), show_alert=True) - return - - await query.answer(result.get('message', f"Equipped {item['name']}!"), show_alert=False) - - # Refresh the item view - emoji = item.get('emoji', 'โ”') - text = f"{emoji} {item.get('name', 'Unknown')}\n" - - description = item.get('description') - if description: - text += f"{description}\n\n" - else: - text += "\n" - - text += f"Weight: {item.get('weight', 0)} kg | Volume: {item.get('volume', 0)} vol\n" - - if item.get('type') == 'weapon': - text += f"Damage: {item.get('damage_min', 0)}-{item.get('damage_max', 0)}\n" - - text += "\nโœ… Currently Equipped" - - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboards.inventory_item_actions_keyboard( - item_db_id, item, True, item['quantity'] - ), - image_path=location_image - ) - - -async def handle_inventory_unequip(query, user_id: int, player: dict, data: list): - """Unequip an item.""" - from .api_client import api_client - - item_db_id = int(data[1]) - - # Get inventory to find the item - inv_result = await api_client.get_inventory(user_id) - inventory_items = inv_result.get('inventory', []) - item = next((i for i in inventory_items if i['id'] == item_db_id), None) - - if not item: - await query.answer("Item not found.", show_alert=False) - return - - # Use API to unequip item - result = await api_client.unequip_item(user_id, item['item_id']) - - if not result.get('success'): - await query.answer(result.get('message', 'Failed to unequip item'), show_alert=True) - return - - await query.answer(result.get('message', f"Unequipped {item['name']}."), show_alert=False) - - # Refresh the item view - emoji = item.get('emoji', 'โ”') - text = f"{emoji} {item.get('name', 'Unknown')}\n" - - description = item.get('description') - if description: - text += f"{description}\n\n" - else: - text += "\n" - - text += f"Weight: {item.get('weight', 0)} kg | Volume: {item.get('volume', 0)} vol\n" - - if item.get('type') == 'weapon': - text += f"Damage: {item.get('damage_min', 0)}-{item.get('damage_max', 0)}\n" - - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboards.inventory_item_actions_keyboard( - item_db_id, item, False, item['quantity'] - ), - image_path=location_image - ) diff --git a/old/bot/keyboards.py b/old/bot/keyboards.py deleted file mode 100644 index 08bf7c2..0000000 --- a/old/bot/keyboards.py +++ /dev/null @@ -1,607 +0,0 @@ -from telegram import InlineKeyboardButton, InlineKeyboardMarkup -from data.world_loader import game_world -from data.items import ITEMS - -# ... (main_menu_keyboard, move_keyboard are unchanged) ... -def main_menu_keyboard() -> InlineKeyboardMarkup: - keyboard = [[InlineKeyboardButton("๐Ÿ—บ๏ธ Move", callback_data="move_menu"), InlineKeyboardButton("๐Ÿ‘€ Inspect Area", callback_data="inspect_area")], [InlineKeyboardButton("๐Ÿ‘ค Profile", callback_data="profile"), InlineKeyboardButton("๐ŸŽ’ Inventory", callback_data="inventory_menu")]] - return InlineKeyboardMarkup(keyboard) - -async def move_keyboard(current_location_id: str, player_id: int) -> InlineKeyboardMarkup: - """ - Create a movement keyboard with stamina costs. - Layout: - [ North (โšก5) ] - [ West (โšก5) ] [ East (โšก5) ] - [ South (โšก5) ] - [ Other exits (inside, down, etc.) ] - [ Back ] - """ - from bot import logic - from bot.api_client import api_client - - keyboard = [] - location = game_world.get_location(current_location_id) - player = await api_client.get_player(player_id) - inventory = await api_client.get_inventory(player_id) - - if location and player: - # Dictionary to hold direction buttons - compass_directions = {} - other_exits = [] - - for direction, destination_id in location.exits.items(): - destination = game_world.get_location(destination_id) - if destination: - # Calculate stamina cost for this specific route - stamina_cost = logic.calculate_travel_stamina_cost(player, inventory, location, destination) - - # Map direction to emoji and label - direction_lower = direction.lower() - if direction_lower == "north": - emoji = "โฌ†๏ธ" - compass_directions["north"] = InlineKeyboardButton( - f"{emoji} {destination.name} (โšก{stamina_cost})", - callback_data=f"move:{destination_id}" - ) - elif direction_lower == "south": - emoji = "โฌ‡๏ธ" - compass_directions["south"] = InlineKeyboardButton( - f"{emoji} {destination.name} (โšก{stamina_cost})", - callback_data=f"move:{destination_id}" - ) - elif direction_lower == "east": - emoji = "โžก๏ธ" - compass_directions["east"] = InlineKeyboardButton( - f"{emoji} {destination.name} (โšก{stamina_cost})", - callback_data=f"move:{destination_id}" - ) - elif direction_lower == "west": - emoji = "โฌ…๏ธ" - compass_directions["west"] = InlineKeyboardButton( - f"{emoji} {destination.name} (โšก{stamina_cost})", - callback_data=f"move:{destination_id}" - ) - elif direction_lower == "northeast": - emoji = "โ†—๏ธ" - compass_directions["northeast"] = InlineKeyboardButton( - f"{emoji} {destination.name} (โšก{stamina_cost})", - callback_data=f"move:{destination_id}" - ) - elif direction_lower == "northwest": - emoji = "โ†–๏ธ" - compass_directions["northwest"] = InlineKeyboardButton( - f"{emoji} {destination.name} (โšก{stamina_cost})", - callback_data=f"move:{destination_id}" - ) - elif direction_lower == "southeast": - emoji = "โ†˜๏ธ" - compass_directions["southeast"] = InlineKeyboardButton( - f"{emoji} {destination.name} (โšก{stamina_cost})", - callback_data=f"move:{destination_id}" - ) - elif direction_lower == "southwest": - emoji = "โ†™๏ธ" - compass_directions["southwest"] = InlineKeyboardButton( - f"{emoji} {destination.name} (โšก{stamina_cost})", - callback_data=f"move:{destination_id}" - ) - elif direction_lower == "inside": - emoji = "๐Ÿšช" - other_exits.append(InlineKeyboardButton( - f"{emoji} Enter {destination.name} (โšก{stamina_cost})", - callback_data=f"move:{destination_id}" - )) - elif direction_lower == "outside": - emoji = "๐Ÿšช" - other_exits.append(InlineKeyboardButton( - f"{emoji} Exit to {destination.name} (โšก{stamina_cost})", - callback_data=f"move:{destination_id}" - )) - elif direction_lower == "down": - emoji = "โฌ‡๏ธ" - other_exits.append(InlineKeyboardButton( - f"{emoji} Descend to {destination.name} (โšก{stamina_cost})", - callback_data=f"move:{destination_id}" - )) - elif direction_lower == "up": - emoji = "โฌ†๏ธ" - other_exits.append(InlineKeyboardButton( - f"{emoji} Ascend to {destination.name} (โšก{stamina_cost})", - callback_data=f"move:{destination_id}" - )) - else: - # Generic fallback for any other direction - emoji = "๐Ÿ”€" - other_exits.append(InlineKeyboardButton( - f"{emoji} {destination.name} (โšก{stamina_cost})", - callback_data=f"move:{destination_id}" - )) - - # Build compass layout - # Row 1: Northwest, North, Northeast - top_row = [] - if "northwest" in compass_directions: - top_row.append(compass_directions["northwest"]) - if "north" in compass_directions: - top_row.append(compass_directions["north"]) - if "northeast" in compass_directions: - top_row.append(compass_directions["northeast"]) - if top_row: - keyboard.append(top_row) - - # Row 2: West and/or East - middle_row = [] - if "west" in compass_directions: - middle_row.append(compass_directions["west"]) - if "east" in compass_directions: - middle_row.append(compass_directions["east"]) - if middle_row: - keyboard.append(middle_row) - - # Row 3: Southwest, South, Southeast - bottom_row = [] - if "southwest" in compass_directions: - bottom_row.append(compass_directions["southwest"]) - if "south" in compass_directions: - bottom_row.append(compass_directions["south"]) - if "southeast" in compass_directions: - bottom_row.append(compass_directions["southeast"]) - if bottom_row: - keyboard.append(bottom_row) - - # Add other exits (inside, outside, up, down, etc.) - for exit_button in other_exits: - keyboard.append([exit_button]) - - keyboard.append([InlineKeyboardButton("โฌ…๏ธ Back", callback_data="main_menu")]) - return InlineKeyboardMarkup(keyboard) - -async def inspect_keyboard(location_id: str, dropped_items: list, wandering_enemies: list = None) -> InlineKeyboardMarkup: - from bot.api_client import api_client - from data.npcs import NPCS - - keyboard = [] - location = game_world.get_location(location_id) - - # Show wandering enemies first if present (in pairs, emoji only) - if wandering_enemies: - row = [] - for enemy in wandering_enemies: - npc_def = NPCS.get(enemy['npc_id']) - if npc_def: - button = InlineKeyboardButton( - f"โš ๏ธ {npc_def.emoji} {npc_def.name}", - callback_data=f"attack_wandering:{enemy['id']}" - ) - row.append(button) - if len(row) == 2: - keyboard.append(row) - row = [] - if row: # Add remaining enemy if odd number - keyboard.append(row) - if wandering_enemies: - keyboard.append([InlineKeyboardButton("--- Environment ---", callback_data="no_op")]) - - # Show interactables in pairs when text is short enough - if location: - row = [] - for instance_id, interactable in location.interactables.items(): - label = interactable.name - # Check if ANY action is available (not on cooldown) - has_available_action = False - for action_id in interactable.actions.keys(): - cooldown_key = f"{instance_id}:{action_id}" - if await api_client.get_cooldown(cooldown_key) == 0: - has_available_action = True - break - if not has_available_action and len(interactable.actions) > 0: - label += " โณ" - - # Include location_id in callback data for efficient lookup - button = InlineKeyboardButton(label, callback_data=f"inspect:{location_id}:{instance_id}") - - # If text is short (< 20 chars), try to pair it - if len(label) < 20: - row.append(button) - if len(row) == 2: - keyboard.append(row) - row = [] - else: - # Long text, add any pending row first, then add this one alone - if row: - keyboard.append(row) - row = [] - keyboard.append([button]) - - # Add remaining button if odd number - if row: - keyboard.append(row) - - # Show player corpse bags - player_corpses = await api_client.get_player_corpses_in_location(location_id) - if player_corpses: - keyboard.append([InlineKeyboardButton("--- Fallen survivors ---", callback_data="no_op")]) - row = [] - for corpse in player_corpses: - button = InlineKeyboardButton( - f"๐ŸŽ’ {corpse['player_name']}'s bag", - callback_data=f"loot_player_corpse:{corpse['id']}" - ) - row.append(button) - if len(row) == 2: - keyboard.append(row) - row = [] - if row: - keyboard.append(row) - - # Show NPC corpses - npc_corpses = await api_client.get_npc_corpses_in_location(location_id) - if npc_corpses: - if not player_corpses: # Only add separator if not already added - keyboard.append([InlineKeyboardButton("--- Corpses ---", callback_data="no_op")]) - row = [] - for corpse in npc_corpses: - from data.npcs import NPCS - npc_def = NPCS.get(corpse['npc_id']) - if npc_def: - button = InlineKeyboardButton( - f"{npc_def.emoji} {npc_def.name}", - callback_data=f"scavenge_npc_corpse:{corpse['id']}" - ) - row.append(button) - if len(row) == 2: - keyboard.append(row) - row = [] - if row: - keyboard.append(row) - - if dropped_items: - keyboard.append([InlineKeyboardButton("--- Items on the ground ---", callback_data="no_op")]) - row = [] - for item in dropped_items: - item_def = ITEMS.get(item['item_id'], {}) - emoji = item_def.get('emoji', 'โ”') - quantity_text = f" x{item['quantity']}" if item['quantity'] > 1 else "" - button = InlineKeyboardButton( - f"{emoji} {item_def.get('name', 'Unknown')}{quantity_text}", - callback_data=f"pickup_menu:{item['id']}" - ) - row.append(button) - if len(row) == 2: - keyboard.append(row) - row = [] - if row: - keyboard.append(row) - - keyboard.append([InlineKeyboardButton("โฌ…๏ธ Back", callback_data="main_menu")]) - return InlineKeyboardMarkup(keyboard) - -def pickup_options_keyboard(item_id: int, item_name: str, quantity: int) -> InlineKeyboardMarkup: - """Create pickup options keyboard with x1, x5, x10, and All options.""" - keyboard = [] - - if quantity == 1: - # Just show a single "Pick" button for single items - keyboard.append([InlineKeyboardButton("๐Ÿ“ฆ Pick", callback_data=f"pickup:{item_id}:1")]) - else: - # Build pickup row with available options - pickup_row = [InlineKeyboardButton("๐Ÿ“ฆ Pick x1", callback_data=f"pickup:{item_id}:1")] - - if quantity >= 5: - pickup_row.append(InlineKeyboardButton("๐Ÿ“ฆ Pick x5", callback_data=f"pickup:{item_id}:5")) - if quantity >= 10: - pickup_row.append(InlineKeyboardButton("๐Ÿ“ฆ Pick x10", callback_data=f"pickup:{item_id}:10")) - - # Split into rows if more than 2 buttons - if len(pickup_row) > 2: - keyboard.append(pickup_row[:2]) - keyboard.append(pickup_row[2:]) - else: - keyboard.append(pickup_row) - - # Add "Pick All" option - keyboard.append([InlineKeyboardButton(f"๐Ÿ“ฆ Pick All ({quantity})", callback_data=f"pickup:{item_id}:all")]) - - # Back button - keyboard.append([InlineKeyboardButton("โฌ…๏ธ Back", callback_data="inspect_area")]) - - return InlineKeyboardMarkup(keyboard) - -async def actions_keyboard(location_id: str, instance_id: str) -> InlineKeyboardMarkup: - from bot.api_client import api_client - keyboard = [] - - location = game_world.get_location(location_id) - - if location: - interactable = location.get_interactable(instance_id) - if interactable: - for action_id, action in interactable.actions.items(): - cooldown_key = f"{instance_id}:{action_id}" - cooldown = await api_client.get_cooldown(cooldown_key) - label = action.label - # Add stamina cost to the label - if action.stamina_cost > 0: - label += f" (โšก{action.stamina_cost})" - if cooldown > 0: - label += " โณ" - keyboard.append([InlineKeyboardButton(label, callback_data=f"action:{location_id}:{instance_id}:{action_id}")]) - - keyboard.append([InlineKeyboardButton("โฌ…๏ธ Back", callback_data=f"inspect_area_menu:{location_id}")]) - return InlineKeyboardMarkup(keyboard) - -# ... (inventory_keyboard, inventory_item_actions_keyboard are unchanged) ... -def inventory_keyboard(inventory_items: list) -> InlineKeyboardMarkup: - keyboard = [] - if inventory_items: - # Categorize and sort items - # Group items by item_id and equipped status to handle stacking properly - item_groups = {} - - for item in inventory_items: - item_def = ITEMS.get(item['item_id'], {}) - item_type = item_def.get('type', 'resource') - item_name = item_def.get('name', 'Unknown') - is_equipped = item.get('is_equipped', False) - - # Create a unique key for grouping: item_id + equipped status - group_key = (item['item_id'], is_equipped) - - if group_key not in item_groups: - item_groups[group_key] = { - 'name': item_name, - 'def': item_def, - 'type': item_type, - 'is_equipped': is_equipped, - 'items': [] - } - item_groups[group_key]['items'].append(item) - - # Categorize groups - equipped = [] - consumables = [] - weapons = [] - equipment = [] - resources = [] - quest_items = [] - - for group_key, group_data in item_groups.items(): - item_name = group_data['name'] - item_def = group_data['def'] - item_type = group_data['type'] - is_equipped = group_data['is_equipped'] - items_list = group_data['items'] - - # Calculate total quantity and weight/volume for this group - total_quantity = sum(itm['quantity'] for itm in items_list) - weight_per_item = item_def.get('weight', 0) - volume_per_item = item_def.get('volume', 0) - total_weight = weight_per_item * total_quantity - total_volume = volume_per_item * total_quantity - - # Use the first item's ID for the callback (they're all the same item type) - first_item_id = items_list[0]['id'] - - # Create item data tuple: (name, item_def, first_item_id, quantity, weight, volume, is_equipped) - item_tuple = (item_name, item_def, first_item_id, total_quantity, total_weight, total_volume, is_equipped) - - # Only equipped items go to equipped section - if is_equipped: - equipped.append(item_tuple) - elif item_type == 'consumable': - consumables.append(item_tuple) - elif item_type == 'weapon': - weapons.append(item_tuple) - elif item_type == 'equipment': - equipment.append(item_tuple) - elif item_type == 'quest': - quest_items.append(item_tuple) - else: - resources.append(item_tuple) - - # Sort each category alphabetically by name - equipped.sort(key=lambda x: x[0]) - consumables.sort(key=lambda x: x[0]) - weapons.sort(key=lambda x: x[0]) - equipment.sort(key=lambda x: x[0]) - resources.sort(key=lambda x: x[0]) - quest_items.sort(key=lambda x: x[0]) - - # Build keyboard sections - def add_section(section_name, items_list): - if items_list: - keyboard.append([InlineKeyboardButton(f"--- {section_name} ---", callback_data="no_op")]) - row = [] - for item_name, item_def, item_id, quantity, weight, volume, is_equipped in items_list: - emoji = item_def.get('emoji', 'โ”') - quantity_text = f" x{quantity}" if quantity > 1 else "" - equipped_marker = " โœ“" if is_equipped else "" - # Round to 2 decimals - weight_vol_text = f" ({weight:.2f}kg, {volume:.2f}vol)" if quantity > 0 else "" - - button = InlineKeyboardButton( - f"{emoji} {item_name}{quantity_text}{equipped_marker}{weight_vol_text}", - callback_data=f"inventory_item:{item_id}" - ) - row.append(button) - if len(row) == 2: - keyboard.append(row) - row = [] - # Add remaining item if odd number - if row: - keyboard.append(row) - - # Add sections in order - add_section("Equipped", equipped) - add_section("Consumables", consumables) - add_section("Weapons", weapons) - add_section("Equipment", equipment) - add_section("Resources", resources) - add_section("Quest Items", quest_items) - - if not keyboard: - keyboard.append([InlineKeyboardButton("--- Inventory is empty ---", callback_data="no_op")]) - else: - keyboard.append([InlineKeyboardButton("--- Inventory is empty ---", callback_data="no_op")]) - - keyboard.append([InlineKeyboardButton("โฌ…๏ธ Back", callback_data="main_menu")]) - return InlineKeyboardMarkup(keyboard) -def inventory_item_actions_keyboard(item_db_id: int, item_def: dict, is_equipped: bool = False, quantity: int = 1) -> InlineKeyboardMarkup: - keyboard = [] - - # Use button for consumables - if item_def.get('type') == 'consumable': - keyboard.append([InlineKeyboardButton("โžก๏ธ Use Item", callback_data=f"inventory_use:{item_db_id}")]) - - # Equip/Unequip button for weapons and equipment - if item_def.get('type') in ["weapon", "equipment"]: - if is_equipped: - keyboard.append([InlineKeyboardButton("โŒ Unequip", callback_data=f"inventory_unequip:{item_db_id}")]) - else: - keyboard.append([InlineKeyboardButton("โœ… Equip", callback_data=f"inventory_equip:{item_db_id}")]) - - # Drop buttons - simplified for single items - if quantity == 1: - # Just show a single "Drop" button - keyboard.append([InlineKeyboardButton("๐Ÿ—‘๏ธ Drop", callback_data=f"inventory_drop:{item_db_id}:all")]) - else: - # Show x1, x5, x10 options based on quantity - drop_row = [InlineKeyboardButton("๐Ÿ—‘๏ธ Drop x1", callback_data=f"inventory_drop:{item_db_id}:1")] - if quantity >= 5: - drop_row.append(InlineKeyboardButton("๐Ÿ—‘๏ธ Drop x5", callback_data=f"inventory_drop:{item_db_id}:5")) - if quantity >= 10: - drop_row.append(InlineKeyboardButton("๐Ÿ—‘๏ธ Drop x10", callback_data=f"inventory_drop:{item_db_id}:10")) - - # Split into rows if more than 2 buttons - if len(drop_row) > 2: - keyboard.append(drop_row[:2]) - keyboard.append(drop_row[2:]) - else: - keyboard.append(drop_row) - - # Add "Drop All" option - keyboard.append([InlineKeyboardButton(f"๐Ÿ—‘๏ธ Drop All ({quantity})", callback_data=f"inventory_drop:{item_db_id}:all")]) - - keyboard.append([InlineKeyboardButton("โฌ…๏ธ Back to Inventory", callback_data="inventory_menu")]) - return InlineKeyboardMarkup(keyboard) - -async def combat_keyboard(player_id: int) -> InlineKeyboardMarkup: - """Create combat action keyboard.""" - from bot.api_client import api_client - keyboard = [] - - # Attack option - keyboard.append([InlineKeyboardButton("โš”๏ธ Attack", callback_data="combat_attack")]) - - # Flee option - keyboard.append([InlineKeyboardButton("๐Ÿƒ Try to Flee", callback_data="combat_flee")]) - - # Use item option (show consumables) - inventory_items = await api_client.get_inventory(player_id) - consumables = [item for item in inventory_items if ITEMS.get(item['item_id'], {}).get('type') == 'consumable'] - - if consumables: - keyboard.append([InlineKeyboardButton("๐Ÿ’Š Use Item", callback_data="combat_use_item_menu")]) - - # Profile button (no effect on turn, just info) - keyboard.append([InlineKeyboardButton("๐Ÿ‘ค Profile", callback_data="profile")]) - - return InlineKeyboardMarkup(keyboard) - -async def combat_items_keyboard(player_id: int) -> InlineKeyboardMarkup: - """Show consumable items during combat.""" - from bot.api_client import api_client - keyboard = [] - - inventory_items = await api_client.get_inventory(player_id) - consumables = [item for item in inventory_items if ITEMS.get(item['item_id'], {}).get('type') == 'consumable'] - - if consumables: - keyboard.append([InlineKeyboardButton("--- Select item to use ---", callback_data="no_op")]) - for item in consumables: - item_def = ITEMS.get(item['item_id'], {}) - emoji = item_def.get('emoji', 'โ”') - keyboard.append([InlineKeyboardButton( - f"{emoji} {item_def.get('name', 'Unknown')} x{item['quantity']}", - callback_data=f"combat_use_item:{item['id']}" - )]) - - keyboard.append([InlineKeyboardButton("โฌ…๏ธ Back to Combat", callback_data="combat_back")]) - return InlineKeyboardMarkup(keyboard) - -def corpse_keyboard(corpse_id: int, corpse_type: str) -> InlineKeyboardMarkup: - """Create keyboard for interacting with corpses.""" - keyboard = [] - - if corpse_type == "player": - keyboard.append([InlineKeyboardButton("๐ŸŽ’ Loot Bag", callback_data=f"loot_player_corpse:{corpse_id}")]) - else: # NPC corpse - keyboard.append([InlineKeyboardButton("๐Ÿ”ช Scavenge Corpse", callback_data=f"scavenge_npc_corpse:{corpse_id}")]) - - keyboard.append([InlineKeyboardButton("โฌ…๏ธ Back", callback_data="inspect_area")]) - return InlineKeyboardMarkup(keyboard) - -def player_corpse_loot_keyboard(corpse_id: int, items: list) -> InlineKeyboardMarkup: - """Show items in a player corpse bag.""" - keyboard = [] - - if items: - keyboard.append([InlineKeyboardButton("--- Take items ---", callback_data="no_op")]) - for i, item_data in enumerate(items): - item_def = ITEMS.get(item_data['item_id'], {}) - emoji = item_def.get('emoji', 'โ”') - keyboard.append([InlineKeyboardButton( - f"{emoji} Take {item_def.get('name', 'Unknown')} x{item_data['quantity']}", - callback_data=f"take_corpse_item:{corpse_id}:{i}" - )]) - else: - keyboard.append([InlineKeyboardButton("--- Bag is empty ---", callback_data="no_op")]) - - keyboard.append([InlineKeyboardButton("โฌ…๏ธ Back", callback_data="inspect_area")]) - return InlineKeyboardMarkup(keyboard) - -def npc_corpse_scavenge_keyboard(corpse_id: int, loot_items: list) -> InlineKeyboardMarkup: - """Show scavenging options for NPC corpse.""" - keyboard = [] - - if loot_items: - keyboard.append([InlineKeyboardButton("--- Scavenge for materials ---", callback_data="no_op")]) - for i, loot_data in enumerate(loot_items): - item_def = ITEMS.get(loot_data['item_id'], {}) - emoji = item_def.get('emoji', 'โ”') - - label = f"{emoji} {item_def.get('name', 'Unknown')}" - if loot_data.get('required_tool'): - tool_def = ITEMS.get(loot_data['required_tool'], {}) - label += f" (needs {tool_def.get('name', 'tool')})" - - keyboard.append([InlineKeyboardButton( - label, - callback_data=f"scavenge_corpse_item:{corpse_id}:{i}" - )]) - else: - keyboard.append([InlineKeyboardButton("--- Nothing left to scavenge ---", callback_data="no_op")]) - - keyboard.append([InlineKeyboardButton("โฌ…๏ธ Back", callback_data="inspect_area")]) - return InlineKeyboardMarkup(keyboard) - -def spend_points_keyboard() -> InlineKeyboardMarkup: - """Create keyboard for spending stat points.""" - keyboard = [ - [ - InlineKeyboardButton("โค๏ธ Max HP (+10)", callback_data="spend_point:max_hp"), - InlineKeyboardButton("โšก Stamina (+5)", callback_data="spend_point:max_stamina") - ], - [ - InlineKeyboardButton("๐Ÿ’ช Strength (+1)", callback_data="spend_point:strength"), - InlineKeyboardButton("๐Ÿƒ Agility (+1)", callback_data="spend_point:agility") - ], - [ - InlineKeyboardButton("๐Ÿ’š Endurance (+1)", callback_data="spend_point:endurance"), - InlineKeyboardButton("๐Ÿง  Intellect (+1)", callback_data="spend_point:intellect") - ], - [InlineKeyboardButton("โฌ…๏ธ Back to Profile", callback_data="profile")] - ] - return InlineKeyboardMarkup(keyboard) - diff --git a/old/bot/logic.py b/old/bot/logic.py deleted file mode 100644 index 527cbf8..0000000 --- a/old/bot/logic.py +++ /dev/null @@ -1,119 +0,0 @@ -import random -from typing import Tuple, Dict, Any - -from data.items import ITEMS -from data.models import Action, Outcome - -def calculate_inventory_load(player_inventory: list) -> Tuple[float, float]: - """Calculates the total weight and volume of a player's inventory.""" - total_weight = 0.0 - total_volume = 0.0 - for item in player_inventory: - item_def = ITEMS.get(item["item_id"]) - if item_def: - total_weight += item_def["weight"] * item["quantity"] - total_volume += item_def["volume"] * item["quantity"] - return round(total_weight, 2), round(total_volume, 2) - -def get_player_capacity(player_inventory: list, player_stats: dict) -> Tuple[float, float]: - """Calculates the total carrying capacity of a player.""" - base_weight_cap = player_stats['strength'] * 5 # Example formula - base_volume_cap = player_stats['strength'] * 2 # Example formula - - for item in player_inventory: - if item["is_equipped"]: - item_def = ITEMS.get(item["item_id"]) - if item_def and item_def.get("type") == "equipment": - effects = item_def.get("effects", {}) - base_weight_cap += effects.get("capacity_weight", 0) - base_volume_cap += effects.get("capacity_volume", 0) - - return base_weight_cap, base_volume_cap - -def resolve_action(player_stats: dict, action_obj: Action) -> Outcome: - """ - Resolves a player action, like searching, based on stats and luck. - Returns the resulting Outcome object. - """ - # A simple success chance calculation - base_chance = 50 + (player_stats.get('intellect', 5) * 2) - roll = random.randint(1, 100) - - outcome_key = "failure" - if roll <= 5 and "critical_failure" in action_obj.outcomes: - outcome_key = "critical_failure" - elif roll <= base_chance and "success" in action_obj.outcomes: - outcome_key = "success" - - return action_obj.outcomes.get(outcome_key, action_obj.outcomes["failure"]) - -async def can_add_item_to_inventory(user_id: int, item_id: str, quantity: int) -> Tuple[bool, str]: - """ - Check if an item can be added to the player's inventory. - Returns (can_add, reason_if_not) - """ - from .api_client import api_client - - player = await api_client.get_player(user_id) - if not player: - return False, "Player not found." - - inventory = await api_client.get_inventory(user_id) - item_def = ITEMS.get(item_id) - - if not item_def: - return False, "Invalid item." - - # Calculate current and projected weight/volume - current_weight, current_volume = calculate_inventory_load(inventory) - max_weight, max_volume = get_player_capacity(inventory, player) - - item_weight = item_def["weight"] * quantity - item_volume = item_def["volume"] * quantity - - new_weight = current_weight + item_weight - new_volume = current_volume + item_volume - - if new_weight > max_weight: - return False, f"Too heavy! ({new_weight:.1f}/{max_weight:.1f} kg)" - - if new_volume > max_volume: - return False, f"Not enough space! ({new_volume:.1f}/{max_volume:.1f} vol)" - - return True, "" - -def calculate_travel_stamina_cost(player: dict, inventory: list, from_location, to_location) -> int: - """ - Calculate stamina cost for traveling between locations. - Based on distance, endurance (reduces cost), and carried weight (increases cost). - - Args: - player: Player stats dictionary - inventory: Player's inventory list - from_location: Location object being traveled from - to_location: Location object being traveled to - """ - from data.travel_helpers import calculate_base_stamina_cost - - # Get base cost from shared helper (used by map and game) - distance_cost = calculate_base_stamina_cost(from_location, to_location) - - # Endurance reduces cost (each point reduces by 0.5) - endurance_reduction = player['endurance'] * 0.5 - - # Calculate weight burden - current_weight, _ = calculate_inventory_load(inventory) - max_weight, _ = get_player_capacity(inventory, player) - - # Weight penalty: if carrying more than 50% capacity, add extra cost - weight_ratio = current_weight / max_weight if max_weight > 0 else 0 - weight_penalty = 0 - - if weight_ratio > 0.5: - # Each 10% over 50% adds 1 stamina - weight_penalty = int((weight_ratio - 0.5) * 10) - - # Calculate final cost (minimum 3) - final_cost = max(3, int(distance_cost - endurance_reduction + weight_penalty)) - - return final_cost diff --git a/old/bot/message_utils.py b/old/bot/message_utils.py deleted file mode 100644 index 346ca20..0000000 --- a/old/bot/message_utils.py +++ /dev/null @@ -1,121 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Message utility functions for sending and editing Telegram messages. -Handles image caching, smooth transitions, and message editing logic. -""" -import logging -import os -from telegram import InlineKeyboardMarkup, InputMediaPhoto -from telegram.error import BadRequest -from .api_client import api_client - -logger = logging.getLogger(__name__) - - -async def send_or_edit_with_image(query, text: str, reply_markup: InlineKeyboardMarkup, - image_path: str = None, parse_mode: str = 'HTML'): - """ - Send a message with an image (as caption) or edit existing message. - Uses edit_message_media for smooth transitions when changing images. - - Args: - query: The callback query object - text: Message text/caption - reply_markup: Inline keyboard markup - image_path: Optional path to image file - parse_mode: Parse mode for text (default 'HTML') - """ - current_message = query.message - has_photo = bool(current_message.photo) - - if image_path: - # Get or upload image - cached_file_id = await api_client.get_cached_image(image_path) - - if not cached_file_id and os.path.exists(image_path): - # Upload new image - try: - with open(image_path, 'rb') as img_file: - temp_msg = await current_message.reply_photo( - photo=img_file, - caption=text, - reply_markup=reply_markup, - parse_mode=parse_mode - ) - if temp_msg.photo: - cached_file_id = temp_msg.photo[-1].file_id - await api_client.cache_image(image_path, cached_file_id) - # Delete old message to keep chat clean - try: - await current_message.delete() - except: - pass - return - except Exception as e: - logger.error(f"Error uploading image: {e}") - cached_file_id = None - - if cached_file_id: - # Check if current message has same photo - if has_photo: - current_file_id = current_message.photo[-1].file_id - if current_file_id == cached_file_id: - # Same image, just edit caption - try: - await query.edit_message_caption( - caption=text, - reply_markup=reply_markup, - parse_mode=parse_mode - ) - return - except BadRequest as e: - if "Message is not modified" in str(e): - return - else: - # Different image - use edit_message_media for smooth transition - try: - media = InputMediaPhoto( - media=cached_file_id, - caption=text, - parse_mode=parse_mode - ) - await query.edit_message_media( - media=media, - reply_markup=reply_markup - ) - return - except Exception as e: - logger.error(f"Error editing message media: {e}") - - # Current message has no photo - need to delete and send new - if not has_photo: - try: - await current_message.delete() - except: - pass - - try: - await current_message.reply_photo( - photo=cached_file_id, - caption=text, - reply_markup=reply_markup, - parse_mode=parse_mode - ) - except Exception as e: - logger.error(f"Error sending cached image: {e}") - else: - # No image requested - if has_photo: - # Current message has photo, need to delete and send text-only - try: - await current_message.delete() - except: - pass - await current_message.reply_html(text=text, reply_markup=reply_markup) - else: - # Both text-only, just edit - try: - await query.edit_message_text(text=text, reply_markup=reply_markup, parse_mode=parse_mode) - except BadRequest as e: - if "Message is not modified" not in str(e): - await current_message.reply_html(text=text, reply_markup=reply_markup) diff --git a/old/bot/pickup_handlers.py b/old/bot/pickup_handlers.py deleted file mode 100644 index d817d4e..0000000 --- a/old/bot/pickup_handlers.py +++ /dev/null @@ -1,136 +0,0 @@ -""" -Pickup and item collection handlers. -""" -import logging -from . import keyboards, logic -from .api_client import api_client -from data.world_loader import game_world -from data.items import ITEMS - -logger = logging.getLogger(__name__) - - -async def handle_pickup_menu(query, user_id: int, player: dict, data: list): - """Show pickup options for a dropped item.""" - dropped_item_id = int(data[1]) - item_to_pickup = await api_client.get_dropped_item(dropped_item_id) - - if not item_to_pickup: - await query.answer("Someone already picked that up!", show_alert=False) - location_id = player['location_id'] - location = game_world.get_location(location_id) - dropped_items = await api_client.get_dropped_items_in_location(location_id) - wandering_enemies = await api_client.get_wandering_enemies_in_location(location_id) - keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies) - image_path = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text="You scan the area. You notice...", - reply_markup=keyboard, - image_path=image_path - ) - return - - item_def = ITEMS.get(item_to_pickup['item_id'], {}) - emoji = item_def.get('emoji', 'โ”') - text = f"{emoji} {item_def.get('name', 'Unknown')}\n\n" - text += f"Available: {item_to_pickup['quantity']}\n" - text += f"Weight: {item_def.get('weight', 0)} kg each\n" - text += f"Volume: {item_def.get('volume', 0)} vol each\n\n" - text += "How many do you want to pick up?" - - await query.answer() - keyboard = keyboards.pickup_options_keyboard( - dropped_item_id, - item_def.get('name', 'Unknown'), - item_to_pickup['quantity'] - ) - - location = game_world.get_location(player['location_id']) - image_path = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=text, - reply_markup=keyboard, - image_path=image_path - ) - - -async def handle_pickup(query, user_id: int, player: dict, data: list): - """Pick up a dropped item from the world.""" - dropped_item_id = int(data[1]) - pickup_amount_str = data[2] if len(data) > 2 else "all" - - item_to_pickup = await api_client.get_dropped_item(dropped_item_id) - if not item_to_pickup: - await query.answer("Someone already picked that up!", show_alert=False) - location_id = player['location_id'] - location = game_world.get_location(location_id) - dropped_items = await api_client.get_dropped_items_in_location(location_id) - wandering_enemies = await api_client.get_wandering_enemies_in_location(location_id) - keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies) - image_path = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text="You scan the area. You notice...", - reply_markup=keyboard, - image_path=image_path - ) - return - - # Determine how much to pick up - if pickup_amount_str == "all": - pickup_amount = item_to_pickup['quantity'] - else: - pickup_amount = min(int(pickup_amount_str), item_to_pickup['quantity']) - - # Check inventory capacity - can_add, reason = await logic.can_add_item_to_inventory( - user_id, item_to_pickup['item_id'], pickup_amount - ) - - if not can_add: - await query.answer(reason, show_alert=True) - return - - # Add to inventory - await api_client.add_item_to_inventory(user_id, item_to_pickup['item_id'], pickup_amount) - - # Update or remove dropped item - remaining = item_to_pickup['quantity'] - pickup_amount - item_def = ITEMS.get(item_to_pickup['item_id'], {}) - - if remaining > 0: - await api_client.update_dropped_item(dropped_item_id, remaining) - await query.answer( - f"Picked up {pickup_amount}x {item_def.get('name', 'item')}. {remaining} remaining.", - show_alert=False - ) - else: - await api_client.remove_dropped_item(dropped_item_id) - await query.answer( - f"Picked up {pickup_amount}x {item_def.get('name', 'item')}.", - show_alert=False - ) - - # Return to inspect area - location_id = player['location_id'] - location = game_world.get_location(location_id) - dropped_items = await api_client.get_dropped_items_in_location(location_id) - wandering_enemies = await api_client.get_wandering_enemies_in_location(location_id) - keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies) - image_path = location.image_path if location else None - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text="You scan the area. You notice...", - reply_markup=keyboard, - image_path=image_path - ) diff --git a/old/bot/profile_handlers.py b/old/bot/profile_handlers.py deleted file mode 100644 index 27eab1a..0000000 --- a/old/bot/profile_handlers.py +++ /dev/null @@ -1,169 +0,0 @@ -""" -Profile and character stat management handlers. -""" -import logging -from telegram import InlineKeyboardButton, InlineKeyboardMarkup -from . import keyboards -from data.world_loader import game_world - -logger = logging.getLogger(__name__) - - -async def handle_profile(query, user_id: int, player: dict, data: list = None): - """Display player profile with stats and level info.""" - from .utils import format_stat_bar - await query.answer() - from bot import combat - from .utils import format_stat_bar, create_progress_bar - - # Calculate stats - xp_current = player['xp'] - xp_needed = combat.xp_for_level(player['level'] + 1) - xp_for_current_level = combat.xp_for_level(player['level']) - xp_progress = max(0, xp_current - xp_for_current_level) - xp_level_requirement = xp_needed - xp_for_current_level - progress_percent = int((xp_progress / xp_level_requirement) * 100) if xp_level_requirement > 0 else 0 - - unspent = player.get('unspent_points', 0) - - # Build profile with visual bars - profile_text = f"๐Ÿ‘ค {player['name']}\n" - profile_text += f"โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n\n" - profile_text += f"Level: {player['level']}\n" - - # XP bar - xp_bar = create_progress_bar(xp_progress, xp_level_requirement, length=10) - profile_text += f"โญ XP: {xp_bar} {progress_percent}% ({xp_current}/{xp_needed})\n" - - if unspent > 0: - profile_text += f"๐Ÿ’Ž Unspent Points: {unspent}\n" - - profile_text += f"\n{format_stat_bar('HP', 'โค๏ธ', player['hp'], player['max_hp'])}\n" - profile_text += f"{format_stat_bar('Stamina', 'โšก', player['stamina'], player['max_stamina'])}\n\n" - profile_text += f"Stats:\n" - profile_text += f"๐Ÿ’ช Strength: {player['strength']}\n" - profile_text += f"๐Ÿƒ Agility: {player['agility']}\n" - profile_text += f"๐Ÿ’š Endurance: {player['endurance']}\n" - profile_text += f"๐Ÿง  Intellect: {player['intellect']}\n\n" - profile_text += f"Combat:\n" - profile_text += f"โš”๏ธ Base Damage: {5 + player['strength'] // 2 + player['level']}\n" - profile_text += f"๐Ÿ›ก๏ธ Flee Chance: {int((0.5 + player['agility'] / 100) * 100)}%\n" - profile_text += f"๐Ÿ’š Stamina Regen: {1 + player['endurance'] // 10}/cycle\n\n" - - # Show status effects if any - try: - from .api_client import api_client - status_effects = await api_client.get_player_status_effects(user_id) - if status_effects: - from bot.status_utils import get_status_details - from .api_client import api_client - # Check if player is in combat - combat_state = await api_client.get_combat(user_id) - in_combat = combat_state is not None - profile_text += f"Status Effects:\n" - profile_text += get_status_details(status_effects, in_combat=in_combat) + "\n\n" - except: - pass # Status effects not critical, skip if error - - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - # Add spend points button if player has unspent points - keyboard_buttons = [] - if unspent > 0: - keyboard_buttons.append([ - InlineKeyboardButton("โญ Spend Stat Points", callback_data="spend_points_menu") - ]) - keyboard_buttons.append([InlineKeyboardButton("โฌ…๏ธ Back", callback_data="main_menu")]) - back_keyboard = InlineKeyboardMarkup(keyboard_buttons) - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image( - query, - text=profile_text, - reply_markup=back_keyboard, - image_path=location_image - ) - - -async def handle_spend_points_menu(query, user_id: int, player: dict, data: list = None): - """Show menu for spending attribute points.""" - await query.answer() - unspent = player.get('unspent_points', 0) - - if unspent <= 0: - await query.answer("You have no points to spend!", show_alert=False) - return - - text = f"โญ Spend Stat Points\n\n" - text += f"Available Points: {unspent}\n\n" - text += f"Current Stats:\n" - text += f"โค๏ธ Max HP: {player['max_hp']} (+10 per point)\n" - text += f"โšก Max Stamina: {player['max_stamina']} (+5 per point)\n" - text += f"๐Ÿ’ช Strength: {player['strength']} (+1 per point)\n" - text += f"๐Ÿƒ Agility: {player['agility']} (+1 per point)\n" - text += f"๐Ÿ’š Endurance: {player['endurance']} (+1 per point)\n" - text += f"๐Ÿง  Intellect: {player['intellect']} (+1 per point)\n\n" - text += f"๐Ÿ’ก Choose wisely! Each point matters." - - keyboard = keyboards.spend_points_keyboard() - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image(query, text=text, reply_markup=keyboard) - - -async def handle_spend_point(query, user_id: int, player: dict, data: list): - """Spend a stat point on a specific attribute.""" - stat_name = data[1] - unspent = player.get('unspent_points', 0) - - if unspent <= 0: - await query.answer("You have no points to spend!", show_alert=False) - return - - # Map stat names to updates - stat_mapping = { - 'max_hp': ('max_hp', 10, 'โค๏ธ Max HP'), - 'max_stamina': ('max_stamina', 5, 'โšก Max Stamina'), - 'strength': ('strength', 1, '๐Ÿ’ช Strength'), - 'agility': ('agility', 1, '๐Ÿƒ Agility'), - 'endurance': ('endurance', 1, '๐Ÿ’š Endurance'), - 'intellect': ('intellect', 1, '๐Ÿง  Intellect'), - } - - if stat_name not in stat_mapping: - await query.answer("Invalid stat!", show_alert=False) - return - - db_field, increase, display_name = stat_mapping[stat_name] - new_value = player[db_field] + increase - new_unspent = unspent - 1 - - from .api_client import api_client - await api_client.update_player(user_id, { - db_field: new_value, - 'unspent_points': new_unspent - }) - - # Update local player data - player[db_field] = new_value - player['unspent_points'] = new_unspent - - await query.answer(f"+{increase} {display_name}!", show_alert=False) - - # Refresh the spend points menu - text = f"โญ Spend Stat Points\n\n" - text += f"Available Points: {new_unspent}\n\n" - text += f"Current Stats:\n" - text += f"โค๏ธ Max HP: {player['max_hp']} (+10 per point)\n" - text += f"โšก Max Stamina: {player['max_stamina']} (+5 per point)\n" - text += f"๐Ÿ’ช Strength: {player['strength']} (+1 per point)\n" - text += f"๐Ÿƒ Agility: {player['agility']} (+1 per point)\n" - text += f"๐Ÿ’š Endurance: {player['endurance']} (+1 per point)\n" - text += f"๐Ÿง  Intellect: {player['intellect']} (+1 per point)\n\n" - text += f"๐Ÿ’ก Choose wisely! Each point matters." - - keyboard = keyboards.spend_points_keyboard() - - from .handlers import send_or_edit_with_image - await send_or_edit_with_image(query, text=text, reply_markup=keyboard) diff --git a/old/bot/spawn_manager.py b/old/bot/spawn_manager.py deleted file mode 100644 index ed2cd05..0000000 --- a/old/bot/spawn_manager.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -Global Wandering Enemy Spawn Manager -Runs periodically to spawn/despawn enemies based on location danger levels. -""" -import asyncio -import logging -import random -from typing import Dict, List -from bot import database -from data.npcs import ( - LOCATION_SPAWNS, - LOCATION_DANGER, - get_random_npc_for_location, - get_wandering_enemy_chance -) - -logger = logging.getLogger(__name__) - - -# Configuration -SPAWN_CHECK_INTERVAL = 120 # Check every 2 minutes -ENEMY_LIFETIME = 600 # Enemies live for 10 minutes -MAX_ENEMIES_PER_LOCATION = { - 0: 0, # Safe zones - no wandering enemies - 1: 1, # Low danger - max 1 enemy - 2: 2, # Medium danger - max 2 enemies - 3: 3, # High danger - max 3 enemies - 4: 4, # Extreme danger - max 4 enemies -} - - -def get_danger_level(location_id: str) -> int: - """Get danger level for a location.""" - danger_data = LOCATION_DANGER.get(location_id, (0, 0.0, 0.0)) - return danger_data[0] - - -async def spawn_manager_loop(): - """ - Main spawn manager loop. - Runs continuously, checking spawn conditions every SPAWN_CHECK_INTERVAL seconds. - """ - logger.info("๐ŸŽฒ Spawn Manager started") - - while True: - try: - await asyncio.sleep(SPAWN_CHECK_INTERVAL) - - # Clean up expired enemies first - despawned_count = await database.cleanup_expired_wandering_enemies() - if despawned_count > 0: - logger.info(f"๐Ÿงน Cleaned up {despawned_count} expired wandering enemies") - - # Process each location - spawned_count = 0 - for location_id, spawn_table in LOCATION_SPAWNS.items(): - if not spawn_table: - continue # Skip locations with no spawns - - # Get danger level and max enemies for this location - danger_level = get_danger_level(location_id) - max_enemies = MAX_ENEMIES_PER_LOCATION.get(danger_level, 0) - - if max_enemies == 0: - continue # Skip safe zones - - # Check current enemy count - current_count = await database.get_wandering_enemy_count_in_location(location_id) - - if current_count >= max_enemies: - continue # Location is at capacity - - # Calculate spawn chance based on wandering_enemy_chance - spawn_chance = get_wandering_enemy_chance(location_id) - - # Attempt to spawn enemies up to max capacity - for _ in range(max_enemies - current_count): - if random.random() < spawn_chance: - # Spawn an enemy - npc_id = get_random_npc_for_location(location_id) - if npc_id: - await database.spawn_wandering_enemy( - npc_id=npc_id, - location_id=location_id, - lifetime_seconds=ENEMY_LIFETIME - ) - spawned_count += 1 - logger.info(f"๐Ÿ‘น Spawned {npc_id} at {location_id} (current: {current_count + 1}/{max_enemies})") - - if spawned_count > 0: - logger.info(f"โœจ Spawn cycle complete: {spawned_count} enemies spawned") - - except Exception as e: - logger.error(f"โŒ Error in spawn manager loop: {e}", exc_info=True) - # Continue running even if there's an error - await asyncio.sleep(10) - - -async def start_spawn_manager(): - """Start the spawn manager as a background task.""" - asyncio.create_task(spawn_manager_loop()) - logger.info("๐ŸŽฎ Spawn Manager initialized") - - -async def get_spawn_stats() -> Dict: - """Get statistics about current spawns (for debugging/monitoring).""" - all_enemies = await database.get_all_active_wandering_enemies() - - # Count by location - location_counts = {} - for enemy in all_enemies: - loc = enemy['location_id'] - location_counts[loc] = location_counts.get(loc, 0) + 1 - - return { - "total_active": len(all_enemies), - "by_location": location_counts, - "enemies": all_enemies - } diff --git a/old/bot/status_utils.py b/old/bot/status_utils.py deleted file mode 100644 index 530db42..0000000 --- a/old/bot/status_utils.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -Status effect utilities for display and management. -""" -from collections import defaultdict - - -def stack_status_effects(effects: list) -> dict: - """ - Stack status effects by name, summing damage and counting stacks. - - Args: - effects: List of dicts with keys: effect_name, effect_icon, damage_per_tick, ticks_remaining - - Returns: - Dict with keys: effect_name -> {icon, total_damage, stacks, min_ticks, effects: [list of effect dicts]} - """ - stacked = defaultdict(lambda: { - 'icon': '', - 'total_damage': 0, - 'stacks': 0, - 'min_ticks': float('inf'), - 'max_ticks': 0, - 'effects': [] - }) - - for effect in effects: - name = effect['effect_name'] - stacked[name]['icon'] = effect['effect_icon'] - stacked[name]['total_damage'] += effect.get('damage_per_tick', 0) - stacked[name]['stacks'] += 1 - stacked[name]['min_ticks'] = min(stacked[name]['min_ticks'], effect['ticks_remaining']) - stacked[name]['max_ticks'] = max(stacked[name]['max_ticks'], effect['ticks_remaining']) - stacked[name]['effects'].append(effect) - - return dict(stacked) - - -def get_status_summary(effects: list, in_combat: bool = False) -> str: - """ - Generate compact status summary for display in menus. - - Args: - effects: List of status effect dicts - in_combat: If True, show "turns" instead of "cycles" - - Returns: - String like "Statuses: ๐Ÿฉธ (-4), โ˜ฃ๏ธ (-3)" or empty string if no effects - """ - if not effects: - return "" - - stacked = stack_status_effects(effects) - - if not stacked: - return "" - - parts = [] - for name, data in stacked.items(): - if data['total_damage'] > 0: - parts.append(f"{data['icon']} (-{data['total_damage']})") - else: - parts.append(f"{data['icon']}") - - return "Statuses: " + ", ".join(parts) - - -def get_status_details(effects: list, in_combat: bool = False) -> str: - """ - Generate detailed status display for profile menu. - - Args: - effects: List of status effect dicts - in_combat: If True, show "turns" instead of "cycles" - - Returns: - Multi-line string with detailed effect info - """ - if not effects: - return "No active status effects." - - stacked = stack_status_effects(effects) - - lines = [] - for name, data in stacked.items(): - # Build effect line - effect_line = f"{data['icon']} {name.replace('_', ' ').title()}" - - # Add damage info - if data['total_damage'] > 0: - effect_line += f": -{data['total_damage']} HP/{'turn' if in_combat else 'cycle'}" - - # Add tick info - if data['stacks'] == 1: - tick_unit = 'turn' if in_combat else 'cycle' - tick_count = data['min_ticks'] - effect_line += f" ({tick_count} {tick_unit}{'s' if tick_count != 1 else ''} left)" - else: - tick_unit = 'turns' if in_combat else 'cycles' - if data['min_ticks'] == data['max_ticks']: - effect_line += f" (ร—{data['stacks']}, {data['min_ticks']} {tick_unit} left)" - else: - effect_line += f" (ร—{data['stacks']}, {data['min_ticks']}-{data['max_ticks']} {tick_unit} left)" - - lines.append(effect_line) - - return "\n".join(lines) - - -def calculate_status_damage(effects: list) -> int: - """ - Calculate total damage from all status effects. - - Args: - effects: List of status effect dicts - - Returns: - Total damage per tick - """ - return sum(effect.get('damage_per_tick', 0) for effect in effects) diff --git a/old/bot/utils.py b/old/bot/utils.py deleted file mode 100644 index df90fe3..0000000 --- a/old/bot/utils.py +++ /dev/null @@ -1,128 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Utility functions and decorators for the bot. -""" -import os -import functools -import logging -from telegram import Update -from telegram.ext import ContextTypes - -logger = logging.getLogger(__name__) - - -def create_progress_bar(current: int, maximum: int, length: int = 10, filled_char: str = "โ–ˆ", empty_char: str = "โ–‘") -> str: - """ - Create a visual progress bar. - - Args: - current: Current value - maximum: Maximum value - length: Length of the bar in characters (default 10) - filled_char: Character for filled portion (default โ–ˆ) - empty_char: Character for empty portion (default โ–‘) - - Returns: - String representation of progress bar - - Examples: - >>> create_progress_bar(75, 100) - "โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘" - >>> create_progress_bar(0, 100) - "โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘" - >>> create_progress_bar(100, 100) - "โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ" - """ - if maximum <= 0: - return empty_char * length - - percentage = min(1.0, max(0.0, current / maximum)) - filled_length = int(length * percentage) - empty_length = length - filled_length - - return filled_char * filled_length + empty_char * empty_length - - -def format_stat_bar(label: str, emoji: str, current: int, maximum: int, bar_length: int = 10, label_width: int = 7) -> str: - """ - Format a stat (HP, Stamina, etc.) with visual progress bar. - Uses right-aligned label format to avoid alignment issues with Telegram's proportional font. - - Args: - label: Stat label (e.g., "HP", "Stamina", "Your HP") - emoji: Emoji to display (e.g., "โค๏ธ", "โšก", "๐Ÿ•") - current: Current value - maximum: Maximum value - bar_length: Length of the progress bar - label_width: Not used, kept for backwards compatibility - - Returns: - Formatted string with bar on left, label on right - - Examples: - >>> format_stat_bar("HP", "โค๏ธ", 75, 100) - "โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 75% (75/100) โค๏ธ HP" - >>> format_stat_bar("Stamina", "โšก", 50, 100) - "โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘ 50% (50/100) โšก Stamina" - """ - bar = create_progress_bar(current, maximum, bar_length) - percentage = int((current / maximum * 100)) if maximum > 0 else 0 - - # Right-aligned format: bar first, then stats, then emoji + label - # This way bars are always left-aligned regardless of label length - if emoji: - return f"{bar} {percentage}% ({current}/{maximum}) {emoji} {label}" - else: - # If no emoji provided, just use label - return f"{bar} {percentage}% ({current}/{maximum}) {label}" - - - -def get_admin_ids(): - """Get the list of admin user IDs from environment variable.""" - admin_ids_str = os.getenv("ADMIN_IDS", "") - if not admin_ids_str: - logger.warning("ADMIN_IDS not set in .env file. No admins configured.") - return set() - - try: - # Parse comma-separated list of IDs - admin_ids = set(int(id.strip()) for id in admin_ids_str.split(",") if id.strip()) - return admin_ids - except ValueError as e: - logger.error(f"Error parsing ADMIN_IDS: {e}") - return set() - - -def admin_only(func): - """ - Decorator that restricts command to admin users only. - - Usage: - @admin_only - async def my_admin_command(update: Update, context: ContextTypes.DEFAULT_TYPE): - ... - """ - @functools.wraps(func) - async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs): - user_id = update.effective_user.id - admin_ids = get_admin_ids() - - if user_id not in admin_ids: - await update.message.reply_html( - "๐Ÿšซ Access Denied\n\n" - "This command is restricted to administrators only." - ) - logger.warning(f"User {user_id} attempted to use admin command: {func.__name__}") - return - - # User is admin, execute the command - return await func(update, context, *args, **kwargs) - - return wrapper - - -def is_admin(user_id: int) -> bool: - """Check if a user ID is an admin.""" - admin_ids = get_admin_ids() - return user_id in admin_ids diff --git a/old/main.py b/old/main.py deleted file mode 100644 index 3f7c08f..0000000 --- a/old/main.py +++ /dev/null @@ -1,87 +0,0 @@ -import asyncio -import logging -import signal -import os - -from dotenv import load_dotenv -from telegram import Update -from telegram.ext import Application, CommandHandler, CallbackQueryHandler - -from bot import database, handlers -from bot import background_tasks - -# Enable logging -logging.basicConfig( - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO -) -# Quieten down the HTTPX logger, which is very verbose -logging.getLogger("httpx").setLevel(logging.WARNING) -logger = logging.getLogger(__name__) - -# A global event to signal shutdown -shutdown_event = asyncio.Event() - -def signal_handler(sig, frame): - """Gracefully handle shutdown signals.""" - logger.info("Shutdown signal received. Shutting down gracefully...") - shutdown_event.set() - -async def main() -> None: - """Start the bot and wait for a shutdown signal.""" - load_dotenv() - TOKEN = os.getenv("TELEGRAM_TOKEN") - - if not TOKEN or TOKEN == "YOUR_TELEGRAM_BOT_TOKEN_HERE": - logger.error("TELEGRAM_TOKEN is not set! Please edit your .env file.") - return - - await database.create_tables() - - application = Application.builder().token(TOKEN).build() - - application.add_handler(CommandHandler("start", handlers.start)) - application.add_handler(CommandHandler("map", handlers.export_map)) - application.add_handler(CommandHandler("spawns", handlers.spawn_stats)) - application.add_handler(CallbackQueryHandler(handlers.button_handler)) - - async with application: - await application.start() - await application.updater.start_polling(allowed_updates=Update.ALL_TYPES) - logger.info("Bot is running and polling for updates...") - - # Start the spawn manager - from bot import spawn_manager - await spawn_manager.start_spawn_manager() - - # Start the background tasks - logger.info("Starting background tasks...") - decay_task = asyncio.create_task(background_tasks.decay_dropped_items(shutdown_event)) - stamina_task = asyncio.create_task(background_tasks.regenerate_stamina(shutdown_event)) - combat_timer_task = asyncio.create_task(background_tasks.check_combat_timers(shutdown_event)) - corpse_decay_task = asyncio.create_task(background_tasks.decay_corpses(shutdown_event)) - status_effects_task = asyncio.create_task(background_tasks.process_status_effects(shutdown_event)) - logger.info("โœ… All background tasks started") - - await shutdown_event.wait() - - await application.updater.stop() - await application.stop() - - # Ensure the background tasks are also cancelled on shutdown - logger.info("Stopping background tasks...") - decay_task.cancel() - stamina_task.cancel() - combat_timer_task.cancel() - corpse_decay_task.cancel() - status_effects_task.cancel() - logger.info("Bot has been shut down.") - - -if __name__ == "__main__": - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - - try: - asyncio.run(main()) - except (KeyboardInterrupt, SystemExit): - logger.info("Main function interrupted.") diff --git a/old/migrate_account_character_split.py b/old/migrate_account_character_split.py deleted file mode 100644 index e69de29..0000000 diff --git a/old/migrate_account_player_separation.py b/old/migrate_account_player_separation.py deleted file mode 100644 index eb14046..0000000 --- a/old/migrate_account_player_separation.py +++ /dev/null @@ -1,410 +0,0 @@ -#!/usr/bin/env python3 -""" -Major Database Migration: Account/Player Separation -==================================================== - -This migration separates authentication (accounts) from gameplay (characters): -- Creates new 'accounts' table for login credentials -- Creates new 'characters' table for game data -- Migrates existing 'players' data to both tables -- Updates foreign keys in related tables -- Drops old 'players' table - -IMPORTANT: This is a breaking change. Backup your database first! -""" - -import asyncio -import asyncpg -import os -from datetime import datetime - -# Database connection -DB_USER = os.getenv("POSTGRES_USER") -DB_PASS = os.getenv("POSTGRES_PASSWORD") -DB_NAME = os.getenv("POSTGRES_DB") -DB_HOST = os.getenv("POSTGRES_HOST", "echoes_of_the_ashes_db") -DB_PORT = os.getenv("POSTGRES_PORT", "5432") - -DATABASE_URL = f"postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}" - - -async def main(): - print("=" * 70) - print("ACCOUNT/PLAYER SEPARATION MIGRATION") - print("=" * 70) - print() - - conn = await asyncpg.connect(DATABASE_URL) - - try: - # Step 0: Check if migration already ran - print("Step 0: Checking migration status...") - tables_exist = await conn.fetchval(""" - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'accounts' - ) - """) - - if tables_exist: - print("โš ๏ธ Accounts table already exists. Migration may have already run.") - print(" Cleaning up previous migration attempt...") - await conn.execute("DROP TABLE IF EXISTS characters CASCADE;") - await conn.execute("DROP TABLE IF EXISTS accounts CASCADE;") - await conn.execute("DROP TABLE IF EXISTS players_backup_20251109 CASCADE;") - print("โœ… Cleaned up existing tables") - print() - - # Step 1: Backup existing players table - print("Step 1: Creating backup of players table...") - await conn.execute(""" - CREATE TABLE IF NOT EXISTS players_backup_20251109 AS - SELECT * FROM players; - """) - backup_count = await conn.fetchval( - "SELECT COUNT(*) FROM players_backup_20251109" - ) - print(f"โœ… Backed up {backup_count} players to players_backup_20251109") - print() - - # Step 2: Create temporary mapping table - print("Step 2: Creating temporary mapping table...") - await conn.execute(""" - CREATE TEMP TABLE IF NOT EXISTS player_character_mapping ( - old_player_id INTEGER, - new_character_id INTEGER - ); - """) - print("โœ… Created temporary mapping table") - print() - - # Step 3: Create accounts table - print("Step 3: Creating accounts table...") - await conn.execute(""" - CREATE TABLE IF NOT EXISTS accounts ( - id SERIAL PRIMARY KEY, - email VARCHAR(255) UNIQUE NOT NULL, - password_hash VARCHAR(255), - steam_id VARCHAR(255) UNIQUE, - account_type VARCHAR(20) DEFAULT 'web', - premium_expires_at REAL, - email_verified BOOLEAN DEFAULT FALSE, - email_verification_token VARCHAR(255), - password_reset_token VARCHAR(255), - password_reset_expires REAL, - created_at REAL DEFAULT EXTRACT(EPOCH FROM NOW()), - last_login_at REAL, - CONSTRAINT check_account_type CHECK (account_type IN ('web', 'steam')) - ); - """) - print("โœ… Created accounts table") - - # Create indexes for accounts - await conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_accounts_email ON accounts(email); - """) - await conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_accounts_steam_id ON accounts(steam_id); - """) - print("โœ… Created indexes on accounts table") - print() - - # Step 4: Create characters table - print("Step 4: Creating characters table...") - await conn.execute(""" - CREATE TABLE IF NOT EXISTS characters ( - id SERIAL PRIMARY KEY, - account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, - name VARCHAR(100) UNIQUE NOT NULL, - avatar_data TEXT, - - -- RPG Stats - level INTEGER DEFAULT 1, - xp INTEGER DEFAULT 0, - hp INTEGER DEFAULT 100, - max_hp INTEGER DEFAULT 100, - stamina INTEGER DEFAULT 100, - max_stamina INTEGER DEFAULT 100, - - -- Base Attributes - strength INTEGER DEFAULT 0, - agility INTEGER DEFAULT 0, - endurance INTEGER DEFAULT 0, - intellect INTEGER DEFAULT 0, - unspent_points INTEGER DEFAULT 0, - - -- Game State - location_id VARCHAR(255) DEFAULT 'cabin', - is_dead BOOLEAN DEFAULT FALSE, - last_movement_time REAL DEFAULT 0, - - -- Timestamps - created_at REAL DEFAULT EXTRACT(EPOCH FROM NOW()), - last_played_at REAL DEFAULT EXTRACT(EPOCH FROM NOW()), - - CONSTRAINT check_unspent_points CHECK (unspent_points >= 0) - ); - """) - print("โœ… Created characters table") - - # Create indexes for characters - await conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_characters_account_id ON characters(account_id); - """) - await conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_characters_name ON characters(name); - """) - await conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_characters_location_id ON characters(location_id); - """) - print("โœ… Created indexes on characters table") - print() - - # Step 5: Migrate existing players to accounts and characters - print("Step 5: Migrating existing players...") - - # Get all existing players - players = await conn.fetch("SELECT * FROM players ORDER BY id") - print(f"Found {len(players)} players to migrate") - - migrated = 0 - character_names_used = set() - - for player in players: - # Generate email if not present - email = player['email'] - if not email: - username = player['username'] or f"player_{player['id']}" - email = f"{username}@echoes-migrated.local" - - # Ensure unique character name - char_name = player['name'] - if char_name in character_names_used or char_name == "Survivor": - # Make it unique - char_name = f"{player['username'] or 'Survivor'}_{player['id']}" - character_names_used.add(char_name) - - # Convert to timestamp (float) - now_timestamp = datetime.utcnow().timestamp() - - # Create account - account_id = await conn.fetchval(""" - INSERT INTO accounts ( - email, password_hash, steam_id, account_type, - premium_expires_at, created_at, last_login_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id - """, - email, - player['password_hash'], - player.get('steam_id'), - player.get('account_type', 'web'), - player.get('premium_expires_at'), # Keep as is (NULL or timestamp) - now_timestamp, - now_timestamp - ) - - # Create character from player data - character_id = await conn.fetchval(""" - INSERT INTO characters ( - account_id, name, avatar_data, - level, xp, hp, max_hp, stamina, max_stamina, - strength, agility, endurance, intellect, unspent_points, - location_id, is_dead, last_movement_time, - created_at, last_played_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) - RETURNING id - """, - account_id, - char_name, # Use unique character name - None, # avatar_data - player['level'], - player['xp'], - player['hp'], - player['max_hp'], - player['stamina'], - player['max_stamina'], - player['strength'], - player['agility'], - player['endurance'], - player['intellect'], - player['unspent_points'], - player['location_id'], - player['is_dead'], - player['last_movement_time'], - now_timestamp, - now_timestamp - ) - - # Store mapping for foreign key updates - await conn.execute(""" - INSERT INTO player_character_mapping (old_player_id, new_character_id) - VALUES ($1, $2) - """, player['id'], character_id) - - migrated += 1 - if migrated % 10 == 0: - print(f" Migrated {migrated}/{len(players)} players...") - - print("โœ… Migrated {migrated} players to accounts and characters") - print() - - # Step 6: Update foreign keys in related tables - print("Step 6: Updating foreign keys in related tables...") - - # Update inventory table - if await conn.fetchval(""" - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'inventory' - ) - """): - print(" Updating inventory.player_id -> character_id...") - - # Add new character_id column - await conn.execute(""" - ALTER TABLE inventory - ADD COLUMN IF NOT EXISTS character_id INTEGER; - """) - - # Copy player_id to character_id using mapping - await conn.execute(""" - UPDATE inventory i - SET character_id = m.new_character_id - FROM player_character_mapping m - WHERE i.player_id = m.old_player_id; - """) - - # Drop old player_id column and rename - await conn.execute(""" - ALTER TABLE inventory DROP COLUMN IF EXISTS player_id; - """) - - # Add foreign key constraint - await conn.execute(""" - ALTER TABLE inventory - ADD CONSTRAINT fk_inventory_character - FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE; - """) - - print(" โœ… Updated inventory table") - - # Update equipment table - if await conn.fetchval(""" - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'equipment' - ) - """): - print(" Updating equipment.player_id -> character_id...") - - await conn.execute(""" - ALTER TABLE equipment - ADD COLUMN IF NOT EXISTS character_id INTEGER; - """) - - await conn.execute(""" - UPDATE equipment e - SET character_id = m.new_character_id - FROM player_character_mapping m - WHERE e.player_id = m.old_player_id; - """) - - await conn.execute(""" - ALTER TABLE equipment DROP COLUMN IF EXISTS player_id; - """) - - await conn.execute(""" - ALTER TABLE equipment - ADD CONSTRAINT fk_equipment_character - FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE; - """) - - print(" โœ… Updated equipment table") - - # Update dropped_items table - if await conn.fetchval(""" - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'dropped_items' - ) - """): - # Check if column exists - has_player_col = await conn.fetchval(""" - SELECT EXISTS ( - SELECT FROM information_schema.columns - WHERE table_name = 'dropped_items' - AND column_name = 'dropped_by_player_id' - ) - """) - - if has_player_col: - print(" Updating dropped_items.dropped_by_player_id -> dropped_by_character_id...") - - await conn.execute(""" - ALTER TABLE dropped_items - ADD COLUMN IF NOT EXISTS dropped_by_character_id INTEGER; - """) - - await conn.execute(""" - UPDATE dropped_items d - SET dropped_by_character_id = m.new_character_id - FROM player_character_mapping m - WHERE d.dropped_by_player_id = m.old_player_id; - """) - - await conn.execute(""" - ALTER TABLE dropped_items DROP COLUMN IF EXISTS dropped_by_player_id; - """) - - print(" โœ… Updated dropped_items table") - else: - print(" โญ๏ธ Skipping dropped_items (no dropped_by_player_id column)") - - print("โœ… Updated all foreign key references") - print() - - # Step 7: Drop old players table - print("Step 7: Dropping old players table...") - print("โš ๏ธ WARNING: About to drop players table (backup exists as players_backup_20251109)") - print(" Press Ctrl+C within 5 seconds to cancel...") - await asyncio.sleep(5) - - await conn.execute("DROP TABLE IF EXISTS players;") - print("โœ… Dropped players table") - print() - - # Step 8: Summary - print("=" * 70) - print("MIGRATION COMPLETED SUCCESSFULLY!") - print("=" * 70) - - account_count = await conn.fetchval("SELECT COUNT(*) FROM accounts") - character_count = await conn.fetchval("SELECT COUNT(*) FROM characters") - - print(f"โœ… Created {account_count} accounts") - print(f"โœ… Created {character_count} characters") - print(f"โœ… Backup preserved in: players_backup_20251109") - print() - print("Next steps:") - print("1. Update application code to use new schema") - print("2. Rebuild and restart API container") - print("3. Test authentication and character selection") - print("4. Update frontend to show character selection screen") - print() - - except Exception as e: - print(f"\nโŒ ERROR: {e}") - print("\nRolling back changes...") - await conn.execute("ROLLBACK;") - print("Migration failed. Database unchanged.") - raise - - finally: - await conn.close() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/old/migrate_interactable_cooldowns.py b/old/migrate_interactable_cooldowns.py deleted file mode 100644 index f046f88..0000000 --- a/old/migrate_interactable_cooldowns.py +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env python3 -""" -Migration script to update interactable_cooldowns table schema. -Changes from single instance_id to instance_id + action_id composite key. -""" -import asyncio -from api.database import engine -from sqlalchemy import text - - -async def migrate(): - """Drop and recreate interactable_cooldowns table with new schema.""" - async with engine.begin() as conn: - print("๐Ÿ”„ Migrating interactable_cooldowns table...") - - # Drop old table - await conn.execute(text("DROP TABLE IF EXISTS interactable_cooldowns CASCADE")) - print("โœ… Dropped old interactable_cooldowns table") - - # Create new table with updated schema - await conn.execute(text(""" - CREATE TABLE interactable_cooldowns ( - id SERIAL PRIMARY KEY, - interactable_instance_id VARCHAR NOT NULL, - action_id VARCHAR NOT NULL, - expiry_timestamp DOUBLE PRECISION NOT NULL, - CONSTRAINT uix_interactable_action UNIQUE (interactable_instance_id, action_id) - ) - """)) - print("โœ… Created new interactable_cooldowns table with per-action cooldowns") - - # Create index for faster lookups - await conn.execute(text(""" - CREATE INDEX IF NOT EXISTS idx_interactable_cooldowns_expiry - ON interactable_cooldowns(expiry_timestamp) - """)) - print("โœ… Created index on expiry_timestamp") - - print("โœจ Migration complete!") - - -if __name__ == "__main__": - asyncio.run(migrate()) diff --git a/old/migrate_remove_pvp_hp_fields.py b/old/migrate_remove_pvp_hp_fields.py deleted file mode 100644 index 787f498..0000000 --- a/old/migrate_remove_pvp_hp_fields.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 -""" -Migration: Remove attacker_hp and defender_hp columns from pvp_combats table -These fields are no longer needed as we use player HP directly from players table. -""" -import asyncio -from api.database import engine -from sqlalchemy import text - -async def migrate(): - """Remove HP fields from pvp_combats table""" - - async with engine.begin() as conn: - print("๐Ÿ”ง Starting migration: Remove attacker_hp and defender_hp from pvp_combats...") - - # Check if columns exist - check_query = text(""" - SELECT column_name - FROM information_schema.columns - WHERE table_name = 'pvp_combats' - AND column_name IN ('attacker_hp', 'defender_hp') - """) - result = await conn.execute(check_query) - existing_columns = result.fetchall() - - if not existing_columns: - print("โœ… Columns already removed. Nothing to do.") - return - - column_names = [row[0] for row in existing_columns] - print(f"Found {len(existing_columns)} column(s) to remove: {column_names}") - - # Drop the columns - if 'attacker_hp' in column_names: - print("Dropping attacker_hp column...") - await conn.execute(text("ALTER TABLE pvp_combats DROP COLUMN IF EXISTS attacker_hp")) - print("โœ… Dropped attacker_hp") - - if 'defender_hp' in column_names: - print("Dropping defender_hp column...") - await conn.execute(text("ALTER TABLE pvp_combats DROP COLUMN IF EXISTS defender_hp")) - print("โœ… Dropped defender_hp") - - print("โœ… Migration completed successfully!") - -if __name__ == "__main__": - asyncio.run(migrate()) - - diff --git a/old/migrate_steam_support.py b/old/migrate_steam_support.py deleted file mode 100644 index bdc1659..0000000 --- a/old/migrate_steam_support.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -Migration: Add Steam support and remove Telegram -- Remove telegram_id column -- Add steam_id column -- Add email column (required for web users) -- Add premium_expires_at column (NULL = premium forever, timestamp = expires at that time) -- Add account_type ENUM ('web', 'steam') -""" - -import asyncio -import sys -import os - -# Add parent directory to path -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from api.database import engine, metadata -from sqlalchemy import text - -async def migrate(): - """Run migration""" - print("๐Ÿ”„ Starting Steam support migration...") - - async with engine.begin() as conn: - # 1. Add new columns - print("๐Ÿ“ Adding new columns...") - - try: - await conn.execute(text(""" - ALTER TABLE players - ADD COLUMN steam_id VARCHAR(255) UNIQUE, - ADD COLUMN email VARCHAR(255), - ADD COLUMN premium_expires_at TIMESTAMP, - ADD COLUMN account_type VARCHAR(20) DEFAULT 'web' - """)) - print(" โœ… Added: steam_id, email, premium_expires_at, account_type") - except Exception as e: - if "already exists" in str(e).lower(): - print(" โš ๏ธ Columns already exist, skipping...") - else: - raise - - # 2. Create index on steam_id for fast lookups - print("๐Ÿ“ Creating indexes...") - try: - await conn.execute(text(""" - CREATE INDEX IF NOT EXISTS idx_players_steam_id ON players(steam_id) - """)) - await conn.execute(text(""" - CREATE INDEX IF NOT EXISTS idx_players_email ON players(email) - """)) - print(" โœ… Created indexes on steam_id and email") - except Exception as e: - print(f" โš ๏ธ Index creation warning: {e}") - - # 3. Set account_type for existing users - print("๐Ÿ“ Setting account_type for existing users...") - result = await conn.execute(text(""" - UPDATE players - SET account_type = CASE - WHEN telegram_id IS NOT NULL THEN 'telegram' - ELSE 'web' - END - WHERE account_type IS NULL OR account_type = 'web' - """)) - print(f" โœ… Updated {result.rowcount} existing users") - - # 4. Check if telegram_id column exists before trying to drop it - print("๐Ÿ“ Checking for telegram_id column...") - result = await conn.execute(text(""" - SELECT column_name - FROM information_schema.columns - WHERE table_name = 'players' AND column_name = 'telegram_id' - """)) - has_telegram = result.fetchone() is not None - - if has_telegram: - # Count how many telegram users we have - result = await conn.execute(text(""" - SELECT COUNT(*) FROM players WHERE telegram_id IS NOT NULL - """)) - telegram_count = result.fetchone()[0] - - if telegram_count > 0: - print(f" โš ๏ธ Found {telegram_count} Telegram users") - print(f" โš ๏ธ Telegram support is deprecated, but keeping data for now") - print(f" โ„น๏ธ To fully remove: DROP COLUMN telegram_id (manual step)") - else: - print(" โœ… No Telegram users found") - # Safely drop the column if no users - try: - await conn.execute(text("ALTER TABLE players DROP COLUMN telegram_id")) - print(" โœ… Dropped telegram_id column") - except Exception as e: - print(f" โš ๏ธ Could not drop telegram_id: {e}") - else: - print(" โœ… telegram_id column already removed") - - # 5. Add CHECK constraint for account_type - print("๐Ÿ“ Adding constraints...") - try: - await conn.execute(text(""" - ALTER TABLE players - ADD CONSTRAINT check_account_type - CHECK (account_type IN ('web', 'steam', 'telegram')) - """)) - print(" โœ… Added account_type constraint") - except Exception as e: - if "already exists" in str(e).lower(): - print(" โš ๏ธ Constraint already exists") - else: - print(f" โš ๏ธ Could not add constraint: {e}") - - # 6. Make email required for web users (but allow NULL for steam/legacy) - # This is a soft requirement - we'll enforce it in the application layer - - print("โœ… Migration completed successfully!") - print("\n๐Ÿ“‹ Summary:") - print(" - Added steam_id column (unique, indexed)") - print(" - Added email column (required for web registration)") - print(" - Added premium_expires_at (NULL = premium, timestamp = free trial)") - print(" - Added account_type ('web', 'steam', 'telegram')") - print(" - Kept telegram_id for existing users (deprecated)") - print("\n๐Ÿ’ก Next steps:") - print(" 1. Update registration endpoint to require email") - print(" 2. Implement Steam authentication flow") - print(" 3. Add premium tier restrictions") - print(" 4. Migrate telegram users or archive their data") - -if __name__ == "__main__": - asyncio.run(migrate()) diff --git a/pwa/src/components/Game.css b/pwa/src/components/Game.css index 0d406a6..0ae9a80 100644 --- a/pwa/src/components/Game.css +++ b/pwa/src/components/Game.css @@ -1052,6 +1052,35 @@ body.no-scroll { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; + max-height: 400px; + overflow-y: auto; + padding-right: 5px; + /* Custom scrollbar */ + scrollbar-width: thin; + scrollbar-color: var(--game-border-active) rgba(0, 0, 0, 0.3); + /* Ensure overflow doesn't clip dropdowns if possible, + but for scrolling lists we need overflow-y: auto. + Dropdowns must use fixed position or Portal if they need to escape. + However, we can try to make sure cards have z-index context */ +} + +/* Ensure cards handle their own stacking context */ +.entity-card { + position: relative; + /* ... existing styles ... */ +} + +.entity-list::-webkit-scrollbar { + width: 6px; +} + +.entity-list::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.3); +} + +.entity-list::-webkit-scrollbar-thumb { + background-color: var(--game-border-active); + border-radius: 3px; } .entity-card { @@ -1879,13 +1908,9 @@ body.no-scroll { /* Changed from center to space-between */ gap: 0.25rem; /* Fixed dimensions for consistent sizing */ - min-height: 150px; - min-width: 110px; - /* Increased to 150px for better visual balance */ - max-height: 150px; - max-width: 110px; - height: 150px; - width: 110px; + min-height: 100px; + min-width: 80px; + max-width: 100%; transition: all 0.2s; cursor: pointer; overflow: visible; @@ -1894,6 +1919,29 @@ body.no-scroll { /* For tooltip positioning */ } +/* Constrain item images in sidebar */ +/* Constrain item images in sidebar */ +.equipment-slot-image { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-position: center; + border-radius: 6px; + opacity: 0.6; + z-index: 1; + /* Below text/buttons */ +} + +/* Ensure content is above image */ +.equipment-slot>*:not(.equipment-slot-image) { + z-index: 10; + position: relative; +} + + .equipment-slot.large { min-width: 150px; } @@ -4137,4 +4185,84 @@ body.no-scroll { /* Utility classes */ .text-danger { color: #ff4444 !important; +} + +/* --- Combat Actions --- */ +.combat-actions { + display: flex; + flex-direction: column; + gap: 1rem; + min-height: 220px; + /* Reserve space for grid to prevent layout shift */ + justify-content: center; + /* Center content vertically */ +} + +.combat-actions-group { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.combat-actions-group .btn { + padding: 1rem; + font-size: 1.1rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + transition: all 0.2s; +} + +/* Specific Combat Buttons */ +.btn-attack { + background: linear-gradient(180deg, rgba(220, 38, 38, 0.2) 0%, rgba(153, 27, 27, 0.3) 100%); + border: 1px solid rgba(220, 38, 38, 0.5); + color: #fca5a5; +} + +.btn-attack:hover:not(:disabled) { + background: linear-gradient(180deg, rgba(220, 38, 38, 0.3) 0%, rgba(153, 27, 27, 0.4) 100%); + border-color: #f87171; + box-shadow: 0 0 15px rgba(220, 38, 38, 0.3); + transform: translateY(-2px); +} + +.btn-defend { + background: linear-gradient(180deg, rgba(37, 99, 235, 0.2) 0%, rgba(30, 64, 175, 0.3) 100%); + border: 1px solid rgba(37, 99, 235, 0.5); + color: #93c5fd; +} + +.btn-defend:hover:not(:disabled) { + background: linear-gradient(180deg, rgba(37, 99, 235, 0.3) 0%, rgba(30, 64, 175, 0.4) 100%); + border-color: #60a5fa; + box-shadow: 0 0 15px rgba(37, 99, 235, 0.3); + transform: translateY(-2px); +} + +.btn-supplies { + background: linear-gradient(180deg, rgba(217, 119, 6, 0.2) 0%, rgba(180, 83, 9, 0.3) 100%); + border: 1px solid rgba(217, 119, 6, 0.5); + color: #fcd34d; +} + +.btn-supplies:hover:not(:disabled) { + background: linear-gradient(180deg, rgba(217, 119, 6, 0.3) 0%, rgba(180, 83, 9, 0.4) 100%); + border-color: #fbbf24; + box-shadow: 0 0 15px rgba(217, 119, 6, 0.3); + transform: translateY(-2px); +} + +.btn-flee { + background: linear-gradient(180deg, rgba(75, 85, 99, 0.2) 0%, rgba(55, 65, 81, 0.3) 100%); + border: 1px solid rgba(75, 85, 99, 0.5); + color: #e5e7eb; +} + +.btn-flee:hover:not(:disabled) { + background: linear-gradient(180deg, rgba(75, 85, 99, 0.3) 0%, rgba(55, 65, 81, 0.4) 100%); + border-color: #9ca3af; + box-shadow: 0 0 15px rgba(75, 85, 99, 0.3); + transform: translateY(-2px); } \ No newline at end of file diff --git a/pwa/src/components/common/GameTooltip.tsx b/pwa/src/components/common/GameTooltip.tsx index 7e93339..08fae6d 100644 --- a/pwa/src/components/common/GameTooltip.tsx +++ b/pwa/src/components/common/GameTooltip.tsx @@ -64,7 +64,7 @@ export const GameTooltip: React.FC = ({ content, children, cla position: 'fixed', top: position.y, left: position.x, - zIndex: 9999, + zIndex: 100000, pointerEvents: 'none', // Ensure mouse doesn't get stuck on the tooltip itself maxWidth: '300px' }} diff --git a/pwa/src/components/game/InventoryModal.tsx b/pwa/src/components/game/InventoryModal.tsx index bbfd6f8..8c7f8c3 100644 --- a/pwa/src/components/game/InventoryModal.tsx +++ b/pwa/src/components/game/InventoryModal.tsx @@ -360,30 +360,32 @@ function InventoryModal({
โš–๏ธ
-
+ {t('game.weight')}: {(profile.current_weight || 0).toFixed(1)} / {profile.max_weight || 0} kg -
-
-
-
+ +
๐Ÿ“ฆ
-
+ {t('game.volume')}: {(profile.current_volume || 0).toFixed(1)} / {profile.max_volume || 0} L -
-
-
-
+ +
@@ -493,3 +495,4 @@ function InventoryModal({ } export default InventoryModal +import { GameProgressBar } from '../common/GameProgressBar' diff --git a/pwa/src/components/game/Workbench.css b/pwa/src/components/game/Workbench.css index 1185a45..11a2783 100644 --- a/pwa/src/components/game/Workbench.css +++ b/pwa/src/components/game/Workbench.css @@ -91,11 +91,18 @@ /* Workbench Layout */ .workbench-content-grid { + display: flex; + /* Changed to flex to match inventory layout logic if needed, but grid is fine if aligned */ + /* Keeping grid but fixing transparency */ display: grid; + grid-template-columns: 220px 1fr; + /* Changed to 2-column layout to match inventory? No, Inventory has 2 cols (sidebar + content). Workbench has 3 (sidebar + items + details). */ + /* Let's keep 3 columns but better styled */ grid-template-columns: 220px 350px 1fr; height: 100%; overflow: hidden; - background: rgba(0, 0, 0, 0.2); + background: var(--game-bg-app); + /* Solid background */ } /* Column 1: Sidebar */ @@ -144,9 +151,9 @@ } .workbench-sidebar .category-btn.active { - background: rgba(234, 113, 66, 0.15); - border-color: var(--game-color-primary); - color: var(--game-color-primary); + background: rgba(66, 153, 225, 0.15); + border-color: #4299e1; + color: #63b3ed; } .workbench-sidebar .cat-icon { @@ -165,15 +172,16 @@ .workbench-items-column { display: flex; flex-direction: column; - border-right: 1px solid #3a4b5c; - background: rgba(0, 0, 0, 0.1); + border-right: 1px solid var(--game-border-color); + background: var(--game-bg-app); overflow: hidden; } .workbench-filters { padding: 1rem; - border-bottom: 1px solid #3a4b5c; - background: rgba(0, 0, 0, 0.1); + border-bottom: 1px solid var(--game-border-color); + background: var(--game-bg-input); + /* Match search bar bg */ } .workbench-items-list { diff --git a/pwa/src/index.css b/pwa/src/index.css index 7810b94..ad0d8ca 100644 --- a/pwa/src/index.css +++ b/pwa/src/index.css @@ -9,7 +9,7 @@ --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); + --game-bg-tooltip: #151515; /* --- Borders & Separators --- */ --game-border-color: rgba(255, 255, 255, 0.12); diff --git a/refactor_summary.md b/refactor_summary.md deleted file mode 100644 index cf27cd9..0000000 --- a/refactor_summary.md +++ /dev/null @@ -1,51 +0,0 @@ -# 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?