You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
autohero/docs/blueprint_server_authoritat...

866 lines
31 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# AutoHero Server-Authority Blueprint
## Part 1: Architecture Review — Authority Gap Analysis
### Severity P0 (Critical — Exploitable Now)
**GAP-1: `SaveHero` is an unrestricted cheat API**
- **File:** `backend/internal/handler/game.go:472584`
- The `saveHeroRequest` struct accepts `HP`, `MaxHP`, `Attack`, `Defense`, `Speed`, `Strength`, `Constitution`, `Agility`, `Luck`, `State`, `Gold`, `XP`, `Level`, `WeaponID`, `ArmorID` — every progression-critical field.
- Lines 526573 apply each field with zero validation: no bounds checks, no delta verification, no comparison against known server state.
- A `POST /api/v1/hero/save` with body `{"gold":999999,"level":100,"xp":0}` is accepted and persisted to PostgreSQL.
- The auto-save in `App.tsx:256261` sends the full client-computed hero state every 10 seconds, plus a `sendBeacon` on page unload (line 279285).
**GAP-2: Combat runs entirely on the client**
- **File:** `frontend/src/game/engine.ts:464551` (`_simulateFighting`)
- The client computes hero attack damage (line 490496), enemy attack damage (line 518524), HP changes, crit rolls, stun checks — all locally.
- The backend's `Engine.StartCombat()` (`backend/internal/game/engine.go:5798`) is never called from any handler. `GameHandler` holds `*game.Engine` but none of its methods invoke `engine.StartCombat`, `engine.StopCombat`, or `engine.GetCombat`.
- The `RequestEncounter` handler (`game.go:259300`) returns enemy stats as JSON but does not register the combat. The enemy ID is `time.Now().UnixNano()` — ephemeral, untracked.
**GAP-3: XP, gold, and level-up are client-dictated**
- **File:** `frontend/src/game/engine.ts:624671` (`_onEnemyDefeated`)
- Client awards `xpGain = template.xp * (1 + hero.level * 0.1)`, adds gold, runs the `while(xp >= xpToNext)` level-up loop including stat increases.
- These values are then sent to the server via `SaveHero`, overwriting server data.
**GAP-4: Auth middleware is disabled**
- **File:** `backend/internal/router/router.go:5759`
- `r.Use(handler.TelegramAuthMiddleware(deps.BotToken))` is commented out.
- Identity falls back to `?telegramId=` query parameter (`game.go:4661`), which anyone can spoof.
### Severity P1 (High — Broken Plumbing)
**GAP-5: WebSocket protocol mismatch — server events never reach the client**
- **Server sends:** flat `model.CombatEvent` JSON (`{"type":"attack","heroId":1,...}`) via `WriteJSON` (`ws.go:184`).
- **Client expects:** `{type: string, payload: unknown}` envelope (`websocket.ts:1215`). The client's `dispatch(msg)` calls `handlers.get(msg.type)` where `msg.type` comes from the envelope — but the server's flat JSON has `type` at root, not inside `payload`. Even if `type` matched, `msg.payload` would be `undefined`, and the `App.tsx` handlers destructure `msg.payload`.
- **Heartbeat mismatch:** Server sends WS `PingMessage` (binary control frame, `ws.go:190`). Client sends text `"ping"` (`websocket.ts:191`) and expects text `"pong"` (`websocket.ts:109`). Server's `readPump` discards all incoming messages (`ws.go:158`), never responds with `"pong"`. Client times out after `WS_HEARTBEAT_TIMEOUT_MS` and disconnects with code 4000.
- Result: the `_serverAuthoritative` flag in `engine.ts` is never set to `true` during normal gameplay. The WS `game_state` handler (`App.tsx:331334`) that would call `engine.applyServerState()` never fires.
**GAP-6: WS heroID hardcoded to 1**
- **File:** `backend/internal/handler/ws.go:128129`
- Every WS client is assigned `heroID = 1`. In a multi-user scenario, all clients receive events for hero 1 only, and no other hero's combat events are routed.
**GAP-7: Loot is client-generated or stubbed**
- **Client:** `engine.ts:649655` generates a trivial `LootDrop` with `itemType: 'gold'`, `rarity: Common`.
- **Server:** `GetLoot` (`game.go:587614`) returns an in-memory cache or empty list. No loot generation exists on the server side tied to enemy death.
### Severity P2 (Medium — Design Debt)
**GAP-8: Buffs/debuffs not persisted across save**
- `HeroStore.Save` writes hero stats but does not write to `hero_active_buffs` or `hero_active_debuffs` tables.
- `ActivateBuff` handler mutates the in-memory hero, saves via `store.Save`, but buffs are lost on next DB load.
**GAP-9: Engine death handler doesn't persist**
- `handleEnemyDeath` awards XP/gold and runs `hero.LevelUp()` on the in-memory `*model.Hero`, but never calls `store.Save`. If the server restarts, all in-flight combat progress is lost.
**GAP-10: `CheckOrigin: return true` on WebSocket**
- `ws.go:1720` — any origin can upgrade. Combined with no auth, this enables cross-site WebSocket hijacking.
**GAP-11: Redis connected but unused**
- `cmd/server/main.go` creates a Redis client that is never passed to any service. No session store, no rate limiting, no pub/sub for WS scaling.
---
## Part 2: Backend Engineer Prompt (Go)
### Objective
Make combat, progression, and economy **server-authoritative**. The client becomes a thin renderer that sends commands and receives state updates over WebSocket. No gameplay-critical computation on the client.
### New WebSocket Message Envelope
All server→client and client→server messages use this envelope:
```go
// internal/model/ws_message.go
type WSMessage struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
```
Server always sends `WSMessage`. Client always sends `WSMessage`. The `readPump` must parse incoming text into `WSMessage` and route by `Type`.
### New WS Event Types (server→client)
```json
// combat_start
{"type":"combat_start","payload":{"enemy":{"id":123,"name":"Forest Wolf","hp":30,"maxHp":30,"attack":8,"defense":2,"speed":1.8,"enemyType":"wolf","isElite":false},"heroHp":100,"heroMaxHp":100}}
// attack (hero hits enemy)
{"type":"attack","payload":{"source":"hero","damage":15,"isCrit":true,"heroHp":100,"enemyHp":15,"debuffApplied":null,"timestamp":"..."}}
// attack (enemy hits hero)
{"type":"attack","payload":{"source":"enemy","damage":8,"isCrit":false,"heroHp":92,"enemyHp":15,"debuffApplied":"poison","timestamp":"..."}}
// hero_died
{"type":"hero_died","payload":{"heroHp":0,"enemyHp":30,"killedBy":"Fire Demon"}}
// combat_end (enemy defeated)
{"type":"combat_end","payload":{"xpGained":50,"goldGained":30,"newXp":1250,"newGold":500,"newLevel":5,"leveledUp":true,"loot":[{"itemType":"gold","rarity":"common","goldAmount":30}]}}
// level_up (emitted inside combat_end or standalone)
{"type":"level_up","payload":{"newLevel":5,"hp":120,"maxHp":120,"attack":22,"defense":15,"speed":1.45,"strength":6,"constitution":6,"agility":6,"luck":6}}
// buff_applied
{"type":"buff_applied","payload":{"buffType":"rage","magnitude":100,"durationMs":10000,"expiresAt":"..."}}
// debuff_applied
{"type":"debuff_applied","payload":{"debuffType":"poison","magnitude":5,"durationMs":5000,"expiresAt":"..."}}
// hero_state (full sync on connect or after revive)
{"type":"hero_state","payload":{...full hero JSON...}}
```
### New WS Command Types (client→server)
```json
// Client requests next encounter (replaces REST POST /hero/encounter)
{"type":"request_encounter","payload":{}}
// Client requests revive (replaces REST POST /hero/revive)
{"type":"request_revive","payload":{}}
// Client requests buff activation (replaces REST POST /hero/buff/{buffType})
{"type":"activate_buff","payload":{"buffType":"rage"}}
```
### Step-by-Step Changes
#### 1. Fix WS protocol — `internal/handler/ws.go`
**a) Respond to text "ping" with text "pong"**
In `readPump` (currently lines 157166), instead of discarding all messages, parse them:
```go
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadLimit(maxMessageSize)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, raw, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
c.hub.logger.Warn("websocket read error", "error", err)
}
break
}
text := string(raw)
if text == "ping" {
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
c.conn.WriteMessage(websocket.TextMessage, []byte("pong"))
continue
}
var msg model.WSMessage
if err := json.Unmarshal(raw, &msg); err != nil {
c.hub.logger.Warn("invalid ws message", "error", err)
continue
}
c.handleCommand(msg)
}
}
```
**b) Wrap outbound events in envelope**
Change `Client.send` channel type from `model.CombatEvent` to `model.WSMessage`. In `writePump`, `WriteJSON(msg)` where `msg` is already `WSMessage`.
Change `Hub.broadcast` channel and `BroadcastEvent` to accept `WSMessage`. Create a helper:
```go
func WrapEvent(eventType string, payload any) model.WSMessage {
data, _ := json.Marshal(payload)
return model.WSMessage{Type: eventType, Payload: data}
}
```
**c) Extract heroID from auth**
In `HandleWS`, parse `initData` query param, validate Telegram HMAC, extract user ID, load hero from DB:
```go
func (h *WSHandler) HandleWS(w http.ResponseWriter, r *http.Request) {
initData := r.URL.Query().Get("initData")
telegramID, err := h.auth.ValidateAndExtractUserID(initData)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
if err != nil || hero == nil {
http.Error(w, "hero not found", http.StatusNotFound)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { return }
client := &Client{
hub: h.hub,
conn: conn,
send: make(chan model.WSMessage, sendBufSize),
heroID: hero.ID,
hero: hero,
engine: h.engine,
store: h.store,
}
h.hub.register <- client
go client.writePump()
go client.readPump()
// Send initial hero state
client.sendMessage(WrapEvent("hero_state", hero))
}
```
**d) Add `WSHandler` dependencies**
`WSHandler` needs: `*Hub`, `*game.Engine`, `*storage.HeroStore`, `*handler.AuthValidator`, `*slog.Logger`.
**e) Route commands in `handleCommand`**
```go
func (c *Client) handleCommand(msg model.WSMessage) {
switch msg.Type {
case "request_encounter":
c.handleRequestEncounter()
case "request_revive":
c.handleRequestRevive()
case "activate_buff":
c.handleActivateBuff(msg.Payload)
}
}
```
#### 2. Wire `StartCombat` into encounter flow — `internal/handler/ws.go` (new method on `Client`)
```go
func (c *Client) handleRequestEncounter() {
hero := c.hero
if hero.State == model.StateDead || hero.HP <= 0 {
return
}
if _, ok := c.engine.GetCombat(hero.ID); ok {
return // already in combat
}
enemy := pickEnemyForLevel(hero.Level)
enemy.ID = generateEnemyID()
hero.State = model.StateFighting
c.engine.StartCombat(hero, &enemy)
c.sendMessage(WrapEvent("combat_start", map[string]any{
"enemy": enemy,
"heroHp": hero.HP,
"heroMaxHp": hero.MaxHP,
}))
}
```
Now the engine's tick loop will drive the combat, emit `CombatEvent` via `eventCh`, which flows through the bridge goroutine to `Hub.BroadcastEvent`, to matching clients.
#### 3. Enrich engine events with envelope — `internal/game/engine.go`
Change `emitEvent` to emit `WSMessage` instead of `CombatEvent`:
```go
type Engine struct {
// ...
eventCh chan model.WSMessage // changed from CombatEvent
}
func (e *Engine) emitEvent(eventType string, payload any) {
msg := model.WrapEvent(eventType, payload)
select {
case e.eventCh <- msg:
default:
e.logger.Warn("event channel full, dropping", "type", eventType)
}
}
```
Update all `emitEvent` call sites in `processHeroAttack`, `processEnemyAttack`, `handleEnemyDeath`, `StartCombat`, and the debuff/death checks.
#### 4. Server-side rewards on enemy death — `internal/game/engine.go`
`handleEnemyDeath` already awards XP/gold and calls `hero.LevelUp()`. Add:
a) Persist to DB after rewards:
```go
func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
hero := cs.Hero
enemy := &cs.Enemy
oldLevel := hero.Level
hero.XP += enemy.XPReward
hero.Gold += enemy.GoldReward
leveledUp := false
for hero.LevelUp() {
leveledUp = true
}
hero.State = model.StateWalking
// Persist
if e.store != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := e.store.Save(ctx, hero); err != nil {
e.logger.Error("failed to persist after enemy death", "error", err)
}
}
// Emit combat_end with rewards
e.emitEvent("combat_end", map[string]any{
"xpGained": enemy.XPReward,
"goldGained": enemy.GoldReward,
"newXp": hero.XP,
"newGold": hero.Gold,
"newLevel": hero.Level,
"leveledUp": leveledUp,
"loot": generateLoot(enemy, hero),
})
if leveledUp {
e.emitEvent("level_up", map[string]any{
"newLevel": hero.Level, "hp": hero.HP, "maxHp": hero.MaxHP,
"attack": hero.Attack, "defense": hero.Defense, "speed": hero.Speed,
"strength": hero.Strength, "constitution": hero.Constitution,
"agility": hero.Agility, "luck": hero.Luck,
})
}
delete(e.combats, cs.HeroID)
}
```
b) Add `store *storage.HeroStore` to `Engine` struct, pass it from `main.go`.
#### 5. Implement server-side loot generation — `internal/game/loot.go` (new file)
```go
package game
import "math/rand"
func generateLoot(enemy *model.Enemy, hero *model.Hero) []LootDrop {
roll := rand.Float64()
var rarity string
switch {
case roll < 0.001:
rarity = "legendary"
case roll < 0.01:
rarity = "epic"
case roll < 0.05:
rarity = "rare"
case roll < 0.25:
rarity = "uncommon"
default:
rarity = "common"
}
return []LootDrop{{
ItemType: "gold",
Rarity: rarity,
GoldAmount: enemy.GoldReward,
}}
}
type LootDrop struct {
ItemType string `json:"itemType"`
Rarity string `json:"rarity"`
GoldAmount int64 `json:"goldAmount"`
}
```
#### 6. Replace `SaveHero` — `internal/handler/game.go`
**Delete** the current `SaveHero` handler entirely (lines 471585). Replace with a minimal preferences-only endpoint:
```go
type savePreferencesRequest struct {
// Only non-gameplay settings
}
func (h *GameHandler) SavePreferences(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
```
Update the route in `router.go`: replace `r.Post("/hero/save", gameH.SaveHero)` with `r.Post("/hero/preferences", gameH.SavePreferences)` (or remove entirely for now).
Keep `POST /hero/encounter`, `POST /hero/revive`, and `POST /hero/buff/{buffType}` as REST fallbacks, but the primary flow should be WS commands. The REST encounter handler should also call `engine.StartCombat`:
```go
func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
// ... existing hero lookup ...
enemy := pickEnemyForLevel(hero.Level)
enemy.ID = time.Now().UnixNano()
h.engine.StartCombat(hero, &enemy) // NEW: register combat
writeJSON(w, http.StatusOK, encounterEnemyResponse{...})
}
```
#### 7. Persist buffs/debuffs — `internal/storage/hero_store.go`
Add methods to write/read `hero_active_buffs` and `hero_active_debuffs` tables. Update `Save` to transactionally persist hero + buffs + debuffs. Update `GetByTelegramID` to join and hydrate them.
#### 8. Enable auth middleware — `internal/router/router.go`
Uncomment line 59:
```go
r.Use(handler.TelegramAuthMiddleware(deps.BotToken))
```
Fix `validateInitData` to reject empty `botToken` instead of skipping HMAC.
Restrict CORS origin to the Telegram Mini App domain instead of `*`.
Set `CheckOrigin` in ws.go to validate against allowed origins.
#### 9. Hero state synchronization on WS connect
When a client connects, send `hero_state` with the full current hero. If the hero is in an active combat, also send `combat_start` with the current enemy and HP values so the client can resume rendering mid-fight.
#### 10. Auto-save on disconnect
In `readPump`'s defer (when client disconnects), if hero is in combat, call `engine.StopCombat(heroID)` and persist the hero state to DB.
### Files to Modify
| File | Action |
|------|--------|
| `internal/model/ws_message.go` | **New**`WSMessage` struct + `WrapEvent` helper |
| `internal/handler/ws.go` | Major rewrite — envelope, text ping/pong, command routing, auth, encounter/revive/buff handlers |
| `internal/game/engine.go` | Change `eventCh` to `chan WSMessage`, add `store`, enrich `handleEnemyDeath` with rewards+persist |
| `internal/game/loot.go` | **New**`generateLoot` |
| `internal/handler/game.go` | Delete `SaveHero` (lines 471585), wire `StartCombat` into `RequestEncounter` |
| `internal/router/router.go` | Uncomment auth, remove `/hero/save` route, pass engine to WSHandler |
| `internal/storage/hero_store.go` | Add buff/debuff persistence in `Save` and `GetByTelegramID` |
| `internal/handler/auth.go` | Reject empty `botToken`, export `ValidateAndExtractUserID` for WS |
| `cmd/server/main.go` | Pass `store` to engine, pass `engine` + `store` + `auth` to WSHandler |
### Risks
1. **Engine holds `*model.Hero` in memory** — if the REST handler also loads the same hero from DB and modifies it, state diverges. Mitigation: during active combat, REST reads should load from engine's `CombatState`, not DB. Or: make the engine the single writer for heroes in combat.
2. **Tick-based DoT/regen**`ProcessDebuffDamage` and `ProcessEnemyRegen` are called in `processTick` but may need careful timing. Verify they run at 10Hz and produce sane damage values.
3. **Event channel backpressure** — if a slow client falls behind, events are dropped (non-blocking send). Consider per-client buffering with disconnect on overflow (already partly implemented via `sendBufSize`).
---
## Part 3: Frontend Engineer Prompt (React/TS)
### Objective
Transform the frontend from a "simulate locally, sync periodically" model to a **"render what the server tells you"** model. The client sends commands over WebSocket, receives authoritative state updates, and renders them. Client-side combat simulation is removed.
### What the Frontend Keeps Ownership Of
- **Rendering:** PixiJS canvas, isometric projection, sprites, draw calls
- **Camera:** pan, zoom, shake effects
- **Animation:** walking cycle, attack animations, death animations, visual transitions
- **UI overlays:** HUD, HP bars, buff bars, death screen, loot popups, floating damage numbers
- **Walking movement:** client drives the visual walking animation (diagonal drift) between encounters. The walking is cosmetic — the server decides when an encounter starts, not the client.
- **Input:** touch/click events for buff activation, revive button
- **Sound/haptics:** triggered by incoming server events
### What the Frontend Stops Doing
- Computing damage (hero or enemy)
- Rolling crits, applying debuffs
- Deciding when enemies die
- Awarding XP, gold, levels
- Generating loot
- Running `_simulateFighting` / `_onEnemyDefeated` / `_spawnEnemy`
- Auto-saving hero stats to `/hero/save`
- Sending `sendBeacon` with hero stats on unload
- Level-up stat calculations
### New WS Message Types to Consume
```typescript
// src/network/types.ts (new file or add to existing types)
interface CombatStartPayload {
enemy: {
id: number;
name: string;
hp: number;
maxHp: number;
attack: number;
defense: number;
speed: number;
enemyType: string;
isElite: boolean;
};
heroHp: number;
heroMaxHp: number;
}
interface AttackPayload {
source: 'hero' | 'enemy';
damage: number;
isCrit: boolean;
heroHp: number;
enemyHp: number;
debuffApplied: string | null;
timestamp: string;
}
interface HeroDiedPayload {
heroHp: number;
enemyHp: number;
killedBy: string;
}
interface CombatEndPayload {
xpGained: number;
goldGained: number;
newXp: number;
newGold: number;
newLevel: number;
leveledUp: boolean;
loot: LootDrop[];
}
interface LevelUpPayload {
newLevel: number;
hp: number;
maxHp: number;
attack: number;
defense: number;
speed: number;
strength: number;
constitution: number;
agility: number;
luck: number;
}
interface HeroStatePayload {
id: number;
hp: number;
maxHp: number;
attack: number;
defense: number;
speed: number;
// ...all fields
}
interface BuffAppliedPayload {
buffType: string;
magnitude: number;
durationMs: number;
expiresAt: string;
}
interface DebuffAppliedPayload {
debuffType: string;
magnitude: number;
durationMs: number;
expiresAt: string;
}
```
### New WS Commands to Send
```typescript
// Request next encounter (triggers server combat)
ws.send('request_encounter', {});
// Request revive
ws.send('request_revive', {});
// Activate buff
ws.send('activate_buff', { buffType: 'rage' });
```
### Step-by-Step Changes
#### 1. Fix WebSocket client — `src/network/websocket.ts`
The current implementation already expects `{type, payload}` envelope and handles text `"pong"` — this matches the new server protocol. **No changes needed** to the message parsing.
#### 2. Gut `_simulateFighting` — `src/game/engine.ts`
Remove the entire fighting simulation. The `_simulateTick` method should only handle the walking phase visually:
```typescript
private _simulateTick(dtMs: number): void {
if (!this._gameState.hero) return;
if (this._gameState.phase === GamePhase.Walking) {
this._simulateWalking(dtMs);
}
// Fighting, death, loot — all driven by server events now
}
```
**Remove entirely:**
- `_simulateFighting` (lines 464551)
- `_onEnemyDefeated` (lines 624671)
- `_spawnEnemy` (lines 553572)
- `_requestEncounter` (lines 575595) — encounters are now requested via WS
- `_effectiveHeroDamage`, `_effectiveHeroAttackSpeed`, `_incomingDamageMultiplier` — server computes these
- `_isBuffActive`, `_isHeroStunned` — server handles stun/buff logic
- Attack timer fields: `_nextHeroAttackMs`, `_nextEnemyAttackMs`
**Keep** `_simulateWalking` but strip it down to visual movement only — remove the encounter spawning logic:
```typescript
private _simulateWalking(dtMs: number): void {
const hero = this._gameState.hero!;
const moveSpeed = 0.002; // tiles per ms
hero.position.x += moveSpeed * dtMs * 0.7071;
hero.position.y += moveSpeed * dtMs * 0.7071;
}
```
#### 3. Remove `_serverAuthoritative` flag — `src/game/engine.ts`
This flag is no longer needed. The engine is always server-authoritative. Remove:
- The `_serverAuthoritative` field (line 6162)
- The `if (!this._serverAuthoritative)` check in `_update` (line 334)
- The flag-setting in `applyServerState` (line 185)
- The `if (this._serverAuthoritative) return` guard in `reviveHero` (line 674)
#### 4. Add server event handlers to the engine — `src/game/engine.ts`
```typescript
handleCombatStart(payload: CombatStartPayload): void {
const enemy: EnemyState = {
id: payload.enemy.id,
name: payload.enemy.name,
hp: payload.enemy.hp,
maxHp: payload.enemy.maxHp,
position: {
x: this._gameState.hero!.position.x + 1.5,
y: this._gameState.hero!.position.y,
},
attackSpeed: payload.enemy.speed,
damage: payload.enemy.attack,
defense: payload.enemy.defense,
enemyType: payload.enemy.enemyType as EnemyType,
};
this._gameState = {
...this._gameState,
phase: GamePhase.Fighting,
enemy,
hero: { ...this._gameState.hero!, hp: payload.heroHp, maxHp: payload.heroMaxHp },
};
this._onStateChange?.(this._gameState);
}
handleAttack(payload: AttackPayload): void {
const hero = { ...this._gameState.hero! };
const enemy = this._gameState.enemy ? { ...this._gameState.enemy } : null;
hero.hp = payload.heroHp;
if (enemy) enemy.hp = payload.enemyHp;
this._gameState = { ...this._gameState, hero, enemy };
this._onStateChange?.(this._gameState);
}
handleHeroDied(payload: HeroDiedPayload): void {
this._gameState = {
...this._gameState,
phase: GamePhase.Dead,
hero: { ...this._gameState.hero!, hp: 0 },
};
this._onStateChange?.(this._gameState);
}
handleCombatEnd(payload: CombatEndPayload): void {
const hero = { ...this._gameState.hero! };
hero.xp = payload.newXp;
hero.gold = payload.newGold;
hero.level = payload.newLevel;
hero.xpToNext = Math.round(100 * Math.pow(1.1, payload.newLevel));
this._gameState = {
...this._gameState,
phase: GamePhase.Walking,
hero,
enemy: null,
loot: payload.loot.length > 0 ? payload.loot[0] : null,
};
this._onStateChange?.(this._gameState);
}
handleLevelUp(payload: LevelUpPayload): void {
const hero = { ...this._gameState.hero! };
hero.level = payload.newLevel;
hero.hp = payload.hp;
hero.maxHp = payload.maxHp;
hero.damage = payload.attack;
hero.defense = payload.defense;
hero.attackSpeed = payload.speed;
hero.strength = payload.strength;
hero.constitution = payload.constitution;
hero.agility = payload.agility;
hero.luck = payload.luck;
hero.xpToNext = Math.round(100 * Math.pow(1.1, payload.newLevel));
this._gameState = { ...this._gameState, hero };
this._onStateChange?.(this._gameState);
}
handleHeroState(payload: HeroStatePayload): void {
const heroState = heroResponseToState(payload);
this._gameState = {
...this._gameState,
hero: heroState,
};
this._onStateChange?.(this._gameState);
}
```
#### 5. Wire WS events in App.tsx — `src/App.tsx`
Replace existing WS handlers with:
```typescript
ws.on('hero_state', (msg) => {
engine.handleHeroState(msg.payload as HeroStatePayload);
});
ws.on('combat_start', (msg) => {
engine.handleCombatStart(msg.payload as CombatStartPayload);
});
ws.on('attack', (msg) => {
const p = msg.payload as AttackPayload;
engine.handleAttack(p);
hapticImpact(p.isCrit ? 'heavy' : 'light');
engine.camera.shake(p.isCrit ? 8 : 4, p.isCrit ? 250 : 150);
});
ws.on('hero_died', (msg) => {
engine.handleHeroDied(msg.payload as HeroDiedPayload);
hapticNotification('error');
});
ws.on('combat_end', (msg) => {
engine.handleCombatEnd(msg.payload as CombatEndPayload);
hapticNotification('success');
});
ws.on('level_up', (msg) => {
engine.handleLevelUp(msg.payload as LevelUpPayload);
hapticNotification('success');
});
```
#### 6. Replace encounter trigger
Remove `engine.setEncounterProvider(...)`. Instead, after walking for X seconds, send a WS command:
```typescript
// In _simulateWalking:
private _walkTimerMs = 0;
private static readonly ENCOUNTER_REQUEST_INTERVAL_MS = 3000;
private _simulateWalking(dtMs: number): void {
// ... visual movement ...
this._walkTimerMs += dtMs;
if (this._walkTimerMs >= GameEngine.ENCOUNTER_REQUEST_INTERVAL_MS) {
this._walkTimerMs = 0;
this._onRequestEncounter?.();
}
}
// In App.tsx:
engine.onRequestEncounter(() => {
ws.send('request_encounter', {});
});
```
#### 7. Remove auto-save
- Remove `engine.onSave(...)` registration
- Remove `_triggerSave`, `_saveTimerMs`, `SAVE_INTERVAL_MS` from engine.ts
- Remove `handleBeforeUnload` / `sendBeacon`
- Remove `heroStateToSaveRequest` function
- Remove the import of `saveHero` from `network/api.ts`
#### 8. Fix revive flow
```typescript
const handleRevive = useCallback(() => {
ws.send('request_revive', {});
}, []);
```
Remove `engine.reviveHero()` from engine.ts.
#### 9. Fix buff activation
```typescript
const handleBuffActivate = useCallback((type: BuffType) => {
ws.send('activate_buff', { buffType: type });
hapticImpact('medium');
}, []);
```
Keep REST `activateBuff` as fallback if WS disconnected.
#### 10. Handle WS disconnect gracefully
Show "Reconnecting..." overlay. On reconnect, server sends `hero_state` automatically.
Remove `initDemoState` fallback (or gate behind explicit `DEV_OFFLINE` flag).
### Files to Modify
| File | Action |
|------|--------|
| `src/game/engine.ts` | Major rewrite — remove fighting sim, add server event handlers, remove save logic |
| `src/App.tsx` | Replace WS handlers, remove auto-save, encounter provider, fix revive/buff to use WS |
| `src/network/websocket.ts` | No changes needed (already correct format) |
| `src/network/api.ts` | Remove `saveHero`. Keep `initHero`, `requestRevive`, `activateBuff` as REST fallbacks |
| `src/network/types.ts` | **New** — WS payload interfaces |
| `src/game/types.ts` | Remove types only used by client-side combat (if any) |
| `src/ui/LootPopup.tsx` | Wire to `combat_end` loot payload instead of client-generated loot |
### Risks
1. **Latency perception** — At 10Hz server tick (100ms), attacks feel delayed compared to client-side instant feedback. Mitigation: client can play "optimistic" attack animations immediately and snap HP values when the `attack` event arrives. 100ms is fast enough for idle game feel.
2. **Walking encounter timing** — If the client requests encounters every 3s but WS latency varies, encounters may feel irregular. Mitigation: server enforces minimum walk cooldown (e.g. 2s) and responds immediately when valid.
3. **Disconnect during combat** — If the client disconnects mid-fight, the server keeps the combat running. Hero might die while offline. On reconnect, server sends current state (possibly dead). Client must handle resuming into any phase.
4. **Demo mode removal** — Removing `initDemoState` means the game won't work without a backend. Keep a `DEV_OFFLINE` env flag for development.
---
## Implementation Priority
| Order | Task | Owner | Blocks |
|-------|------|-------|--------|
| 1 | Enable auth middleware + fix `validateInitData` | Backend | Everything |
| 2 | WS envelope + text ping/pong + heroID from auth | Backend | Frontend WS integration |
| 3 | Wire `StartCombat` into `RequestEncounter` handler | Backend | Server-driven combat |
| 4 | Enrich engine events with rewards, persist on death | Backend | Frontend can render combat |
| 5 | Delete `SaveHero`, add `SavePreferences` | Backend | Frontend must stop auto-save |
| 6 | Frontend: remove client combat sim, add server event handlers | Frontend | Needs steps 24 done |
| 7 | Frontend: switch encounter/revive/buff to WS commands | Frontend | Needs step 2 done |
| 8 | Buff/debuff DB persistence | Backend | Nice-to-have for MVP |
| 9 | Server-side loot generation | Backend | Nice-to-have for MVP |
| 10 | Restrict CORS + WS CheckOrigin | Backend | Pre-production hardening |
Steps 15 (backend) and 67 (frontend) can be parallelized once the WS contract (envelope format + event types) is agreed upon — which this document defines.