# 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. | | Wilderness | `wild` | Heal / wander / encounters (depends on `ExcursionKind`). | | 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**). 4. **`return`:** Attractor = `StartX/Y`; on arrival → `endExcursion`, restore road progress, clear rest. 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`). 2. **`out`:** Reach attractor → **`wild`**, schedule wander (`WanderNextAt`, `adventurePickWanderAttractor` within `AdventureWanderRadius`). 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. ## Persistence - `TownPausePersisted.Excursion` holds kind, phase, freeze snapshot, `startX/Y`, attractor, `adventureEndsAt`, `wanderNextAt`, `pendingReturnAfterCombat`, etc. - Engine persists when `TownPausePersistDue` signature changes (see `townPausePersistSignature` in `movement.go`). ## WebSocket (online) Typical messages: `excursion_start`, `excursion_phase`, `hero_move`, `hero_state`, `excursion_end`. After admin “force return” on adventure, engine sends `excursion_phase` + `hero_move` (not immediate `excursion_end`). ## Tuning keys (reference) | Key | Role | |-----|------| | `AdventureDurationMinMs` / `MaxMs` | Adventure `wild` window | | `AdventureWanderRadius` | Random retarget radius around hero | | `AdventureWanderRetargetMinMs` / `MaxMs` | Retarget interval | | `ExcursionArrivalEpsilonWorld` | Arrival threshold (shared with town step-to-point) | | `RoadsideRestExitHp` | Early end of roadside `wild` | | `AdventureRestTargetHp` | End of adventure inline heal | ## Client - `excursionPhase` and `excursionKind` (`roadside` \| `adventure`) on hero JSON; visuals follow `hero_move` world coordinates. ## Primary source files | Area | File | |------|------| | Session model + persist DTO | `backend/internal/model/excursion.go` | | FSM, stepping, persist blob | `backend/internal/game/movement.go` | | Post-combat return | `backend/internal/game/engine.go`, `offline.go` | | Defaults | `backend/internal/tuning/runtime.go` | ## Related docs - [engine_unified_offline_online.md](./engine_unified_offline_online.md) — single simulation path. - [spec-server-authoritative.md](./spec-server-authoritative.md) — WS envelopes and authority boundaries.