if (live.excursionKind) rows.push(`<divclass="kv"><kbd>excursionKind (live)</kbd><div>${e(live.excursionKind)}</div></div>`);
if (live.excursionPhase) rows.push(`<divclass="kv"><kbd>excursionPhase</kbd><div>${e(live.excursionPhase)}</div></div>`);
if (live.restUntil) rows.push(`<divclass="kv"><kbd>отдых / restUntil</kbd><div>${statusCountdownLine(live.restUntil)}</div></div>`);
if (live.restUntil) rows.push(`<divclass="kv"><kbd>отдых / restUntil</kbd><div>${statusCountdownLine(live.restUntil)}</div></div>`);
if (live.townLeaveAt) rows.push(`<divclass="kv"><kbd>в городе до выхода</kbd><div>${statusCountdownLine(live.townLeaveAt)}</div></div>`);
if (live.townLeaveAt) rows.push(`<divclass="kv"><kbd>в городе до выхода</kbd><div>${statusCountdownLine(live.townLeaveAt)}</div></div>`);
if (live.nextTownNPCRollAt) rows.push(`<divclass="kv"><kbd>след. событие NPC в городе</kbd><div>${statusCountdownLine(live.nextTownNPCRollAt)}</div></div>`);
if (live.nextTownNPCRollAt) rows.push(`<divclass="kv"><kbd>след. событие NPC в городе</kbd><div>${statusCountdownLine(live.nextTownNPCRollAt)}</div></div>`);
rows.push(`<divstyle="grid-column:1/-1;margin-top:8px;padding-top:8px;border-top:1px solid #2a3551;font-weight:600;color:#cfe3ff">Экскурсия по городу (town tour)</div>`);
if (tt.phase) rows.push(`<divclass="kv"><kbd>phase</kbd><div>${e(tt.phase)}</div></div>`);
if (tt.npcId) rows.push(`<divclass="kv"><kbd>npcId</kbd><div>${e(tt.npcId)}</div></div>`);
if (tt.townTourEndsAt) rows.push(`<divclass="kv"><kbd>конец пребывания в городе</kbd><div>${statusCountdownLine(tt.townTourEndsAt)}</div></div>`);
if (tt.wanderNextAt) rows.push(`<divclass="kv"><kbd>след. смена аттрактора</kbd><div>${statusCountdownLine(tt.wanderNextAt)}</div></div>`);
if (tt.townWelcomeUntil) rows.push(`<divclass="kv"><kbd>welcome до</kbd><div>${statusCountdownLine(tt.townWelcomeUntil)}</div></div>`);
if (tt.townServiceUntil) rows.push(`<divclass="kv"><kbd>service до</kbd><div>${statusCountdownLine(tt.townServiceUntil)}</div></div>`);
if (tt.townRestUntil) rows.push(`<divclass="kv"><kbd>отдых в туре</kbd><div>${statusCountdownLine(tt.townRestUntil)}</div></div>`);
if (tt.townExitPending) rows.push(`<divclass="kv"><kbd>выход из города</kbd><div>ожидает безопасной фазы</div></div>`);
if (tt.townTourDialogOpen) rows.push(`<divclass="kv"><kbd>UI</kbd><div>NPCDialog открыт (таймеры сдвигаются)</div></div>`);
if (tt.townTourInteractionOpen) rows.push(`<divclass="kv"><kbd>UI</kbd><div>interaction открыт</div></div>`);
if (tt.townTourStandX != null && tt.townTourStandY != null) {
<h4style="margin:0 0 8px;font-size:14px;color:#cfe3ff">Экскурсия по городу</h4>
<pclass="muted"style="margin:0 0 8px;font-size:12px">Только для онлайн-героя с<kbd>excursionKind=town</kbd>. Текущий город: <kbd>${e(h.currentTownId)}</kbd>. Детали — блок «Путь, город, отдых».</p>
### Town tour (in-town excursion, `excursionKind: "town"`)
While the hero is in a town with NPCs, the server runs an **ExcursionKindTown** session: attractor wandering inside the town radius, optional rest when HP is low, deferred exit when the town timer elapses, and NPC phases (`npc_approach` → `npc_welcome` → `npc_service`). The client must not infer NPC panels from proximity alone.
**Server → client**
- `town_tour_phase` — payload: `phase`, `townId`, `townNameKey`, optional `npcId`, `npcName`, `npcNameKey`, `npcType`, `worldX`, `worldY`, `exitPending`. Drives which UI to show (`npc_welcome` / `npc_service` vs wander/rest).
- `town_npc_visit` — still emitted when the hero reaches the NPC stand online (adventure log / engine hooks); primary UI should follow `town_tour_phase`.
- `town_tour_service_end` — payload: `reason` (e.g. `timeout`) when the service phase hits the server max duration.
- `town_tour_npc_dialog_closed` — payload `{}` (dialog dismissed; may return to wander from welcome or service).
- `town_tour_npc_interaction_closed` — payload `{}` (interaction chip dismissed or service finished).
**REST (optional, same semantics as dialog close)**
- `POST /api/v1/hero/npc-dialog-pause` with `{ "open": true|false }` maps to `TownNPCUILock` / town tour dialog-open flag; on `open: false` the engine also applies dialog-closed progression (legacy name `SkipTownNPCNarrationAfterDialog`).
**Offline**
- No WebSocket: when the hero hits an NPC stand during offline catch-up, `applyOfflineTownTourNPCVisit` resolves deterministically (quest accept if possible, else merchant upgrade only if rolled gear improves combat rating, else healer heal if HP < 50%, else potion, else merchant autosell / quest-giver checked log).
# 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.
| **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.
- 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.
# Excursion FSM: attractor movement (roadside + adventure)
This document describes the **server-authoritative** mini-excursion flow: the hero moves in **world coordinates** toward successive **attractors** instead of using a time-based perpendicular offset from the road spine.
## Terminology
| Name | `ExcursionPhase` | Meaning |
|------|------------------|---------|
| First exit into forest | `out` | Walk from the frozen road point to the first forest attractor. |
| Return leg | `return` | Walk back to the road (adventure) or to saved `StartX/Y` (roadside). |
Product language sometimes calls the return leg “out”; in code it is always **`return`**.
## Session kinds (`ExcursionKind`)
| Kind | Trigger | `HeroMovement` state |
|------|---------|----------------------|
| `roadside` | Low HP on road → `beginRoadsideRest` | `StateResting`, `RestKindRoadside` |
| `adventure` | Random roll while walking → `beginExcursion` | `StateWalking` with active `Excursion` |
| `town` | In-town tour (separate sub-FSM) | `StateInTown` |
Roadside and adventure share attractor stepping helpers; town uses its own tour phases (`TownTourPhase` in `model/excursion.go`).
## Kinematics
- **`CurrentX` / `CurrentY`** are the true world position during `out` / `wild` / `return`.
- Movement uses `stepTowardAttractor` (excursion speed from `refreshSpeed`) or `stepTowardWorldPoint` for town NPC/center walks, with arrival epsilon `ExcursionArrivalEpsilonWorld` (`tuning`).
- For attractor-based excursions, `displayOffset` is zero; `hero.position` / `hero_move` match world coords.
- **Legacy** JSON blobs without `excursion.kind` but with a non-empty phase are cleared on load (`applyExcursionFromBlob`) so old offset-only sessions are not resumed.
## Roadside (`roadside`)
1. **Start:**`StartX/Y` = road position; road progress frozen (`RoadFreezeWaypoint` / `RoadFreezeFraction`); first forest attractor from `pickExcursionForestAttractor` (depth from tuning).
2. **`out`:** Step toward attractor until within epsilon → **`wild`**.
3. **`wild`:** Regen `RoadsideRestHpPerS`; cap by random duration `[RoadsideRestMinMs, RoadsideRestMaxMs]` or early exit when `HP/MaxHP ≥ RoadsideRestExitHp` (default **0.85**).
Persisted under `heroes.town_pause` → `excursion` (`ExcursionPersisted`).
## Adventure (`adventure`)
1. **Start:**`StartX/Y` on road; `AdventureEndsAt = now + uniform[AdventureDurationMinMs, AdventureDurationMaxMs]`; first `out` attractor like roadside (depth `AdventureDepthWorldUnits`).
3. **`wild`:** While `now < AdventureEndsAt`: step toward current attractor; retarget on `WanderNextAt`; roll encounters; if `HP/MaxHP < LowHpThreshold` → `beginAdventureInlineRest` until `≥ AdventureRestTargetHp` (default **0.85**), then back to `wild`.
4. **Timer elapsed:**`tryBeginAdventureReturn`: if fighting, set `PendingReturnAfterCombat`; else `enterAdventureReturnToRoad` (attractor = closest point on **frozen** road polyline).
5. **After combat win:**`ResumeWalking` then `TryAdventureReturnAfterCombat(now)` — also handles timer elapsed while movement ticks were skipped during combat (checks `AdventureEndsAt`, not only the pending flag).
6. **`return`:** On arrival at road attractor → `endExcursion`, `excursion_end` WS when applicable.
This spec is the contract between the backend and frontend agents.
This spec is the contract between the backend and frontend agents.
Each phase is independently deployable. Phases must ship in order.
Each phase is independently deployable. Phases must ship in order.
**Unified simulation (online/offline):** gameplay ticks run only in the Go `Engine`; WS is observe/commands. See [engine_unified_offline_online.md](./engine_unified_offline_online.md).