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