magicciv/.project/objectives/p1-21-unit-patrol-orders.md
Natalie 60aedfb99e feat(game1): mark patrol orders & determinism objectives as complete
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-19 18:45:12 -07:00

16 KiB
Raw Blame History

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
src/simulator/crates/mc-turn/src/patrol.rs
src/simulator/crates/mc-core/src/action.rs
src/simulator/crates/mc-turn/src/action_handlers.rs
src/simulator/crates/mc-turn/src/processor.rs
src/simulator/crates/mc-turn/src/game_state.rs
src/simulator/crates/mc-ai/src/tactical/movement.rs
src/simulator/crates/mc-ai/src/tactical/state.rs
src/simulator/crates/mc-ai/src/tactical/mod.rs
src/game/engine/tests/unit/test_patrol.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/world_map_hud.gd
src/game/engine/scenes/hud/hotkey_sheet.gd
src/game/engine/scenes/hud/unit_panel.gd
src/game/engine/src/autoloads/event_bus.gd
public/games/age-of-dwarves/vocabulary.json
src/game/project.godot

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):

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 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 in mc-core/src/action.rs and handlers live in mc-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 @onready patrol button in unit_panel.gd. Vocab keys added to vocabulary.json. [DONE 2026-04-19]
  • ✓ Pressing P enters 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_panel reads patrol_order.waypoints and seeds _patrol_waypoints before calling enter_waypoint_pick_mode. [DONE 2026-04-19]
  • ✓ Validation at issue time rejects too-short / too-long routes with typed PatrolError variants. Unseen/unreachable validation deferred to bridge layer. [DONE 2026-04-19]
  • Option<PatrolOrder> round-trips through save/load via #[serde(default)]; old saves load with None. Schema bump in game_state.rs. [DONE 2026-04-19]
  • mc-ai tactical policy issues IssuePatrol via worker-loop, scout-sweep, and chokepoint-garrison heuristics in movement.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 Pui_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.