# 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 => (