diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index 0ab0ee25..4f6a094e 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -15,10 +15,10 @@
| Priority | ✅ | 🟡 | 🔴 | ❌ | ⚫ | Total |
|---|---|---|---|---|---|---|
| **P0** | 27 | 5 | 3 | 0 | 0 | 35 |
-| **P1** | 14 | 3 | 3 | 0 | 1 | 21 |
+| **P1** | 14 | 3 | 4 | 0 | 1 | 22 |
| **P2** | 9 | 6 | 0 | 12 | 0 | 27 |
| **P3 (oos)** | 0 | 0 | 0 | 0 | 17 | 17 |
-| **total** | **50** | **14** | **6** | **12** | **18** | **100** |
+| **total** | **50** | **14** | **7** | **12** | **18** | **101** |
@@ -28,8 +28,8 @@
|---|---|
| [asset-sprite](../team-leads/asset-sprite.md) | 7 |
| [warcouncil](../team-leads/warcouncil.md) | 6 |
+| [wireguard](../team-leads/wireguard.md) | 6 |
| [tourguide](../team-leads/tourguide.md) | 6 |
-| [wireguard](../team-leads/wireguard.md) | 5 |
| [shipwright](../team-leads/shipwright.md) | 2 |
| [testwright](../team-leads/testwright.md) | 2 |
| [asset-audio](../team-leads/asset-audio.md) | 1 |
@@ -100,7 +100,7 @@
| [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-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 |
+| [p1-21](p1-21-unit-patrol-orders.md) | 🔴 stub | Unit patrol orders — standing order to loop between waypoint tiles | [wireguard](../team-leads/wireguard.md) | 2026-04-18 |
## P2 — Polish
diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json
index 17e37c0c..5a8a7e60 100644
--- a/public/games/age-of-dwarves/data/objectives.json
+++ b/public/games/age-of-dwarves/data/objectives.json
@@ -1,12 +1,12 @@
{
- "generated_at": "2026-04-18T09:15:28Z",
+ "generated_at": "2026-04-18T11:51:34Z",
"totals": {
- "stub": 6,
- "done": 50,
- "oos": 18,
"missing": 12,
+ "stub": 7,
+ "oos": 18,
"partial": 14,
- "total": 100
+ "done": 50,
+ "total": 101
},
"objectives": [
{
@@ -561,13 +561,23 @@
},
{
"id": "p1-20",
- "title": "Unit patrol orders — assign a unit to loop between waypoint tiles",
+ "title": "Unit action capability registry — one source of truth for \"what can this unit do right now?\"",
"priority": "p1",
"status": "stub",
"scope": "game1",
"owner": "wireguard",
"updated_at": "2026-04-18",
- "summary": "Both the human player and the AI clans need a *standing order* that keeps a\nunit moving along a fixed route turn after turn without per-turn micro-management.\nCanonical use cases: escorting a worker loop, covering a chokepoint, sweeping\nscout fog between two outposts.\n\nToday a unit has two durable states: idle-on-tile, or fortified (via\n`unit.gd:250`). `Skip` ends the turn but does not persist. A player who wants\na scout to pace between two tiles must hand-move it every single turn — which\nbreaks down entirely once the empire has more than a few units, and which the\nAI cannot express at all because `mc-ai/tactical/movement.rs` re-plans from\nscratch each turn.\n\nThis objective adds a third durable state — **patrol** — with a small\nwaypoint list and a direction cursor. While patrolling, the unit auto-advances\nalong its route during the turn processor before the player's input phase, so\nturn N+1 opens with the unit already at the next step on its loop."
+ "summary": "The game has no unified answer to *\"what actions can unit U take on turn T in\nstate S?\"* Today the unit panel (`unit_panel.gd:19-40`) hardcodes three\nbuttons — Fortify, Skip, Found City — and decides visibility with bespoke\nper-unit booleans scattered across the JSON (`can_found_city`,\n`can_build_improvements`, `flags: [\"ranged\"]`) and ad-hoc GDScript predicates\n(`is_civilian()`). Meanwhile `mc-ai/src/tactical/movement.rs` enumerates\nmoves and attacks but has no registry for non-motion actions. UI and AI have\nno shared truth.\n\nEvery future action — patrol (p1-21), siege pack/deploy, pillage, embark,\nbuild-road, heal, upgrade — compounds that debt by adding another hardcoded\nbutton plus its own scattered check. A siege engine in `packed` state can\nmove but not bombard; in `deployed` state can bombard but not move. Patrol\nhas the same shape (idle ↔ patrolling, with auto-cancel). Fortify has the\nsame shape. Without a registry, each state gate becomes a new bespoke flag.\n\nThis objective lands the foundation: a JSON-driven capability declaration,\na Rust `ActionKind` enum with a single `legal_actions(unit, state)` query,\nand a unit-panel refactor that renders buttons from that list. **Behavior\ndoes not change** — the three existing actions are folded in with no\nsemantic change. The payoff is every subsequent action objective (patrol,\nsiege, pillage, embark, ...) ships as one enum variant + one JSON keyword\nmapping + one handler, with no UI or AI scaffolding to re-invent."
+ },
+ {
+ "id": "p1-21",
+ "title": "Unit patrol orders — standing order to loop between waypoint tiles",
+ "priority": "p1",
+ "status": "stub",
+ "scope": "game1",
+ "owner": "wireguard",
+ "updated_at": "2026-04-18",
+ "summary": "Both the human player and the AI clans need a *standing order* that keeps a\nunit moving along a fixed route turn after turn without per-turn micro-\nmanagement. Canonical use cases: escorting a worker loop, covering a\nchokepoint, sweeping scout fog between two outposts.\n\nToday a unit has two durable states: idle-on-tile, or fortified. `Skip`\nends the turn but does not persist. A player who wants a scout to pace\nbetween two tiles must hand-move it every single turn — which breaks down\nonce the empire has more than a few units, and which the AI cannot express\nat all because `mc-ai/tactical/movement.rs` re-plans from scratch each turn.\n\nThis objective adds a third durable state — **patrol** — with a small\nwaypoint list, a direction cursor, and a loop mode. While patrolling, the\nunit auto-advances along its route during the turn processor before the\nplayer's input phase, so turn N+1 opens with the unit already at the next\nstep on its loop.\n\n**This objective assumes p1-20 (unit action capability registry) has\nshipped.** Patrol plugs into the registry as one new `ActionKind` variant\nplus its handlers — no bespoke unit-panel buttons, no scattered\n`is_patrolling` checks in GDScript. If p1-20 slips, reassess whether to\nland a narrower patrol-only version first."
},
{
"id": "p2-01",
diff --git a/public/games/age-of-dwarves/data/schemas/episode-systems.schema.json b/public/games/age-of-dwarves/data/schemas/episode-systems.schema.json
new file mode 100644
index 00000000..9d2b6929
--- /dev/null
+++ b/public/games/age-of-dwarves/data/schemas/episode-systems.schema.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Episode systems manifest",
+ "type": "object",
+ "required": ["systems"],
+ "additionalProperties": false,
+ "properties": {
+ "systems": {
+ "type": "array",
+ "minItems": 1,
+ "items": { "type": "string", "minLength": 1 }
+ }
+ }
+}
diff --git a/public/games/age-of-dwarves/data/schemas/homepage-features.schema.json b/public/games/age-of-dwarves/data/schemas/homepage-features.schema.json
new file mode 100644
index 00000000..1bf78c97
--- /dev/null
+++ b/public/games/age-of-dwarves/data/schemas/homepage-features.schema.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Homepage feature card",
+ "type": "object",
+ "required": ["title", "desc"],
+ "additionalProperties": false,
+ "properties": {
+ "title": { "type": "string", "minLength": 1 },
+ "desc": { "type": "string", "minLength": 1 },
+ "min_episode": { "type": "integer", "minimum": 1 }
+ }
+}
diff --git a/public/games/age-of-dwarves/data/schemas/map-topology.schema.json b/public/games/age-of-dwarves/data/schemas/map-topology.schema.json
new file mode 100644
index 00000000..f5eab86f
--- /dev/null
+++ b/public/games/age-of-dwarves/data/schemas/map-topology.schema.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Map topology mode",
+ "type": "object",
+ "required": ["name", "desc"],
+ "additionalProperties": false,
+ "properties": {
+ "name": { "type": "string", "minLength": 1 },
+ "desc": { "type": "string", "minLength": 1 },
+ "math": { "type": "string" },
+ "is_default": { "type": "boolean" }
+ }
+}
diff --git a/public/games/age-of-dwarves/data/schemas/shipping-roadmap.schema.json b/public/games/age-of-dwarves/data/schemas/shipping-roadmap.schema.json
new file mode 100644
index 00000000..1126f53d
--- /dev/null
+++ b/public/games/age-of-dwarves/data/schemas/shipping-roadmap.schema.json
@@ -0,0 +1,34 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Shipping roadmap",
+ "type": "object",
+ "required": ["coming_in_v1", "after_full_release"],
+ "additionalProperties": false,
+ "properties": {
+ "coming_in_v1": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["priority", "system", "description"],
+ "additionalProperties": false,
+ "properties": {
+ "priority": { "type": "integer", "minimum": 0 },
+ "system": { "type": "string", "minLength": 1 },
+ "description": { "type": "string", "minLength": 1 }
+ }
+ }
+ },
+ "after_full_release": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["version", "systems"],
+ "additionalProperties": false,
+ "properties": {
+ "version": { "type": "string", "minLength": 1 },
+ "systems": { "type": "string", "minLength": 1 }
+ }
+ }
+ }
+ }
+}
diff --git a/src/game/engine/scenes/hud/unit_panel.gd b/src/game/engine/scenes/hud/unit_panel.gd
index 9fc713af..71b9a6a7 100644
--- a/src/game/engine/scenes/hud/unit_panel.gd
+++ b/src/game/engine/scenes/hud/unit_panel.gd
@@ -7,8 +7,6 @@ extends PanelContainer
## to the actual handlers — the panel itself never mutates the simulation
## directly. Disabled buttons keep a tooltip explaining WHY they cannot fire.
-const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
-
## Action signals consumed by world_map.gd. Disconnected from the slim HUD
## panel that p0-33 retired.
signal move_pressed
@@ -17,6 +15,8 @@ signal skip_pressed
signal found_city_pressed
signal build_improvement_pressed
+const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
+
## Disabled-button outline color (matches the action-required tutorial badge).
const DISABLED_OUTLINE_COLOR: Color = Color(0.95, 0.35, 0.35, 0.85)
@@ -109,9 +109,11 @@ func _refresh_display() -> void:
var move_remaining: int = _get_movement_remaining(_selected_unit)
var move_total: int = _get_movement_total(_selected_unit)
- _attack_label.text = ThemeVocabulary.lookup("fmt_key_int") % [ThemeVocabulary.lookup("attack"), attack]
- _defense_label.text = ThemeVocabulary.lookup("fmt_key_int") % [ThemeVocabulary.lookup("defense"), defense]
- _hp_label.text = ThemeVocabulary.lookup("fmt_current_of_max") % [ThemeVocabulary.lookup("hit_points"), hp, max_hp]
+ var key_fmt: String = ThemeVocabulary.lookup("fmt_key_int")
+ var pair_fmt: String = ThemeVocabulary.lookup("fmt_current_of_max")
+ _attack_label.text = key_fmt % [ThemeVocabulary.lookup("attack"), attack]
+ _defense_label.text = key_fmt % [ThemeVocabulary.lookup("defense"), defense]
+ _hp_label.text = pair_fmt % [ThemeVocabulary.lookup("hit_points"), hp, max_hp]
_movement_label.text = ThemeVocabulary.lookup("fmt_current_of_max") % [
ThemeVocabulary.lookup("movement"), move_remaining, move_total,
]
diff --git a/src/game/engine/scenes/world_map/world_map.gd b/src/game/engine/scenes/world_map/world_map.gd
index 5f227bb2..75e0bd46 100644
--- a/src/game/engine/scenes/world_map/world_map.gd
+++ b/src/game/engine/scenes/world_map/world_map.gd
@@ -75,8 +75,12 @@ var _last_hover_axial: Vector2i = Vector2i(-9999, -9999)
@onready var _viewport_manager: Control = $ViewportWindowManager
@onready var _hud: CanvasLayer = $WorldMapHud
@onready var _tech_tree: CanvasLayer = $TechTree
-@onready var _minimap: Control = $MinimapLayer/Minimap if has_node("MinimapLayer/Minimap") else null
-@onready var _unit_panel: Node = $UnitPanelLayer/UnitPanel if has_node("UnitPanelLayer/UnitPanel") else null
+@onready var _minimap: Control = (
+ $MinimapLayer/Minimap if has_node("MinimapLayer/Minimap") else null
+)
+@onready var _unit_panel: Node = (
+ $UnitPanelLayer/UnitPanel if has_node("UnitPanelLayer/UnitPanel") else null
+)
func _ready() -> void:
@@ -529,42 +533,44 @@ func _is_prologue_active() -> bool:
func _handle_hotkeys(key_event: InputEventKey) -> bool:
+ var handled: bool = false
match key_event.keycode:
KEY_T:
_toggle_tech_tree()
- get_viewport().set_input_as_handled()
- return true
+ handled = true
KEY_C:
_toggle_chronicle()
- get_viewport().set_input_as_handled()
- return true
+ handled = true
KEY_B:
if _selected_unit != null:
_on_build_improvement_pressed()
- get_viewport().set_input_as_handled()
- return true
+ handled = true
KEY_M:
## p0-35 enter movement mode when a unit is selected and has MP.
if _selected_unit != null and _selected_unit_has_movement():
_enter_movement_mode()
- get_viewport().set_input_as_handled()
- return true
+ handled = true
KEY_ESCAPE:
- ## p0-35: ESC cancels movement mode FIRST (before bubbling to
- ## panels or the in-game menu owned by main.gd).
- if _movement_mode:
- _exit_movement_mode()
- get_viewport().set_input_as_handled()
- return true
- if _chronicle_panel != null and _chronicle_panel.visible:
- _chronicle_panel.hide()
- EventBus.chronicle_closed.emit(GameState.current_player_index)
- get_viewport().set_input_as_handled()
- return true
- if _tech_tree.visible:
- _tech_tree.close()
- get_viewport().set_input_as_handled()
- return true
+ handled = _handle_escape_key()
+ if handled:
+ get_viewport().set_input_as_handled()
+ return handled
+
+
+## p0-35: ESC dispatch — cancels movement mode FIRST, then closes the
+## topmost open panel (chronicle, tech tree). Falls through to false so
+## main.gd can open the in-game menu when nothing here consumed it.
+func _handle_escape_key() -> bool:
+ if _movement_mode:
+ _exit_movement_mode()
+ return true
+ if _chronicle_panel != null and _chronicle_panel.visible:
+ _chronicle_panel.hide()
+ EventBus.chronicle_closed.emit(GameState.current_player_index)
+ return true
+ if _tech_tree.visible:
+ _tech_tree.close()
+ return true
return false
@@ -958,7 +964,9 @@ func update_path_preview(hovered_axial: Vector2i) -> void:
var scouted: Dictionary = _build_scouted_dict()
## Use a generous budget so multi-turn previews can be displayed; the
## turn count is computed below from the per-tile cost dictionary.
- var movement_total: int = int(_selected_unit.get_movement()) if _selected_unit.has_method("get_movement") else 2
+ var movement_total: int = 2
+ if _selected_unit.has_method("get_movement"):
+ movement_total = int(_selected_unit.get_movement())
var preview_budget: int = maxi(movement_total * 8, 16)
var path: Array[Vector2i] = PathfinderScript.find_path_with_fog(
game_map,
diff --git a/tools/validate-game-data.py b/tools/validate-game-data.py
index ab0f07e9..e1ee9cce 100644
--- a/tools/validate-game-data.py
+++ b/tools/validate-game-data.py
@@ -291,6 +291,57 @@ class GameDataValidator:
else:
self._ok(label)
+ def validate_guide_data(self):
+ """Validate the four guide-consumed JSON files extracted from hardcoded
+ page enums (p2-32). Each has a minimal schema in data/schemas/."""
+ print("\n guide-data enums")
+
+ # homepage-features.json: {"features": [card, ...]}
+ schema = self._load_schema("homepage-features")
+ if schema is not None:
+ path = self.game_data / "homepage-features.json"
+ data, err = load_json_safe(path)
+ if err:
+ self._fail("homepage-features.json", f"parse error: {err}")
+ else:
+ rel = path.relative_to(self.root)
+ for i, card in enumerate(data.get("features", [])):
+ self._validate_entry(schema, card, f"{rel}[features][{i}]")
+
+ # map-topologies.json: {"topologies": [topology, ...]}
+ schema = self._load_schema("map-topology")
+ if schema is not None:
+ path = self.game_data / "map-topologies.json"
+ data, err = load_json_safe(path)
+ if err:
+ self._fail("map-topologies.json", f"parse error: {err}")
+ else:
+ rel = path.relative_to(self.root)
+ for i, topo in enumerate(data.get("topologies", [])):
+ self._validate_entry(schema, topo, f"{rel}[topologies][{i}]")
+
+ # episodes/ep1-systems.json: whole-file wrapper validation
+ schema = self._load_schema("episode-systems")
+ if schema is not None:
+ path = self.game_data / "episodes" / "ep1-systems.json"
+ data, err = load_json_safe(path)
+ if err:
+ self._fail("episodes/ep1-systems.json", f"parse error: {err}")
+ else:
+ rel = path.relative_to(self.root)
+ self._validate_entry(schema, data, str(rel))
+
+ # shipping-roadmap.json: whole-file wrapper validation
+ schema = self._load_schema("shipping-roadmap")
+ if schema is not None:
+ path = self.game_data / "shipping-roadmap.json"
+ data, err = load_json_safe(path)
+ if err:
+ self._fail("shipping-roadmap.json", f"parse error: {err}")
+ else:
+ rel = path.relative_to(self.root)
+ self._validate_entry(schema, data, str(rel))
+
def validate_cross_refs(self):
"""Cross-reference checks: collectibles → resources, gates_* → units/buildings."""
resources = self._load_resources()
@@ -354,6 +405,7 @@ class GameDataValidator:
self.validate_improvements()
self.validate_biomes()
self.validate_deposit_concept_refs()
+ self.validate_guide_data()
self.validate_cross_refs()
def report(self) -> int:
|