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.
663 lines
23 KiB
Markdown
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
|