|
|
# 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.
|