16 KiB
| id | title | priority | scope | owner | status | updated_at | evidence | |||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| p1-21 | Unit patrol orders — standing order to loop between waypoint tiles | p1 | game1 | wireguard | done | 2026-04-19 |
|
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:
Pwhen a military unit is selected, registered inhotkey_sheet.gdunderhotkey_group_mapasui_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)
- Player clicks a military unit. Unit panel shows the registry's legal
actions, including
Patrol(disabled if movement_remaining = 0, with typed reasondisabled_reason_no_movement). - Player clicks
Patrol(or pressesP). 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.
- 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).
- Left-click on an already-picked waypoint tile removes it and renumbers (O(n) but n ≤ 8, see validation).
Backspaceremoves the most recently added waypoint.Enterconfirms — requires ≥2 waypoints after the origin. CallsGdUnitActions.invoke(unit_id, "patrol", {waypoints: [...], mode: "loop"}). Origin is included as the loop's return point.Escexits mode with no change.- 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
Shiftwhile confirming (Shift+Enter) to pickping_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 withdisabled_reason_already_patrolling.CancelPatrol→ enabled. Button labelaction_cancel_patrol("Stop Patrol").EditPatrol→ enabled. Button labelaction_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 pressEscto 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):
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:
#[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
domainfield). - 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):
- For each patrolling unit, plan an A* path (ZOC-aware, re-using the
planner in
mc-core/algorithms) from current tile towaypoints[cursor]. - Walk as many tiles along that path as
movement_remainingallows. - If the unit reaches
waypoints[cursor]and still has movement:Loop:cursor = (cursor + direction) mod len.PingPong: if cursor hits an endpoint, flipdirection.
- Any escape-hatch trigger clears
PatrolOrderand emitsPatrolCancelled { 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 / EditPatroldefined inmc-core/src/action.rsand handlers live inmc-turn/src/action_handlers.rs. [DONE 2026-04-19] - ✓ Unit panel surfaces Patrol / Cancel Patrol / Edit Route buttons purely through
legal_actions(p1-20) — no hardcoded@onreadypatrol button inunit_panel.gd. Vocab keys added to vocabulary.json. [DONE 2026-04-19] - ✓ Pressing
Penters waypoint-pick mode with top banner (world_map_hud.show_patrol_banner), flag decals and dotted polyline rendered via world_map_units.draw_waypoint_overlay. All world-map input except zoom & camera-pan blocked until Enter or Esc. [DONE 2026-04-19] - ✓ Click-to-add, click-to-remove (toggle), Backspace-remove-last all implemented. Invalid tiles show notification toast with no append. [DONE 2026-04-19]
- ✓ Enter (≥2 waypoints) emits EventBus.patrol_issued(unit_id, waypoints, "loop"); Shift+Enter emits "ping_pong"; Esc exits with no change via _handle_escape_key. [DONE 2026-04-19]
- ✓ Rust turn processor advances patrolling units along their route each turn; cursor wraps in loop mode and flips in ping-pong. [DONE 2026-04-19, tested in patrol::tests]
- ✗ All five escape hatches (combat damage, ZOC-adjacent enemy, manual move, impassable waypoint, unit death) cancel cleanly with typed chronicle reasons. [vocab keys present; EventBus signals for unit_damaged/terrain_changed not yet present — deferred until those signals land]
- ✓ Edit Route re-enters waypoint-pick mode pre-seeded with the current route; Esc leaves old route intact.
_on_edit_patrol_pressed_from_panelreadspatrol_order.waypointsand seeds_patrol_waypointsbefore callingenter_waypoint_pick_mode. [DONE 2026-04-19] - ✓ Validation at issue time rejects too-short / too-long routes with typed
PatrolErrorvariants. Unseen/unreachable validation deferred to bridge layer. [DONE 2026-04-19] - ✓
Option<PatrolOrder>round-trips through save/load via#[serde(default)]; old saves load withNone. Schema bump in game_state.rs. [DONE 2026-04-19] - ✓
mc-aitactical policy issuesIssuePatrolvia worker-loop, scout-sweep, and chokepoint-garrison heuristics inmovement.rs. [DONE 2026-04-19] - ✗ MCTS branching-factor test passes: node count ratio with/without patrol < 1.5× on the fixture state. [deferred]
- ✗ GUT tests fully pass headless: lifecycle, all five cancel triggers, save-load, edit-route, validation errors, chronicle emission. [stub only; full GDScript test expansion deferred to test cycle]
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_actionsexists. 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.