diff --git a/.project/objectives/README.md b/.project/objectives/README.md index 88ebe340..c8379f2d 100644 --- a/.project/objectives/README.md +++ b/.project/objectives/README.md @@ -98,6 +98,7 @@ | [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 | ## P2 — Polish diff --git a/.project/objectives/p1-20-unit-patrol-orders.md b/.project/objectives/p1-20-unit-patrol-orders.md new file mode 100644 index 00000000..ab242108 --- /dev/null +++ b/.project/objectives/p1-20-unit-patrol-orders.md @@ -0,0 +1,187 @@ +--- +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/public/resources/worlds/khazad_prime/climate_spec.json b/public/resources/worlds/khazad_prime/climate_spec.json index b9e7e880..0210a2cd 100644 --- a/public/resources/worlds/khazad_prime/climate_spec.json +++ b/public/resources/worlds/khazad_prime/climate_spec.json @@ -4,8 +4,8 @@ "thresholds": { "storm_moisture_min": 0.70, "storm_temperature_max": 0.70, - "storm_trigger_chance": 0.02, - "storm_radius": 2, + "storm_trigger_chance": 0.03, + "storm_radius": 3, "storm_moisture_delta": 0.04, "storm_movement_penalty": 0.3, "heat_wave_temperature_min": 0.75, @@ -17,8 +17,8 @@ "heat_wave_unit_damage": 1, "blizzard_temperature_max": 0.20, "blizzard_moisture_min": 0.40, - "blizzard_trigger_chance": 0.02, - "blizzard_radius": 2, + "blizzard_trigger_chance": 0.03, + "blizzard_radius": 3, "blizzard_temperature_delta": -0.03, "blizzard_movement_penalty": 0.5, "blizzard_unit_damage": 1