766 lines
23 KiB
Markdown
766 lines
23 KiB
Markdown
# 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 => (
|
||
<div className={`player-card ${!player.is_connected ? 'disconnected' : ''}`}>
|
||
<span className="player-name">{player.username}</span>
|
||
<span className="player-level">Lv. {player.level}</span>
|
||
{!player.is_connected && (
|
||
<span className="player-status">⚠️ Disconnected (Vulnerable)</span>
|
||
)}
|
||
{player.can_attack && (
|
||
<button onClick={() => attackPlayer(player.id)}>
|
||
Attack {!player.is_connected ? '(Easy Target)' : ''}
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
```
|
||
|
||
---
|
||
|
||
## 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?
|