From f4dacc216db48e873677ffcffb21384b33f29801 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 18 Apr 2026 04:53:38 -0700 Subject: [PATCH] =?UTF-8?q?feat(objectives):=20=E2=9C=A8=20update=20priori?= =?UTF-8?q?ty=20counts=20and=20wireguard=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .project/objectives/README.md | 8 +-- .../games/age-of-dwarves/data/objectives.json | 24 +++++--- .../data/schemas/episode-systems.schema.json | 14 +++++ .../schemas/homepage-features.schema.json | 12 ++++ .../data/schemas/map-topology.schema.json | 13 ++++ .../data/schemas/shipping-roadmap.schema.json | 34 +++++++++++ src/game/engine/scenes/hud/unit_panel.gd | 12 ++-- src/game/engine/scenes/world_map/world_map.gd | 60 +++++++++++-------- tools/validate-game-data.py | 52 ++++++++++++++++ 9 files changed, 187 insertions(+), 42 deletions(-) create mode 100644 public/games/age-of-dwarves/data/schemas/episode-systems.schema.json create mode 100644 public/games/age-of-dwarves/data/schemas/homepage-features.schema.json create mode 100644 public/games/age-of-dwarves/data/schemas/map-topology.schema.json create mode 100644 public/games/age-of-dwarves/data/schemas/shipping-roadmap.schema.json 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: