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/spec-server-authoritative.md

663 lines
23 KiB
Markdown

# Server-Authoritative Architecture: Implementation Specification
Status: APPROVED for implementation
Date: 2026-03-27
This spec is the contract between the backend and frontend agents.
Each phase is independently deployable. Phases must ship in order.
---
## 1. WebSocket Message Protocol
All messages use a typed envelope:
```json
{"type": "<message_type>", "payload": { ... }}
```
The `type` field is the discriminant. `payload` is always an object (never null).
### 1.1 Server -> Client Messages
| type | payload | when sent |
|---|---|---|
| `hero_state` | Full `Hero` JSON (same shape as GET /hero) | On WS connect; after level-up; after revive; after equipment change |
| `hero_move` | `{x, y, targetX, targetY, speed, heading}` | 2 Hz while hero is walking (every 500ms) |
| `position_sync` | `{x, y, waypointIndex, waypointFraction, state}` | Every 10s as drift correction |
| `route_assigned` | `{roadId, waypoints: [{x,y}], destinationTownId, speed}` | When hero starts walking a new road segment |
| `combat_start` | `{enemy: {name, type, hp, maxHp, attack, defense, speed, isElite}}` | Server decides encounter |
| `attack` | `{source: "hero"\|"enemy", damage, isCrit, heroHp, enemyHp, debuffApplied}` | Each swing during combat |
| `combat_end` | `{xpGained, goldGained, loot: [{itemType, name, rarity}], leveledUp, newLevel}` | Hero wins fight |
| `hero_died` | `{killedBy: "<enemy_name>"}` | Hero HP reaches 0 |
| `hero_revived` | `{hp}` | After revive (client-requested or auto) |
| `buff_applied` | `{buffType, duration, magnitude}` | Buff activated |
| `town_enter` | `{townId, townName, biome, npcs: [{id, name, type}], restDurationMs}` | Hero arrives at town |
| `town_exit` | `{}` | Rest complete, hero leaves town |
| `npc_encounter` | `{npcId, npcName, role, dialogue, cost}` | Wandering merchant on road |
| `level_up` | `{newLevel, statChanges: {hp, attack, defense, strength, constitution, agility, luck}}` | On level-up |
| `equipment_change` | `{slot: "weapon"\|"armor", item}` | Auto-equip happened |
| `potion_collected` | `{count}` | Potion dropped from loot |
| `quest_available` | `{questId, title, description, npcName}` | NPC offers quest in town |
| `quest_progress` | `{questId, current, target}` | Kill/collect progress update |
| `quest_complete` | `{questId, title, rewards: {xp, gold}}` | Quest objectives met |
| `error` | `{code, message}` | Invalid client action |
### 1.2 Client -> Server Messages
| type | payload | effect |
|---|---|---|
| `activate_buff` | `{buffType: string}` | Activate a buff (server validates charges/gold) |
| `use_potion` | `{}` | Use healing potion (server validates potions > 0, in combat) |
| `accept_quest` | `{questId: int}` | Accept quest from NPC (must be in town with that NPC) |
| `claim_quest` | `{questId: int}` | Claim completed quest reward |
| `npc_interact` | `{npcId: int}` | Interact with NPC (must be in same town) |
| `npc_alms_accept` | `{}` | Accept wandering merchant offer |
| `npc_alms_decline` | `{}` | Decline wandering merchant offer |
| `revive` | `{}` | Request revive (when dead) |
| `ping` | `{}` | Keepalive (server replies with `{"type":"pong","payload":{}}`) |
### 1.3 Backward Compatibility Notes
- Current `CombatEvent` struct (type/heroId/damage/source/isCrit/debuffApplied/heroHp/enemyHp/timestamp) maps directly into the new `attack` envelope payload. Migration: wrap in `{"type":"attack","payload":{...}}`.
- Current `readPump` handles raw `"ping"` string. Change to parse JSON envelope; keep raw `"ping"` support during transition.
- `maxMessageSize` must increase from 512 to 4096 to accommodate `hero_state` and `route_assigned` payloads.
---
## 2. Backend Changes
### 2.1 New GameState Values
Add to `model/combat.go`:
```go
const (
StateWalking GameState = "walking"
StateFighting GameState = "fighting"
StateDead GameState = "dead"
StateResting GameState = "resting" // NEW: in town, resting
StateInTown GameState = "in_town" // NEW: in town, interacting with NPCs
)
```
### 2.2 Hero Model Additions
Add fields to `model.Hero`:
```go
// Movement state (persisted to DB for reconnect recovery)
CurrentTownID *int64 `json:"currentTownId,omitempty"`
DestinationTownID *int64 `json:"destinationTownId,omitempty"`
RoadID *int64 `json:"roadId,omitempty"`
WaypointIndex int `json:"waypointIndex"`
WaypointFraction float64 `json:"waypointFraction"` // 0.0-1.0 between waypoints
```
Migration: `000007_hero_movement_state.sql`
```sql
ALTER TABLE heroes
ADD COLUMN current_town_id BIGINT REFERENCES towns(id),
ADD COLUMN destination_town_id BIGINT REFERENCES towns(id),
ADD COLUMN road_id BIGINT,
ADD COLUMN waypoint_index INT NOT NULL DEFAULT 0,
ADD COLUMN waypoint_fraction DOUBLE PRECISION NOT NULL DEFAULT 0;
```
### 2.3 Road Graph (DB + in-memory)
Migration: `000008_roads.sql`
```sql
CREATE TABLE roads (
id BIGSERIAL PRIMARY KEY,
from_town_id BIGINT NOT NULL REFERENCES towns(id),
to_town_id BIGINT NOT NULL REFERENCES towns(id),
distance DOUBLE PRECISION NOT NULL,
UNIQUE(from_town_id, to_town_id)
);
CREATE TABLE road_waypoints (
id BIGSERIAL PRIMARY KEY,
road_id BIGINT NOT NULL REFERENCES roads(id),
seq INT NOT NULL,
x DOUBLE PRECISION NOT NULL,
y DOUBLE PRECISION NOT NULL,
UNIQUE(road_id, seq)
);
-- Linear chain: Willowdale(1) -> Thornwatch(2) -> ... -> Starfall(7)
-- Waypoints generated at insert time with +-2 tile jitter every 20 tiles
```
In-memory: `game.RoadGraph` struct loaded at startup. Contains all roads + waypoints. Immutable after load.
```go
type RoadGraph struct {
Roads map[int64]*Road // road ID -> road
TownRoads map[int64][]*Road // town ID -> outgoing roads
Towns map[int64]*model.Town // town ID -> town
TownOrder []int64 // ordered town IDs for sequential traversal
}
type Road struct {
ID int64
FromTownID int64
ToTownID int64
Waypoints []Point // ordered list
Distance float64
}
type Point struct {
X, Y float64
}
```
### 2.4 Movement System
New file: `backend/internal/game/movement.go`
```go
type HeroMovement struct {
HeroID int64
Hero *model.Hero // live reference
CurrentX float64
CurrentY float64
TargetX float64 // next waypoint
TargetY float64
Speed float64 // units/sec, base 2.0
State model.GameState
DestinationTownID int64
Road *Road
WaypointIndex int
WaypointFraction float64
LastEncounterAt time.Time // cooldown tracking
RestUntil time.Time // when resting in town
}
const (
BaseMoveSpeed = 2.0 // units per second
MovementTickRate = 500 * time.Millisecond // 2 Hz
PositionSyncRate = 10 * time.Second
EncounterCooldownBase = 15 * time.Second // min time between encounters
EncounterChancePerTick = 0.04 // ~4-5 per road segment
TownRestDuration = 7 * time.Second // 5-10s, randomized per visit
)
```
**Movement tick logic** (called from `Engine.processTick`):
```
for each online hero with active movement:
if state == "resting" and now > restUntil:
set state = "walking"
pick next destination town
assign route
send town_exit + route_assigned
continue
if state != "walking":
continue
// Advance position along waypoints
distanceThisTick = speed * tickDelta.Seconds()
advance hero along road waypoints by distanceThisTick
update waypointIndex, waypointFraction, currentX, currentY
// Check for random encounter (if cooldown passed)
if now - lastEncounterAt > encounterCooldown:
if rand < encounterChancePerTick:
spawn enemy for hero level
start combat (engine.StartCombat)
send combat_start
lastEncounterAt = now
// Check if reached destination town
if waypointIndex >= len(road.Waypoints) - 1 and fraction >= 1.0:
set state = "resting"
set restUntil = now + randomDuration(5s, 10s)
set currentTownID = destinationTownID
send town_enter
// Send position update (2 Hz)
send hero_move {x, y, targetX, targetY, speed, heading}
```
**Destination selection:**
```
func pickNextTown(hero, roadGraph) townID:
if hero has no currentTownID:
return nearest town by Euclidean distance
// Walk the town chain: find current position, go to next
currentIdx = indexOf(hero.currentTownID, roadGraph.TownOrder)
nextIdx = currentIdx + 1
if nextIdx >= len(TownOrder):
nextIdx = currentIdx - 1 // reverse at chain end
return TownOrder[nextIdx]
```
Heroes bounce back and forth along the 7-town chain.
### 2.5 Engine Integration
`Engine` gains:
```go
type Engine struct {
// ... existing fields ...
movements map[int64]*HeroMovement // keyed by hero ID
roadGraph *RoadGraph
moveTicker *time.Ticker // 2 Hz
syncTicker *time.Ticker // 0.1 Hz
}
```
`Engine.Run` changes:
```go
func (e *Engine) Run(ctx context.Context) error {
combatTicker := time.NewTicker(e.tickRate) // existing: 100ms
moveTicker := time.NewTicker(MovementTickRate) // new: 500ms
syncTicker := time.NewTicker(PositionSyncRate) // new: 10s
for {
select {
case <-ctx.Done(): ...
case now := <-combatTicker.C:
e.processCombatTick(now) // renamed from processTick
case now := <-moveTicker.C:
e.processMovementTick(now)
case now := <-syncTicker.C:
e.processPositionSync(now)
}
}
}
```
### 2.6 Hub Refactor
The `Hub` currently only broadcasts `model.CombatEvent`. Change to broadcast generic envelopes:
```go
// WSEnvelope is the wire format for all WS messages.
type WSEnvelope struct {
Type string `json:"type"`
Payload interface{} `json:"payload"`
}
type Client struct {
hub *Hub
conn *websocket.Conn
send chan WSEnvelope // changed from model.CombatEvent
heroID int64
}
type Hub struct {
// ... existing fields ...
broadcast chan WSEnvelope // changed
incoming chan ClientMessage // NEW: messages from clients
}
type ClientMessage struct {
HeroID int64
Type string
Payload json.RawMessage
}
```
`readPump` change: parse incoming JSON as `WSEnvelope`, route to `Hub.incoming` channel. The engine reads from `Hub.incoming` and dispatches.
`writePump` change: serialize `WSEnvelope` via `WriteJSON`.
`BroadcastEvent` still exists but now wraps `CombatEvent` in an envelope before sending.
New method:
```go
// SendToHero sends a message to all connections for a specific hero.
func (h *Hub) SendToHero(heroID int64, msgType string, payload interface{}) {
env := WSEnvelope{Type: msgType, Payload: payload}
h.mu.RLock()
for client := range h.clients {
if client.heroID == heroID {
select {
case client.send <- env:
default:
go func(c *Client) { h.unregister <- c }(client)
}
}
}
h.mu.RUnlock()
}
```
### 2.7 Hero Lifecycle on WS Connect/Disconnect
**On connect:**
1. Load hero from DB
2. If hero has no movement state, initialize: place at nearest town, pick destination, assign route
3. Create `HeroMovement` in engine
4. Send `hero_state` (full snapshot)
5. Send `route_assigned` (current route)
6. If hero was mid-combat (state == fighting), resume or cancel combat
**On disconnect:**
1. Persist hero state to DB (position, HP, gold, etc.)
2. Remove `HeroMovement` from engine
3. Hero becomes eligible for offline simulator ticks
### 2.8 Combat Integration with Movement
When `Engine` decides to start combat (from movement tick):
1. Stop hero movement (state = fighting)
2. Call existing `Engine.StartCombat(hero, enemy)`
3. Send `combat_start` via Hub
When combat ends (enemy dies in `handleEnemyDeath`):
1. Apply rewards (existing `onEnemyDeath` callback)
2. Set state back to "walking"
3. Send `combat_end` with rewards summary
4. Resume movement from where hero stopped
When hero dies:
1. Send `hero_died`
2. Set state to "dead"
3. Stop movement
4. Wait for client `revive` message (or auto-revive after 1 hour, same as offline)
### 2.9 Server-Side Potion Use
When client sends `use_potion`:
1. Validate: hero is in combat, hero.Potions > 0, hero.HP > 0
2. Apply: hero.HP += hero.MaxHP * 30 / 100, clamp to MaxHP, hero.Potions--
3. Emit `attack` event with source "potion" (or a new `potion_used` type)
4. Error envelope if validation fails
---
## 3. Frontend Changes
### 3.1 What to REMOVE from `src/game/engine.ts`
- All movement/walking logic (position calculation, direction, speed)
- Combat simulation (damage calculation, attack timing, HP tracking)
- Encounter triggering (requestEncounter calls)
- Victory reporting (reportVictory calls)
- Save calls (/hero/save)
- Local state mutation for HP, XP, gold, equipment (all comes from server)
### 3.2 What to KEEP in `src/game/engine.ts`
- `requestAnimationFrame` render loop
- Camera follow (track hero position)
- Sprite rendering, animations
- UI overlay rendering (HP bar, XP bar, buff icons)
### 3.3 New: WS Message Handler
```typescript
// src/game/ws-handler.ts
interface WSEnvelope {
type: string;
payload: Record<string, unknown>;
}
class GameWSHandler {
private ws: WebSocket;
private gameState: GameState; // local render state
onMessage(env: WSEnvelope) {
switch (env.type) {
case 'hero_state': this.handleHeroState(env.payload); break;
case 'hero_move': this.handleHeroMove(env.payload); break;
case 'position_sync': this.handlePositionSync(env.payload); break;
case 'route_assigned': this.handleRouteAssigned(env.payload); break;
case 'combat_start': this.handleCombatStart(env.payload); break;
case 'attack': this.handleAttack(env.payload); break;
case 'combat_end': this.handleCombatEnd(env.payload); break;
case 'hero_died': this.handleHeroDied(env.payload); break;
case 'hero_revived': this.handleHeroRevived(env.payload); break;
case 'town_enter': this.handleTownEnter(env.payload); break;
case 'town_exit': this.handleTownExit(env.payload); break;
case 'level_up': this.handleLevelUp(env.payload); break;
case 'equipment_change': this.handleEquipmentChange(env.payload); break;
// ... etc
}
}
// Client -> Server
sendActivateBuff(buffType: string) {
this.send({ type: 'activate_buff', payload: { buffType } });
}
sendUsePotion() {
this.send({ type: 'use_potion', payload: {} });
}
sendRevive() {
this.send({ type: 'revive', payload: {} });
}
}
```
### 3.4 Position Interpolation
Frontend must interpolate between `hero_move` updates (received at 2 Hz, rendered at 60 fps):
```typescript
// On receiving hero_move:
this.prevPosition = this.currentPosition;
this.targetPosition = { x: payload.x, y: payload.y };
this.moveTarget = { x: payload.targetX, y: payload.targetY };
this.heroSpeed = payload.speed;
this.lastUpdateTime = performance.now();
// In render loop:
const elapsed = (performance.now() - this.lastUpdateTime) / 1000;
const t = Math.min(elapsed / 0.5, 1.0); // 500ms between updates
this.renderX = lerp(this.prevPosition.x, this.targetPosition.x, t);
this.renderY = lerp(this.prevPosition.y, this.targetPosition.y, t);
// On position_sync: snap to server position if drift > 2 tiles
const drift = distance(render, sync);
if (drift > 2.0) {
this.renderX = sync.x;
this.renderY = sync.y;
}
```
### 3.5 REST Endpoints: What Stays, What Goes
**KEEP (Phase 1-2):**
- `GET /api/v1/hero` -- initial hero load
- `POST /api/v1/hero` -- create hero
- `POST /api/v1/hero/name` -- set name
- `GET /api/v1/hero/adventure-log` -- history
- `GET /api/v1/map` -- map data
**MOVE TO WS (Phase 2-3, then remove REST):**
- `POST /api/v1/hero/buff/{buffType}` -> client WS `activate_buff`
- `POST /api/v1/hero/revive` -> client WS `revive`
- `POST /api/v1/hero/encounter` -> removed entirely (server decides)
- `POST /api/v1/hero/victory` -> removed entirely (server resolves)
**Phase 3: deprecation.** These endpoints return 410 Gone with `{"error":"use websocket"}`.
---
## 4. Phase Plan
### Phase 1: WS Protocol + Server Movement (est. 3-5 days)
**Backend tasks:**
1. Define `WSEnvelope` type in `model/` or `handler/`
2. Refactor `Hub.broadcast` from `chan model.CombatEvent` to `chan WSEnvelope`
3. Refactor `Client.send` from `chan model.CombatEvent` to `chan WSEnvelope`
4. Add `Hub.SendToHero(heroID, type, payload)` method
5. Wrap existing `emitEvent` calls in envelope: `WSEnvelope{Type: evt.Type, Payload: evt}`
6. Parse incoming client messages as JSON envelopes in `readPump`
7. Add `Hub.incoming` channel + dispatch logic
8. Increase `maxMessageSize` to 4096
9. Run migration `000007_hero_movement_state.sql`
10. Run migration `000008_roads.sql` + seed waypoints
11. Implement `RoadGraph` loader (read from DB at startup)
12. Implement `HeroMovement` struct and movement tick in engine
13. Add `hero_move` (2 Hz) and `position_sync` (10s) sends
14. Send `hero_state` on WS connect
15. Send `route_assigned` when hero starts a new road
16. Send `town_enter` / `town_exit` on arrival/departure
17. Implement hero lifecycle on connect/disconnect (section 2.7)
**Frontend tasks:**
1. Create `ws-handler.ts` with typed envelope parsing
2. Implement `hero_move` handler with lerp interpolation
3. Implement `position_sync` handler with drift snap
4. Implement `route_assigned` handler (optional: draw road on map)
5. Implement `town_enter`/`town_exit` UI
6. Remove client-side walking/movement logic from engine.ts
7. Remove `requestEncounter` calls
8. Remove `reportVictory` calls
9. Keep combat UI rendering but drive it from WS `attack` events
**Integration test:** Connect via WS, verify hero starts walking, receives `hero_move` at 2 Hz, enters town, exits town, receives `combat_start` from server.
### Phase 2: Server-Authoritative Combat (est. 2-3 days)
**Backend tasks:**
1. Engine auto-starts combat when movement tick triggers encounter
2. `combat_start` envelope includes full enemy state
3. `attack` envelopes replace client-simulated damage
4. `combat_end` envelope includes full reward summary
5. Wire `use_potion` client message to potion logic
6. Wire `activate_buff` client message to buff logic (move from REST handler)
7. Wire `revive` client message to revive logic (move from REST handler)
8. `hero_died` sent on death; hero waits for `revive` or auto-revive timer
**Frontend tasks:**
1. Remove all local combat simulation code
2. Display combat from server `attack` events (animate swing, show damage number)
3. Wire "Use Potion" button to `use_potion` WS message
4. Wire buff activation buttons to `activate_buff` WS message
5. Wire revive button to `revive` WS message
6. Display `combat_end` reward summary
### Phase 3: Remove Deprecated Endpoints (est. 1 day)
**Backend:**
1. Remove `RequestEncounter` handler
2. Remove `ReportVictory` handler
3. Remove REST buff/revive handlers (if all clients migrated)
4. Add 410 Gone responses for removed endpoints
5. Remove save endpoint if no longer needed (state persisted on disconnect)
**Frontend:**
1. Remove all REST calls that were migrated to WS
2. Remove any polling/interval-based state refresh (WS pushes everything)
---
## 5. Data Flow Diagrams
### 5.1 Walking + Encounter
```
Engine (2Hz tick)
|
|-- advance hero position along road waypoints
|-- roll encounter check (4% per tick, with cooldown)
|
|-- [no encounter] --> Hub.SendToHero("hero_move", {x,y,...})
|
|-- [encounter!] --> Engine.StartCombat(hero, enemy)
| |
| +--> Hub.SendToHero("combat_start", {enemy})
| +--> movement paused
|
|-- [reach town] --> Hub.SendToHero("town_enter", {townId,...})
| +--> set state = resting, restUntil = now+7s
```
### 5.2 Combat Resolution
```
Engine (100ms combat tick)
|
|-- process attack queue (existing heap)
|-- hero attacks --> Hub.SendToHero("attack", {source:"hero",...})
|-- enemy attacks --> Hub.SendToHero("attack", {source:"enemy",...})
|
|-- [enemy HP <= 0] --> onEnemyDeath callback (loot, xp, gold)
| Hub.SendToHero("combat_end", {rewards})
| resume movement
|
|-- [hero HP <= 0] --> Hub.SendToHero("hero_died", {killedBy})
| stop movement, wait for revive
```
### 5.3 Client Reconnect
```
Client connects via WS
|
+--> Server loads hero from DB
+--> Server sends hero_state (full snapshot)
+--> Server sends route_assigned (current or new route)
+--> Server registers HeroMovement in engine
+--> If mid-combat: send combat_start to resume UI
+--> Normal 2Hz ticks begin
```
---
## 6. Risks and Mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| WS disconnect during combat | Hero stuck in "fighting" state | On disconnect: persist state. On reconnect: if combat stale (>60s), auto-resolve (hero takes proportional damage, combat ends). |
| High tick rate CPU load | Engine CPU spike with many heroes | Movement tick is O(n) over connected heroes only. 2 Hz * 100 heroes = 200 ops/s -- trivial. Monitor via engine status endpoint. |
| Client interpolation jitter | Visual glitches | Lerp with 500ms window. Snap on drift > 2 tiles. Frontend can smooth with exponential decay. |
| Backward compat during rollout | Old clients break | Phase 1 wraps existing events in envelopes. Old client sees `type` field on CombatEvent already. Add version handshake later if needed. |
| Offline simulator conflict | Offline sim moves hero, then WS reconnect loads stale position | Offline sim does NOT touch position/movement fields. It only simulates combat/rewards. Movement state is only live when hero is online. |
---
## 7. Constants Reference
```go
// Movement
BaseMoveSpeed = 2.0 // units/sec
MovementTickRate = 500ms // 2 Hz position updates
PositionSyncRate = 10s // drift correction
WaypointJitter = 2.0 // +/- tiles for road waypoints
WaypointSpacing = 20.0 // tiles between jitter points
TownRestMin = 5 * time.Second
TownRestMax = 10 * time.Second
// Encounters
EncounterCooldown = 15s // min gap between fights
EncounterChancePerTick = 0.04 // per 500ms tick = ~4.8 per 60s of walking
// WS
MaxMessageSize = 4096 // up from 512
SendBufSize = 64 // unchanged
```
## 8. File Manifest
New files to create:
- `backend/internal/game/movement.go` -- HeroMovement, movement tick, destination selection
- `backend/internal/game/road_graph.go` -- RoadGraph struct, DB loader, waypoint generation
- `backend/internal/handler/ws_envelope.go` -- WSEnvelope, ClientMessage types, dispatch
- `backend/migrations/000007_hero_movement_state.sql`
- `backend/migrations/000008_roads.sql`
- `frontend/src/game/ws-handler.ts` -- typed WS message handler
Files to modify:
- `backend/internal/handler/ws.go` -- Hub/Client refactor to WSEnvelope, add SendToHero, incoming channel
- `backend/internal/game/engine.go` -- add movement map, multi-ticker Run loop, movement/sync ticks
- `backend/internal/model/combat.go` -- add StateResting, StateInTown
- `backend/internal/model/hero.go` -- add movement fields (CurrentTownID, etc.)
- `frontend/src/game/engine.ts` -- gut movement/combat logic, receive from WS only