diff --git a/.project/objectives/README.md b/.project/objectives/README.md index b01401fc..0ab0ee25 100644 --- a/.project/objectives/README.md +++ b/.project/objectives/README.md @@ -99,7 +99,8 @@ | [p1-17](p1-17-guide-next-auto-deploy.md) | 🟡 partial | Forgejo workflow auto-deploys dev guide on push to main | [tourguide](../team-leads/tourguide.md) | 2026-04-17 | | [p1-18](p1-18-village-discovery-feedback.md) | 🔴 stub | Village discovery — world-map feedback (notification, reward popup, minimap ping) | [wireguard](../team-leads/wireguard.md) | 2026-04-17 | | [p1-19](p1-19-tutorial-opt-in.md) | 🔴 stub | Tutorial opt-in — HUD button, disappears after turn 5, starts from Step 1 | [wireguard](../team-leads/wireguard.md) | 2026-04-17 | -| [p1-20](p1-20-unit-patrol-orders.md) | 🔴 stub | Unit patrol orders — assign a unit to loop between waypoint tiles | [wireguard](../team-leads/wireguard.md) | 2026-04-18 | +| [p1-20](p1-20-unit-action-capability-registry.md) | 🔴 stub | Unit action capability registry — one source of truth for "what can this unit do right now?" | [wireguard](../team-leads/wireguard.md) | 2026-04-18 | +| [p1-21](p1-21-unit-patrol-orders.md) | 🔴 stub | Unit patrol orders — standing order to loop between waypoint tiles (depends on p1-20) | [wireguard](../team-leads/wireguard.md) | 2026-04-18 | ## P2 — Polish diff --git a/.project/objectives/p1-20-unit-action-capability-registry.md b/.project/objectives/p1-20-unit-action-capability-registry.md new file mode 100644 index 00000000..a9ad9b9e --- /dev/null +++ b/.project/objectives/p1-20-unit-action-capability-registry.md @@ -0,0 +1,193 @@ +--- +id: p1-20 +title: Unit action capability registry — one source of truth for "what can this unit do right now?" +priority: p1 +scope: game1 +owner: wireguard +status: stub +updated_at: 2026-04-18 +evidence: + - src/game/engine/scenes/hud/unit_panel.gd + - src/game/engine/src/entities/unit.gd + - src/simulator/crates/mc-sim/src/lib.rs + - src/simulator/crates/mc-turn/src/processor.rs + - src/simulator/crates/mc-ai/src/tactical/movement.rs + - src/simulator/api-gdext/src/ai.rs + - public/games/age-of-dwarves/data/units/archer.json +--- + +## Summary + +The game has no unified answer to *"what actions can unit U take on turn T in +state S?"* Today the unit panel (`unit_panel.gd:19-40`) hardcodes three +buttons — Fortify, Skip, Found City — and decides visibility with bespoke +per-unit booleans scattered across the JSON (`can_found_city`, +`can_build_improvements`, `flags: ["ranged"]`) and ad-hoc GDScript predicates +(`is_civilian()`). Meanwhile `mc-ai/src/tactical/movement.rs` enumerates +moves and attacks but has no registry for non-motion actions. UI and AI have +no shared truth. + +Every future action — patrol (p1-21), siege pack/deploy, pillage, embark, +build-road, heal, upgrade — compounds that debt by adding another hardcoded +button plus its own scattered check. A siege engine in `packed` state can +move but not bombard; in `deployed` state can bombard but not move. Patrol +has the same shape (idle ↔ patrolling, with auto-cancel). Fortify has the +same shape. Without a registry, each state gate becomes a new bespoke flag. + +This objective lands the foundation: a JSON-driven capability declaration, +a Rust `ActionKind` enum with a single `legal_actions(unit, state)` query, +and a unit-panel refactor that renders buttons from that list. **Behavior +does not change** — the three existing actions are folded in with no +semantic change. The payoff is every subsequent action objective (patrol, +siege, pillage, embark, ...) ships as one enum variant + one JSON keyword +mapping + one handler, with no UI or AI scaffolding to re-invent. + +## Design + +**JSON — keyword-driven action sets.** Unit JSON gets no per-unit `actions` +list (too much duplication). Instead, *keywords* and *unit_type* imply +actions via a lookup table in a new file `data/unit_actions.json`: + +```json +{ + "by_unit_type": { + "military": ["move", "attack", "fortify", "skip"], + "civilian": ["move", "skip"] + }, + "by_keyword": { + "founder": ["found_city"], + "worker": ["build_improvement", "pillage_friendly"], + "ranged": ["ranged_attack"], + "siege": ["pack_siege", "deploy_siege", "bombard"], + "amphibious":["embark", "disembark"] + } +} +``` + +Unit JSON files add a `keywords: [...]` array where needed (existing +`flags: ["ranged"]` migrates to `keywords: ["ranged"]`; `can_found_city: +true` migrates to `keywords: ["founder"]`; same for `can_build_improvements` +→ `keywords: ["worker"]`). The legacy fields are removed, not aliased — per +Rail-9 (zero tech debt). + +**Rust — the capability query.** New module `mc-sim/src/action.rs`: + +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ActionKind { + Move, Attack, RangedAttack, + Fortify, Unfortify, Skip, + FoundCity, BuildImprovement, PillageFriendly, + PackSiege, DeploySiege, Bombard, + Embark, Disembark, + // p1-21 will add: IssuePatrol, CancelPatrol +} + +#[derive(Clone, Debug)] +pub struct ActionAvailability { + pub kind: ActionKind, + pub enabled: bool, // greyed-out button vs hidden button + pub disabled_reason: Option, // typed, for tooltip +} + +pub fn legal_actions(unit: &Unit, state: &WorldState) -> Vec; +``` + +`legal_actions` is the *only* place that knows: +- which keywords a unit has → which base actions are candidates, +- which candidates survive state gates (movement_remaining > 0 for `Move`; + `!is_fortified` for `Fortify`; deployed state for `Bombard`; friendly + tile for `FoundCity`; etc.). + +`DisabledReason` is a typed enum (`NoMovement`, `AlreadyFortified`, +`WrongTerrain`, `NotOnFriendlyTile`, `SiegeMustDeployFirst`, ...) so the +UI's tooltip and the AI's explainer consume the same string through +`ThemeVocabulary.lookup("disabled_reason_no_movement")`. + +**GDExtension bridge.** New `api-gdext/src/action.rs` exposes: +- `GdUnitActions.legal_actions(unit_id) -> Array[Dictionary]` returning + `[{kind: "fortify", enabled: true, disabled_reason: ""}, ...]` +- `GdUnitActions.invoke(unit_id, kind: String, args: Dictionary) -> bool` + +Action handlers (fortify, found-city, etc.) move from their current +scattered locations into `mc-turn/src/action_handlers.rs`, one match arm +per `ActionKind`. GDScript's `unit.gd` keeps thin methods that forward to +the bridge. + +**UI — data-driven unit panel.** `unit_panel.gd` stops holding hardcoded +`@onready var _fortify_button` / `_skip_button` / `_found_city_button`. +Instead it instantiates buttons from the array returned by +`GdUnitActions.legal_actions(selected_unit.id)`, one button per entry, +using a small `action_button.tscn` template. Vocab keys follow a strict +pattern: `action_` for label, `tooltip_action_` for tooltip, +`disabled_reason_` for the disabled tooltip. + +Button order is stable (defined alongside the enum in +`mc-sim/src/action.rs` as a fixed ordering function) so the UI layout +doesn't flip as state changes. Disabled actions render greyed-out with the +typed reason in the tooltip — they do *not* disappear, so the player +learns what's possible even when it's not currently legal. (Exception: +actions the unit fundamentally cannot take — a civilian has no Attack +button at all — are absent, not disabled.) + +**AI integration.** `mc-ai/src/tactical/movement.rs` calls +`legal_actions(unit, state)` to enumerate the non-motion macro-actions +available, then scores them inside the existing policy framework. No more +hardcoded "can this unit fortify?" checks; the registry is the answer. +This is the hook p1-21 (patrol) will extend with one new variant. + +## Acceptance + +- ✓ `mc-sim/src/action.rs` defines `ActionKind`, `ActionAvailability`, `DisabledReason`, and `legal_actions(unit, state)`. +- ✓ `data/unit_actions.json` exists and maps `by_unit_type` + `by_keyword` → action lists. Validated at DataLoader time with a schema check. +- ✓ All existing unit JSON files migrated: `flags: ["ranged"]` → `keywords: ["ranged"]`; `can_found_city: true` → `keywords: ["founder"]`; `can_build_improvements: true` → `keywords: ["worker"]`. Legacy fields removed, not aliased. +- ✓ `GdUnitActions` GDExtension class exposes `legal_actions(unit_id)` and `invoke(unit_id, kind, args)`. +- ✓ `unit_panel.gd` renders buttons from `legal_actions`; no hardcoded `@onready` action buttons remain. Button order is stable across state transitions. +- ✓ Disabled actions stay visible (greyed) with typed tooltip reason; fundamentally-unavailable actions are absent (no Attack button on civilians). +- ✓ `mc-ai/src/tactical/movement.rs` consumes `legal_actions` to enumerate non-motion macros — at minimum Fortify and Skip are sourced from the registry rather than hardcoded. +- ✓ Behavior parity: playing through a full game before and after this refactor produces identical unit-panel button sets at every turn on a deterministic fixture (golden-file test). +- ✓ GUT headless tests pass: capability-query unit tests, panel rendering, migration loader. +- ✓ `./run verify` passes with no `flags` / `can_found_city` / `can_build_improvements` references remaining in runtime code. + +## Files to touch + +| File | Change | +|------|--------| +| `src/simulator/crates/mc-sim/src/action.rs` | **New.** `ActionKind`, `ActionAvailability`, `DisabledReason`, `legal_actions`, stable button-order function. | +| `src/simulator/crates/mc-turn/src/action_handlers.rs` | **New.** One handler per `ActionKind`. Existing fortify/skip/found-city logic migrates here. | +| `src/simulator/crates/mc-turn/src/processor.rs` | Route action invocations through `action_handlers`. Remove inline fortify/skip/found-city arms. | +| `src/simulator/crates/mc-ai/src/tactical/movement.rs` | Call `legal_actions` to enumerate non-motion macros. | +| `src/simulator/api-gdext/src/action.rs` | **New.** `GdUnitActions.legal_actions` + `invoke`. | +| `src/simulator/api-gdext/src/lib.rs` | Register `GdUnitActions`. | +| `public/games/age-of-dwarves/data/unit_actions.json` | **New.** `by_unit_type` + `by_keyword` → action-list map. | +| `public/games/age-of-dwarves/data/units/*.json` | Migrate `flags` → `keywords`; remove `can_found_city` / `can_build_improvements` (folded into keywords). | +| `src/game/engine/src/entities/unit.gd` | Thin `legal_actions()` + `invoke_action(kind, args)` methods forwarding to bridge. Remove bespoke `fortify()` etc. | +| `src/game/engine/scenes/hud/unit_panel.gd` + `.tscn` | Render buttons dynamically from `legal_actions`. Drop hardcoded `@onready` buttons. | +| `src/game/engine/scenes/hud/action_button.tscn` | **New.** Template for a single action button, reused per entry. | +| `src/game/engine/src/loaders/data_loader.gd` | Schema validation for `unit_actions.json` + keyword-vs-action consistency check at boot. | +| `public/games/age-of-dwarves/data/vocabulary.json` | `action_`, `tooltip_action_`, `disabled_reason_` for every current action. | + +## Depends on + +- None. Pure refactor + one new data file. + +## Non-goals + +- Adding new actions. This objective ships *exactly* the action set that + exists today (Fortify, Skip, FoundCity, BuildImprovement, Move, Attack, + RangedAttack). Siege pack/deploy and embark/disembark are stubbed in the + enum with no handler wired — follow-up objectives. +- Hotkey remapping. Button-dispatch by hotkey gets one generic + `ui_unit_action_N` binding (1-9), not per-action hotkeys — p1-21 and + friends can pin specific letters later. +- Reordering the current button layout for players. Same visual order, + same labels, same tooltips (modulo the typed disabled reasons, which + are a small UX gain). + +## Why this is P1 (not P0) + +Game 1 is shipping with the current hardcoded panel. This is a tech-debt +payoff that unblocks patrol (p1-21) and every future standing-order +feature. Upgrade to P0 only if a P0 objective (p1-21, or Game 2 siege) +lands first and starts accumulating more hardcoded buttons before this +refactor ships. diff --git a/.project/objectives/p1-20-unit-patrol-orders.md b/.project/objectives/p1-20-unit-patrol-orders.md deleted file mode 100644 index ab242108..00000000 --- a/.project/objectives/p1-20-unit-patrol-orders.md +++ /dev/null @@ -1,187 +0,0 @@ ---- -id: p1-20 -title: Unit patrol orders — assign a unit to loop between waypoint tiles -priority: p1 -scope: game1 -owner: wireguard -status: stub -updated_at: 2026-04-18 -evidence: - - src/game/engine/src/entities/unit.gd - - src/game/engine/scenes/hud/unit_panel.gd - - src/game/engine/scenes/world_map/world_map.gd - - src/game/engine/scenes/world_map/world_map_units.gd - - src/simulator/crates/mc-turn/src/processor.rs - - src/simulator/crates/mc-ai/src/tactical/movement.rs - - public/games/age-of-dwarves/data/vocabulary.json ---- - -## Summary - -Both the human player and the AI clans need a *standing order* that keeps a -unit moving along a fixed route turn after turn without per-turn micro-management. -Canonical use cases: escorting a worker loop, covering a chokepoint, sweeping -scout fog between two outposts. - -Today a unit has two durable states: idle-on-tile, or fortified (via -`unit.gd:250`). `Skip` ends the turn but does not persist. A player who wants -a scout to pace between two tiles must hand-move it every single turn — which -breaks down entirely once the empire has more than a few units, and which the -AI cannot express at all because `mc-ai/tactical/movement.rs` re-plans from -scratch each turn. - -This objective adds a third durable state — **patrol** — with a small -waypoint list and a direction cursor. While patrolling, the unit auto-advances -along its route during the turn processor before the player's input phase, so -turn N+1 opens with the unit already at the next step on its loop. - -## UI design - -**Entering patrol mode.** Unit panel (`scenes/hud/unit_panel.gd`) gets a new -`%PatrolButton` next to `%FortifyButton`. Disabled for civilians and for units -with 0 remaining movement. Clicking it enters a modal *waypoint-pick* state on -the world map: - -- Cursor changes to a patrol-flag glyph. -- HUD shows a bottom-center hint: `"Click tiles to add waypoints. Enter to confirm, Esc to cancel."` -- Each click on a reachable tile appends a waypoint; the world-map overlay - draws numbered flag markers (1, 2, 3...) and a dotted polyline between them. -- Re-clicking a waypoint tile removes it (and renumbers). -- **Enter** confirms — requires ≥2 waypoints. **Esc** cancels without change. -- Right-click on the unit panel's patrol button while patrolling = clear route - and return unit to idle. - -**Visual state of a patrolling unit.** -- Small flag icon decal on the unit sprite (world_map_units.gd) — parallel to - the existing fortify Z indicator. -- When the unit is selected, its full waypoint loop is redrawn as the dotted - polyline so the player can read the route. -- Minimap (scenes/hud/minimap.gd) shows patrolling units in a distinct hue - from idle/fortified (optional, if the existing marker system has a free - slot — otherwise defer to a follow-up). - -**Unit panel while patrolling.** -- `%PatrolButton` text flips to `"Stop patrol"` (vocab: `stop_patrol`). -- Stats row gains a one-line indicator: `"Patrolling 2/4 → (14,7)"` where the - fraction is the cursor position and the coord is the next waypoint. - -**Escape hatches.** Any of these automatically cancel a patrol and leave the -unit idle at its current tile, with a chronicle entry: -- The unit takes damage from combat (even if it survives). -- An enemy enters a tile adjacent to the unit (ZOC wake-up). -- A waypoint tile becomes impassable (terrain change, new foreign border). -- The player manually moves the unit. - -## Data model - -**Rust — `mc-turn` unit record.** Extend the turn processor's unit state with -a single optional sub-record: - -```rust -// src/simulator/crates/mc-turn/src/patrol.rs (new) -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct PatrolOrder { - pub waypoints: Vec, // 2..=8 tiles, validated on set - pub cursor: u8, // index into waypoints, current target - pub direction: i8, // +1 forward, -1 reverse (for ping-pong) - pub mode: PatrolMode, // Loop | PingPong - pub established_turn: u32, // for chronicle + "unit has been patrolling N turns" UI -} -``` - -`Option` hangs off the unit record alongside the existing -`is_fortified` bool. Patrol and fortify are mutually exclusive. - -**Turn processor integration.** In the prologue phase of the turn processor -(`mc-turn/src/processor.rs`, before the player input window, after weather / -ecology): - -1. For each patrolling unit, plan an A* path (ZOC-aware, same planner that AI - tactical movement uses in `mc-ai/tactical/movement.rs`) from current tile - to `waypoints[cursor]`. -2. Walk as many tiles along that path as `movement_remaining` allows. -3. If the unit reaches `waypoints[cursor]` and has movement left, advance - cursor (`Loop` wraps modulo len, `PingPong` reverses at endpoints) and - continue planning. -4. Any trigger from *Escape hatches* above clears the `PatrolOrder` and emits - `PatrolCancelled { unit_id, reason }` to the event bus. - -**JSON.** No unit-data file changes (stats don't depend on patrol). The -save-file format (`mc-sim` snapshot) must serialize `Option` — -bump the save-schema version and add a migration that treats missing field -as `None`. - -**AI.** `mc-ai/src/tactical/movement.rs` gains a new action in its policy -enum: `IssuePatrol { unit_id, waypoints }`. The strategic evaluator may issue -patrol orders for: -- Workers shuttling between a city and a worked resource tile. -- Scouts between two border outposts in explored-but-fogged territory. -- Garrison-grade units between two chokepoint tiles near the frontier. - -For Game 1's heuristic AI this is a narrow, whitelisted trigger — MCTS -rollouts should treat patrol as a compact macro-action (one node decides the -whole route, rollouts don't re-decide each step) to keep the branching factor -bounded. - -**GDScript bridge.** `api-gdext` exposes: -- `GdPatrol.issue(unit_id, PackedVector2iArray waypoints, String mode)` → bool -- `GdPatrol.cancel(unit_id)` → void -- `GdPatrol.get_order(unit_id)` → Dictionary or null (waypoints, cursor, mode) - -`unit.gd` gets thin methods `patrol(waypoints)`, `cancel_patrol()`, -`get_patrol_order()` that forward to the bridge. No simulation logic in GDScript. - -## Acceptance - -- ✓ `PatrolButton` appears on unit panel for military units and is disabled when movement is 0 or the unit is a civilian. -- ✓ Clicking `PatrolButton` enters waypoint-pick mode; world-map renders numbered flags and a dotted polyline between picked tiles. -- ✓ Enter with ≥2 waypoints calls `GdPatrol.issue(...)` and the unit enters the patrolling state; Esc cancels cleanly. -- ✓ Rust turn processor advances patrolling units along their route each turn, emitting a chronicle event on waypoint arrival and on full loop completion. -- ✓ Combat damage, adjacent-enemy proximity, player manual-move, and impassable-terrain changes all cancel the patrol and emit `PatrolCancelled` with a typed reason. -- ✓ `Option` round-trips through save/load with a schema-version bump; old saves load cleanly with no patrols set. -- ✓ `mc-ai` tactical policy can emit `IssuePatrol` actions; at least one heuristic trigger (worker loop OR scout border-sweep) produces patrol orders in a deterministic test scenario. -- ✓ MCTS rollouts treat patrol as a single macro-action (verified by a branching-factor test that compares "with patrol enabled" vs "disabled" node counts on a fixture state). -- ✓ GUT tests pass headless: patrol lifecycle, cancel triggers, save-load, chronicle emission. - -## Files to touch - -| File | Change | -|------|--------| -| `src/simulator/crates/mc-turn/src/patrol.rs` | **New.** `PatrolOrder`, `PatrolMode`, `PatrolCancelled` event, validation. | -| `src/simulator/crates/mc-turn/src/processor.rs` | Prologue phase: step every patrolling unit along its A* path; trigger cancellation events. | -| `src/simulator/crates/mc-sim/src/snapshot.rs` | Serialize `Option`; schema version bump; migration. | -| `src/simulator/crates/mc-ai/src/tactical/movement.rs` | New `IssuePatrol` policy action + heuristic triggers. | -| `src/simulator/crates/mc-ai/src/tactical/state.rs` | Surface patrol state to evaluator so MCTS can see it as a macro. | -| `src/simulator/crates/api-gdext/src/patrol.rs` | **New.** `GdPatrol` class with issue/cancel/get_order. | -| `src/game/engine/src/entities/unit.gd` | Thin `patrol()`, `cancel_patrol()`, `get_patrol_order()` methods (no sim logic). | -| `src/game/engine/scenes/hud/unit_panel.gd` + `.tscn` | Add `%PatrolButton`, patrolling stats-row indicator, stop-patrol flow. | -| `src/game/engine/scenes/world_map/world_map.gd` | Waypoint-pick input state: modal cursor, Enter/Esc handling, click-to-add / click-to-remove. | -| `src/game/engine/scenes/world_map/world_map_units.gd` | Waypoint overlay (numbered flags + dotted polyline), patrol decal on unit sprite. | -| `src/game/engine/scenes/hud/chronicle_panel.gd` | Render `PatrolCancelled` + waypoint-arrival chronicle events. | -| `public/games/age-of-dwarves/data/vocabulary.json` | Keys: `patrol`, `stop_patrol`, `tooltip_patrol`, `tooltip_stop_patrol`, `patrol_hint_pick_waypoints`, `patrol_cancelled_{combat,proximity,moved,impassable}`, `patrol_status_indicator`. | - -## Depends on - -- Existing A* / ZOC pathfinder in `mc-core/algorithms` (re-used, no changes). -- Chronicle event pipeline (already flowing through `EventBus` for fortify / combat / village events). -- Save schema versioning (bump only — schema-migration infra exists). - -## Non-goals - -- Group patrols (multiple units sharing one route). Per-unit only; players - issue the same route to each unit manually if desired. -- Patrol-and-engage: patrolling units do NOT auto-attack. Combat proximity - cancels the order (see escape hatches). Auto-engage is a separate follow-up - objective. -- Conditional waypoints ("patrol here until enemy sighted then retreat to X"). - Linear/ping-pong routes only. -- Patrol across unexplored fog: waypoints must be on tiles the player has - seen at least once (prevents using patrol to sidestep exploration). - -## Why this is P1 (not P0) - -Game 1 is playable without patrol — the workaround is manual movement each -turn. But the feature materially changes the late-game experience (every -playtester past turn ~50 complains about unit-management tax) and unlocks a -class of AI macro-behaviors that today are impossible to express. Upgrade to -P0 if late-game playtest fatigue ranks as a top-3 friction point. diff --git a/.project/objectives/p1-21-unit-patrol-orders.md b/.project/objectives/p1-21-unit-patrol-orders.md new file mode 100644 index 00000000..289bf840 --- /dev/null +++ b/.project/objectives/p1-21-unit-patrol-orders.md @@ -0,0 +1,281 @@ +--- +id: p1-21 +title: Unit patrol orders — standing order to loop between waypoint tiles +priority: p1 +scope: game1 +owner: wireguard +status: stub +updated_at: 2026-04-18 +evidence: + - src/game/engine/src/entities/unit.gd + - src/game/engine/scenes/hud/unit_panel.gd + - src/game/engine/scenes/world_map/world_map.gd + - src/game/engine/scenes/world_map/world_map_units.gd + - src/game/engine/scenes/hud/hotkey_sheet.gd + - src/simulator/crates/mc-sim/src/action.rs + - src/simulator/crates/mc-turn/src/processor.rs + - src/simulator/crates/mc-ai/src/tactical/movement.rs +--- + +## Summary + +Both the human player and the AI clans need a *standing order* that keeps a +unit moving along a fixed route turn after turn without per-turn micro- +management. Canonical use cases: escorting a worker loop, covering a +chokepoint, sweeping scout fog between two outposts. + +Today a unit has two durable states: idle-on-tile, or fortified. `Skip` +ends the turn but does not persist. A player who wants a scout to pace +between two tiles must hand-move it every single turn — which breaks down +once the empire has more than a few units, and which the AI cannot express +at all because `mc-ai/tactical/movement.rs` re-plans from scratch each turn. + +This objective adds a third durable state — **patrol** — with a small +waypoint list, a direction cursor, and a loop mode. While patrolling, the +unit auto-advances along its route during the turn processor before the +player's input phase, so turn N+1 opens with the unit already at the next +step on its loop. + +**This objective assumes p1-20 (unit action capability registry) has +shipped.** Patrol plugs into the registry as one new `ActionKind` variant +plus its handlers — no bespoke unit-panel buttons, no scattered +`is_patrolling` checks in GDScript. If p1-20 slips, reassess whether to +land a narrower patrol-only version first. + +## UX design + +### Discoverability + +- Patrol is one entry in the capability-registry button list (p1-20). + Vocab keys: `action_patrol`, `tooltip_action_patrol`. +- Hotkey: `P` when a military unit is selected, registered in + `hotkey_sheet.gd` under `hotkey_group_map` as `ui_map_patrol`. +- On the hotkey cheat sheet: `P — Patrol (set waypoint route)`. +- First-time discoverability: patrol is mentioned in one tutorial step + (p1-19 tutorial opt-in) *only* if tutorial is started — no new forced + modal. + +### Issuing a patrol (waypoint-pick mode) + +1. Player clicks a military unit. Unit panel shows the registry's legal + actions, including `Patrol` (disabled if movement_remaining = 0, with + typed reason `disabled_reason_no_movement`). +2. Player clicks `Patrol` (or presses `P`). World map enters modal + *waypoint-pick* state: + - Cursor changes to a patrol-flag glyph. + - HUD shows a top banner (not bottom hint — players miss bottom hints + per a prior fortify playtest): `"Click tiles to add waypoints. Enter + to confirm (≥2), Backspace removes last, Esc cancels."` Vocab key: + `patrol_pick_banner`. + - All other world-map input (unit select, end-turn, camera pan) is + blocked *except* zoom (Q/E, mouse wheel) and camera pan (WASD / + arrow keys / middle-drag) — players need to pan to reach distant + waypoints. +3. Left-click on a visible, passable tile appends a waypoint. A numbered + flag decal (1, 2, 3, …) appears; a dotted polyline connects + consecutive flags; the player's origin tile is flag 0 (auto). +4. Left-click on an already-picked waypoint tile removes it and + renumbers (O(n) but n ≤ 8, see validation). +5. `Backspace` removes the most recently added waypoint. +6. `Enter` confirms — requires ≥2 waypoints after the origin. Calls + `GdUnitActions.invoke(unit_id, "patrol", {waypoints: [...], mode: + "loop"})`. Origin is included as the loop's return point. +7. `Esc` exits mode with no change. +8. Picking an invalid tile (unseen, impassable, beyond a schema-capped + max route length) flashes the tile red for 0.3s; does not append. + +### Patrol mode options + +- Default mode: `loop` (A → B → C → A → B → …). +- Hold `Shift` while confirming (`Shift+Enter`) to pick `ping_pong` (A → B + → C → B → A → B …). +- Exposed in the confirm banner so players can see which mode they are + committing to. + +### Visual state of a patrolling unit + +- Flag decal on the unit sprite (`world_map_units.gd`), a sibling of the + existing fortify Z-indicator. +- When the patrolling unit is **selected**, the full waypoint loop + re-renders (numbered flags + dotted polyline). When not selected, only + the sprite decal shows, keeping the world map clean. +- Minimap (`scenes/hud/minimap.gd`): no change in this objective — p2-01 + already ships minimap unit markers; a distinct patrol hue is a p2 + polish follow-up. + +### Unit panel while patrolling + +Through the registry (p1-20), `legal_actions` returns: +- `Patrol` → disabled with `disabled_reason_already_patrolling`. +- `CancelPatrol` → enabled. Button label `action_cancel_patrol` + ("Stop Patrol"). +- `EditPatrol` → enabled. Button label `action_edit_patrol` + ("Edit Route"). Re-enters waypoint-pick mode pre-seeded with the + current route — the player can click-to-remove and click-to-add to + adjust, or press `Esc` to cancel the edit. + +Stats row gains a one-line indicator (`patrol_status_indicator` vocab): +`"Patrolling 2/4 → (14,7)"` where the fraction is cursor position and +the coord is the next waypoint. + +### Escape hatches (auto-cancel) + +Any of these cancel the patrol, leave the unit idle at its current tile, +and emit a typed chronicle entry. The typed reason is sourced from +`DisabledReason`-style vocab so the chronicle and tooltip share text: + +| Trigger | Chronicle key | +|---|---| +| Unit takes combat damage (survives) | `patrol_cancelled_combat` | +| Enemy enters a tile adjacent to the unit (ZOC wake-up) | `patrol_cancelled_proximity` | +| A waypoint tile became impassable (terrain change, border shift) | `patrol_cancelled_impassable` | +| Player manually moved the unit | `patrol_cancelled_manual_move` | +| Unit died | (standard death chronicle; patrol state implicitly cleared) | + +Fog re-concealing a waypoint does **not** cancel — the unit already +knows the route and is walking a planned path, not re-exploring. + +## Data model + +**`ActionKind` additions.** Extends the enum in `mc-sim/src/action.rs` +(shipped in p1-20): + +```rust +pub enum ActionKind { + // ... existing ... + IssuePatrol, + CancelPatrol, + EditPatrol, +} +``` + +Associated handler in `mc-turn/src/action_handlers.rs` delegates to +`patrol::issue / cancel / edit`. + +**Per-unit state.** New `mc-turn/src/patrol.rs`: + +```rust +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PatrolOrder { + pub waypoints: Vec, // 2..=8 tiles, includes origin + pub cursor: u8, // index of current target waypoint + pub direction: i8, // +1 or -1 (PingPong only uses -1) + pub mode: PatrolMode, // Loop | PingPong + pub established_turn: u32, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub enum PatrolMode { Loop, PingPong } +``` + +`Option` hangs off the unit record. Patrol and fortify are +mutually exclusive — enforced by the registry's `legal_actions` +(Fortify is disabled with `disabled_reason_patrolling`, and vice versa). + +**Validation at issue time** (in `patrol::issue`): +- `2 <= waypoints.len() <= 8` (including origin). +- Every waypoint tile is currently passable *for this unit's domain* + (land/water/air per unit's `domain` field). +- Every waypoint tile has been seen at least once by this player + (fog_state ≥ `Explored`) — prevents exploiting patrol to sidestep + exploration. +- Route is A*-reachable end-to-end (no disconnected waypoints). If not, + return `Err(PatrolError::Unreachable { from_idx, to_idx })` — surfaced + to UI as a flash-red + vocab toast. + +**Turn-processor integration** (`mc-turn/src/processor.rs`, prologue, +after weather/ecology, before player input): + +1. For each patrolling unit, plan an A* path (ZOC-aware, re-using the + planner in `mc-core/algorithms`) from current tile to + `waypoints[cursor]`. +2. Walk as many tiles along that path as `movement_remaining` allows. +3. If the unit reaches `waypoints[cursor]` and still has movement: + - `Loop`: `cursor = (cursor + direction) mod len`. + - `PingPong`: if cursor hits an endpoint, flip `direction`. +4. Any escape-hatch trigger clears `PatrolOrder` and emits + `PatrolCancelled { unit_id, reason }` to the event bus. + +**Save/load.** `Option` is serialized in the snapshot. +Bump `mc-sim::snapshot::SCHEMA_VERSION`; migration for older saves +treats missing field as `None`. Existing `Serialize/Deserialize` +round-trip is covered by an added case in the snapshot golden-file +test. + +**AI integration.** `mc-ai/src/tactical/movement.rs` calls +`legal_actions` (via p1-20), sees `IssuePatrol` in the list, and scores +it in its existing policy framework. Heuristic triggers for Game 1: + +- **Worker loop.** Worker whose city has ≥2 improvement jobs queued + across non-adjacent tiles → patrol A→B between them (build + progresses while walking — the build-improvement handler respects + patrol movement). +- **Scout sweep.** Scout stationed on an explored border with two + chokepoint tiles ≤ 6 hexes apart → patrol the chokepoint pair. +- **Chokepoint garrison.** Military unit on a tile the + strategic-evaluator flags as a defensive chokepoint near a border → + patrol between that tile and the nearest friendly city. + +For MCTS rollouts: patrol is a **compact macro-action**. One MCTS node +decides "issue patrol on unit U with route R"; rollouts do NOT +re-decide per-step — they walk the route via the turn-processor +prologue. This bounds the branching factor. A golden-file test compares +node count on a fixture state with/without patrol enabled and asserts +the branching factor does not explode (ratio < 1.5×). + +## Acceptance + +- ✓ `ActionKind::IssuePatrol / CancelPatrol / EditPatrol` defined and handlers live in `mc-turn/src/action_handlers.rs`. +- ✓ Unit panel surfaces Patrol / Cancel Patrol / Edit Route buttons purely through `legal_actions` (p1-20) — no hardcoded `@onready` patrol button in `unit_panel.gd`. +- ✓ Clicking `Patrol` (or pressing `P`) enters waypoint-pick mode with top banner, flag cursor, flag decals, dotted polyline. All world-map input except zoom & camera-pan is blocked until `Enter` or `Esc`. +- ✓ Click-to-add, click-to-remove, Backspace-remove-last all work. Invalid tiles flash red for 0.3s with no append. +- ✓ `Enter` with ≥2 waypoints issues patrol via `GdUnitActions.invoke(..., "patrol", {...})`; `Shift+Enter` picks ping-pong mode; `Esc` cancels with no change. +- ✓ Rust turn processor advances patrolling units along their route each turn; cursor wraps in loop mode and flips in ping-pong. +- ✓ All five escape hatches (combat damage, ZOC-adjacent enemy, manual move, impassable waypoint, unit death) cancel cleanly with typed chronicle reasons. +- ✓ Edit Route re-enters waypoint-pick mode pre-seeded with the current route; committing replaces the route (cursor resets to 0), Esc leaves the old route intact. +- ✓ Validation at issue time rejects too-short / too-long / unseen / unreachable routes with typed `PatrolError` variants surfaced as toasts. +- ✓ `Option` round-trips through save/load with schema version bumped; old saves load with `None`. +- ✓ `mc-ai` tactical policy issues `IssuePatrol` in at least one of the three heuristic scenarios (worker loop / scout sweep / chokepoint garrison) in a deterministic test. +- ✓ MCTS branching-factor test passes: node count ratio with/without patrol < 1.5× on the fixture state. +- ✓ GUT tests pass headless: lifecycle, all five cancel triggers, save-load, edit-route, validation errors, chronicle emission. + +## Files to touch + +| File | Change | +|------|--------| +| `src/simulator/crates/mc-sim/src/action.rs` | Extend `ActionKind` with `IssuePatrol` / `CancelPatrol` / `EditPatrol`; extend `DisabledReason` with `AlreadyPatrolling` / `NoMovement`-for-patrol variant. | +| `src/simulator/crates/mc-turn/src/patrol.rs` | **New.** `PatrolOrder`, `PatrolMode`, `PatrolError`, `issue`, `cancel`, `edit`, `advance_on_turn`. | +| `src/simulator/crates/mc-turn/src/action_handlers.rs` | Add handlers for the three new `ActionKind` variants, delegating to `patrol::*`. | +| `src/simulator/crates/mc-turn/src/processor.rs` | Prologue step: call `patrol::advance_on_turn` for each patrolling unit. Integrate escape-hatch triggers in combat / movement / terrain-change paths. | +| `src/simulator/crates/mc-turn/src/snapshot.rs` | Serialize `Option`; bump `SCHEMA_VERSION`; migration for missing field. | +| `src/simulator/crates/mc-ai/src/tactical/movement.rs` | Score `IssuePatrol` in the policy; heuristic triggers for worker loop / scout sweep / chokepoint garrison. | +| `src/simulator/crates/mc-ai/src/tactical/state.rs` | Expose patrol state so MCTS can treat it as a macro-action. | +| `src/simulator/api-gdext/src/action.rs` | Route `invoke(unit_id, "patrol", args)` / `cancel_patrol` / `edit_patrol` through to the handlers. | +| `src/game/engine/scenes/world_map/world_map.gd` | Waypoint-pick input state machine: modal cursor, Enter/Esc/Shift+Enter/Backspace, click-to-add / click-to-remove, allowed-input whitelist (zoom, pan). | +| `src/game/engine/scenes/world_map/world_map_units.gd` | Waypoint overlay (numbered flags + dotted polyline), patrol decal on unit sprite, edit-mode pre-seeding. | +| `src/game/engine/scenes/hud/hotkey_sheet.gd` | Register `P` → `ui_map_patrol` entry. | +| `src/game/engine/scenes/hud/chronicle_panel.gd` | Render `PatrolCancelled` chronicle entries with typed reason vocab. | +| `public/games/age-of-dwarves/data/vocabulary.json` | Keys: `action_patrol`, `action_cancel_patrol`, `action_edit_patrol`, `tooltip_action_patrol`, `patrol_pick_banner`, `patrol_status_indicator`, `patrol_cancelled_{combat,proximity,impassable,manual_move}`, `disabled_reason_already_patrolling`, `patrol_error_{unreachable,too_short,too_long,unseen_tile,wrong_domain}`, `hotkey_patrol`, `hotkey_label_patrol`. | + +## Depends on + +- **p1-20 (capability registry)** — every UI + AI integration hook assumes `legal_actions` exists. If p1-20 slips, either delay this objective or land a narrower patrol-only version first (note the re-work cost). +- Existing A* / ZOC planner in `mc-core/algorithms` — re-used, no changes. +- Existing chronicle pipeline via `EventBus`. + +## Non-goals + +- Group patrols (multiple units sharing one route). Per-unit only; players issue the same route to each unit manually if desired. +- Patrol-and-engage: patrolling units do **not** auto-attack. Combat proximity cancels the order (see escape hatches). Auto-engage is a separate objective. +- Conditional waypoints ("patrol here until enemy sighted then retreat to X"). Linear / ping-pong only. +- Patrol across unexplored fog — validation blocks it. +- Minimap patrol hue (deferred to a p2 polish follow-up). + +## Why this is P1 (not P0) + +Game 1 is playable without patrol — the workaround is manual movement +each turn. But the feature materially changes the late-game experience +(every playtester past turn ~50 complains about unit-management tax) +and unlocks a class of AI macro-behaviors that today are impossible to +express. Upgrade to P0 if late-game playtest fatigue ranks as a top-3 +friction point.