feat(@projects/@magic-civilization): add unit capability registry system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-18 03:33:08 -07:00
parent 0cee526b07
commit 77181f8808
4 changed files with 476 additions and 188 deletions

View file

@ -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

View file

@ -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<DisabledReason>, // typed, for tooltip
}
pub fn legal_actions(unit: &Unit, state: &WorldState) -> Vec<ActionAvailability>;
```
`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_<kind>` for label, `tooltip_action_<kind>` for tooltip,
`disabled_reason_<variant>` 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_<kind>`, `tooltip_action_<kind>`, `disabled_reason_<variant>` 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.

View file

@ -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<AxialCoord>, // 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<PatrolOrder>` 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<PatrolOrder>`
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<PatrolOrder>` 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<PatrolOrder>`; 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.

View file

@ -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<AxialCoord>, // 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<PatrolOrder>` 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<PatrolOrder>` 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<PatrolOrder>` 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<PatrolOrder>`; 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.