# 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:472–584`
- The `saveHeroRequest` struct accepts `HP`, `MaxHP`, `Attack`, `Defense`, `Speed`, `Strength`, `Constitution`, `Agility`, `Luck`, `State`, `Gold`, `XP`, `Level`, `WeaponID`, `ArmorID` — every progression-critical field.
- Lines 526–573 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:256–261` sends the full client-computed hero state every 10 seconds, plus a `sendBeacon` on page unload (line 279–285).

**GAP-2: Combat runs entirely on the client**
- **File:** `frontend/src/game/engine.ts:464–551` (`_simulateFighting`)
- The client computes hero attack damage (line 490–496), enemy attack damage (line 518–524), HP changes, crit rolls, stun checks — all locally.
- The backend's `Engine.StartCombat()` (`backend/internal/game/engine.go:57–98`) 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:259–300`) 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:624–671` (`_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:57–59`
- `r.Use(handler.TelegramAuthMiddleware(deps.BotToken))` is commented out.
- Identity falls back to `?telegramId=` query parameter (`game.go:46–61`), 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:12–15`). 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:331–334`) that would call `engine.applyServerState()` never fires.

**GAP-6: WS heroID hardcoded to 1**
- **File:** `backend/internal/handler/ws.go:128–129`
- 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:649–655` generates a trivial `LootDrop` with `itemType: 'gold'`, `rarity: Common`.
- **Server:** `GetLoot` (`game.go:587–614`) 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:17–20` — 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 157–166), 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 471–585). 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 471–585), 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 464–551)
- `_onEnemyDefeated` (lines 624–671)
- `_spawnEnemy` (lines 553–572)
- `_requestEncounter` (lines 575–595) — 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 61–62)
- 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 2–4 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 1–5 (backend) and 6–7 (frontend) can be parallelized once the WS contract (envelope format + event types) is agreed upon — which this document defines.
