|
|
# Unified Engine: One Simulation Path for Online and Offline
|
|
|
|
|
|
This document describes how AutoHero runs **all** gameplay logic (movement cadence, encounters, combat, rewards) in a **single** place: the Go `Engine` (`backend/internal/game/engine.go`). WebSocket is observation and command input only; there is no separate “offline world” loop that advances combat differently while the player is away.
|
|
|
|
|
|
## Authority
|
|
|
|
|
|
| Layer | Role |
|
|
|
|--------|------|
|
|
|
| **Engine** | `processMovementTick`, `processCombatTick`, `startCombatLocked`, `HeroMovement` FSM, persistence hooks |
|
|
|
| **WebSocket Hub** | Delivers envelopes only when the hero has at least one connected client (`SendToHero` no-op otherwise) |
|
|
|
| **PostgreSQL** | Durable hero row; periodic and event-driven saves from the engine |
|
|
|
| **Offline digest** | Aggregated summary for “while you were away” UI; filled only after disconnect grace (see below) |
|
|
|
|
|
|
## Resident heroes
|
|
|
|
|
|
- After the **last** WebSocket disconnect for a hero, `HeroSocketDetached` **does not** remove them from `e.movements` or clear combat. The hero keeps ticking like an online session without a viewer.
|
|
|
- In-memory `Hero.WsDisconnectedAt` is set on disconnect (aligned with `heroes.ws_disconnected_at` in the DB) for digest timing.
|
|
|
- **Cold start:** `ListHeroesForEngineBootstrap` (`backend/internal/storage/hero_store.go`) selects heroes with `ws_disconnected_at IS NOT NULL` and a simulatable `state`. `BootstrapResidentHeroes` (`backend/internal/game/engine_bootstrap.go`) runs a **one-shot** wall-time catch-up via `OfflineSimulator.SimulateHeroAt`, then registers the hero in the engine. Live play after that uses only engine combat.
|
|
|
- **Periodic save without WS:** heroes with no subscriber get a full `heroStore.Save` every `offlineDisconnectedFullSaveInterval` (30s) from the movement tick path (`backend/internal/game/engine.go`).
|
|
|
|
|
|
## Combat and encounters
|
|
|
|
|
|
- **Live progression:** encounters call `startCombatLocked`; resolution uses `e.combats` and `processCombatTick` (same for subscribed and unsubscribed heroes).
|
|
|
- **Batch-only paths** (no second “live” world): `SimulateOneFight` / `simulateHeroTick` remain for **bootstrap after restart** and for **server-downtime gap** recovery when the hero is **not** resident in the engine (`catchUpOfflineGap` in `backend/internal/handler/game.go`). If `HeroHasActiveMovement`, gap catch-up **skips** `SimulateHeroAt` so combat is not simulated twice.
|
|
|
|
|
|
## REST and engine consistency
|
|
|
|
|
|
- `Engine.MergeResidentHeroState` copies the authoritative in-engine hero (after `SyncToHero`) into the handler’s hero struct.
|
|
|
- **`GET /api/v1/hero/init`** and **`GET /api/v1/hero`**: if the hero is resident, merge from engine and persist so the client and DB match the single simulation.
|
|
|
|
|
|
## Offline digest
|
|
|
|
|
|
- Helpers: `OfflineDigestGrace`, `OfflineDigestCollecting` (`backend/internal/game/offline.go`).
|
|
|
- The engine applies digest deltas on kill, death (including DoT death path), and auto-revive **only when** `OfflineDigestCollecting(hero.WsDisconnectedAt, now)` is true.
|
|
|
- Batch `simulateHeroTick` uses the same rule when a digest store is wired.
|
|
|
|
|
|
## Key source files
|
|
|
|
|
|
| Area | File |
|
|
|
|------|------|
|
|
|
| Engine loop, combat, movement, digest hooks, auto-revive, disconnected save | `backend/internal/game/engine.go` |
|
|
|
| Bootstrap query | `backend/internal/storage/hero_store.go` (`ListHeroesForEngineBootstrap`) |
|
|
|
| Bootstrap orchestration | `backend/internal/game/engine_bootstrap.go` |
|
|
|
| Batch catch-up + digest helpers | `backend/internal/game/offline.go` |
|
|
|
| Hub send if connected | `backend/internal/handler/ws.go` |
|
|
|
| Init / GetHero merge; gap catch-up guard | `backend/internal/handler/game.go` |
|
|
|
| Wiring, bootstrap before `Engine.Run` | `backend/cmd/server/main.go` |
|
|
|
|
|
|
## Scaling notes
|
|
|
|
|
|
- Bootstrap is capped (e.g. 500 heroes in `main`); not every account is loaded into RAM.
|
|
|
- Long-term, explicit unload policy (TTL + final save) can reduce residency memory without reintroducing a second gameplay simulator.
|
|
|
|
|
|
## Related docs
|
|
|
|
|
|
- [spec-server-authoritative.md](./spec-server-authoritative.md) — WS contract and phases.
|
|
|
- [excursion_attractor_fsm.md](./excursion_attractor_fsm.md) — roadside/adventure attractor excursion FSM and persistence.
|
|
|
- [blueprint_server_authoritative.md](./blueprint_server_authoritative.md) — historical gap analysis and migration context.
|