10 KiB
| id | title | priority | scope | owner | status | updated_at | evidence | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| p1-20 | Unit action capability registry — one source of truth for "what can this unit do right now?" | p1 | game1 | wireguard | done | 2026-04-19 |
|
Summary
The game has no unified answer to "what actions can unit U take on turn T in
state S?" Today the unit panel (unit_panel.gd:19-40) hardcodes three
buttons — Fortify, Skip, Found City — and decides visibility with bespoke
per-unit booleans scattered across the JSON (can_found_city,
can_build_improvements, flags: ["ranged"]) and ad-hoc GDScript predicates
(is_civilian()). Meanwhile mc-ai/src/tactical/movement.rs enumerates
moves and attacks but has no registry for non-motion actions. UI and AI have
no shared truth.
Every future action — patrol (p1-21), siege pack/deploy, pillage, embark,
build-road, heal, upgrade — compounds that debt by adding another hardcoded
button plus its own scattered check. A siege engine in packed state can
move but not bombard; in deployed state can bombard but not move. Patrol
has the same shape (idle ↔ patrolling, with auto-cancel). Fortify has the
same shape. Without a registry, each state gate becomes a new bespoke flag.
This objective lands the foundation: a JSON-driven capability declaration,
a Rust ActionKind enum with a single legal_actions(unit, state) query,
and a unit-panel refactor that renders buttons from that list. Behavior
does not change — the three existing actions are folded in with no
semantic change. The payoff is every subsequent action objective (patrol,
siege, pillage, embark, ...) ships as one enum variant + one JSON keyword
mapping + one handler, with no UI or AI scaffolding to re-invent.
Design
JSON — keyword-driven action sets. Unit JSON gets no per-unit actions
list (too much duplication). Instead, keywords and unit_type imply
actions via a lookup table in a new file data/unit_actions.json:
{
"by_unit_type": {
"military": ["move", "attack", "fortify", "skip"],
"civilian": ["move", "skip"]
},
"by_keyword": {
"founder": ["found_city"],
"worker": ["build_improvement", "pillage_friendly"],
"ranged": ["ranged_attack"],
"siege": ["pack_siege", "deploy_siege", "bombard"],
"amphibious":["embark", "disembark"]
}
}
Unit JSON files add a keywords: [...] array where needed (existing
flags: ["ranged"] migrates to keywords: ["ranged"]; can_found_city: true migrates to keywords: ["founder"]; same for can_build_improvements
→ keywords: ["worker"]). The legacy fields are removed, not aliased — per
Rail-9 (zero tech debt).
Rust — the capability query. New module mc-sim/src/action.rs:
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ActionKind {
Move, Attack, RangedAttack,
Fortify, Unfortify, Skip,
FoundCity, BuildImprovement, PillageFriendly,
PackSiege, DeploySiege, Bombard,
Embark, Disembark,
// p1-21 will add: IssuePatrol, CancelPatrol
}
#[derive(Clone, Debug)]
pub struct ActionAvailability {
pub kind: ActionKind,
pub enabled: bool, // greyed-out button vs hidden button
pub disabled_reason: Option<DisabledReason>, // typed, for tooltip
}
pub fn legal_actions(unit: &Unit, state: &WorldState) -> Vec<ActionAvailability>;
legal_actions is the only place that knows:
- which keywords a unit has → which base actions are candidates,
- which candidates survive state gates (movement_remaining > 0 for
Move;!is_fortifiedforFortify; deployed state forBombard; friendly tile forFoundCity; etc.).
DisabledReason is a typed enum (NoMovement, AlreadyFortified,
WrongTerrain, NotOnFriendlyTile, SiegeMustDeployFirst, ...) so the
UI's tooltip and the AI's explainer consume the same string through
ThemeVocabulary.lookup("disabled_reason_no_movement").
GDExtension bridge. New api-gdext/src/action.rs exposes:
GdUnitActions.legal_actions(unit_id) -> Array[Dictionary]returning[{kind: "fortify", enabled: true, disabled_reason: ""}, ...]GdUnitActions.invoke(unit_id, kind: String, args: Dictionary) -> bool
Action handlers (fortify, found-city, etc.) move from their current
scattered locations into mc-turn/src/action_handlers.rs, one match arm
per ActionKind. GDScript's unit.gd keeps thin methods that forward to
the bridge.
UI — data-driven unit panel. unit_panel.gd stops holding hardcoded
@onready var _fortify_button / _skip_button / _found_city_button.
Instead it instantiates buttons from the array returned by
GdUnitActions.legal_actions(selected_unit.id), one button per entry,
using a small action_button.tscn template. Vocab keys follow a strict
pattern: action_<kind> for label, tooltip_action_<kind> for tooltip,
disabled_reason_<variant> for the disabled tooltip.
Button order is stable (defined alongside the enum in
mc-sim/src/action.rs as a fixed ordering function) so the UI layout
doesn't flip as state changes. Disabled actions render greyed-out with the
typed reason in the tooltip — they do not disappear, so the player
learns what's possible even when it's not currently legal. (Exception:
actions the unit fundamentally cannot take — a civilian has no Attack
button at all — are absent, not disabled.)
AI integration. mc-ai/src/tactical/movement.rs calls
legal_actions(unit, state) to enumerate the non-motion macro-actions
available, then scores them inside the existing policy framework. No more
hardcoded "can this unit fortify?" checks; the registry is the answer.
This is the hook p1-21 (patrol) will extend with one new variant.
Acceptance
- ✓
mc-sim/src/action.rsdefinesActionKind,ActionAvailability,DisabledReason, andlegal_actions(unit, state). - ✓
data/unit_actions.jsonexists and mapsby_unit_type+by_keyword→ action lists. Validated at DataLoader time with a schema check. - ✓ All existing unit JSON files migrated:
flags: ["ranged"]→keywords: ["ranged"];can_found_city: true→keywords: ["founder"];can_build_improvements: true→keywords: ["worker"]. Legacy fields removed, not aliased. - ✓
GdUnitActionsGDExtension class exposeslegal_actions(unit_id)andinvoke(unit_id, kind, args). - ✓
unit_panel.gdrenders buttons fromlegal_actions; no hardcoded@onreadyaction buttons remain. Button order is stable across state transitions. - ✓ Disabled actions stay visible (greyed) with typed tooltip reason; fundamentally-unavailable actions are absent (no Attack button on civilians).
- ✓
mc-ai/src/tactical/movement.rsconsumeslegal_actionsto enumerate non-motion macros — at minimum Fortify and Skip are sourced from the registry rather than hardcoded. - ✓ Behavior parity: playing through a full game before and after this refactor produces identical unit-panel button sets at every turn on a deterministic fixture (golden-file test).
- ✓ GUT headless tests pass: capability-query unit tests, panel rendering, migration loader.
- ✓
./run verifypasses with noflags/can_found_city/can_build_improvementsreferences remaining in runtime code.
Files to touch
| File | Change |
|---|---|
src/simulator/crates/mc-sim/src/action.rs |
New. ActionKind, ActionAvailability, DisabledReason, legal_actions, stable button-order function. |
src/simulator/crates/mc-turn/src/action_handlers.rs |
New. One handler per ActionKind. Existing fortify/skip/found-city logic migrates here. |
src/simulator/crates/mc-turn/src/processor.rs |
Route action invocations through action_handlers. Remove inline fortify/skip/found-city arms. |
src/simulator/crates/mc-ai/src/tactical/movement.rs |
Call legal_actions to enumerate non-motion macros. |
src/simulator/api-gdext/src/action.rs |
New. GdUnitActions.legal_actions + invoke. |
src/simulator/api-gdext/src/lib.rs |
Register GdUnitActions. |
public/games/age-of-dwarves/data/unit_actions.json |
New. by_unit_type + by_keyword → action-list map. |
public/games/age-of-dwarves/data/units/*.json |
Migrate flags → keywords; remove can_found_city / can_build_improvements (folded into keywords). |
src/game/engine/src/entities/unit.gd |
Thin legal_actions() + invoke_action(kind, args) methods forwarding to bridge. Remove bespoke fortify() etc. |
src/game/engine/scenes/hud/unit_panel.gd + .tscn |
Render buttons dynamically from legal_actions. Drop hardcoded @onready buttons. |
src/game/engine/scenes/hud/action_button.tscn |
New. Template for a single action button, reused per entry. |
src/game/engine/src/loaders/data_loader.gd |
Schema validation for unit_actions.json + keyword-vs-action consistency check at boot. |
public/games/age-of-dwarves/data/vocabulary.json |
action_<kind>, tooltip_action_<kind>, disabled_reason_<variant> for every current action. |
Depends on
- None. Pure refactor + one new data file.
Non-goals
- Adding new actions. This objective ships exactly the action set that exists today (Fortify, Skip, FoundCity, BuildImprovement, Move, Attack, RangedAttack). Siege pack/deploy and embark/disembark are stubbed in the enum with no handler wired — follow-up objectives.
- Hotkey remapping. Button-dispatch by hotkey gets one generic
ui_unit_action_Nbinding (1-9), not per-action hotkeys — p1-21 and friends can pin specific letters later. - Reordering the current button layout for players. Same visual order, same labels, same tooltips (modulo the typed disabled reasons, which are a small UX gain).
Why this is P1 (not P0)
Game 1 is shipping with the current hardcoded panel. This is a tech-debt payoff that unblocks patrol (p1-21) and every future standing-order feature. Upgrade to P0 only if a P0 objective (p1-21, or Game 2 siege) lands first and starts accumulating more hardcoded buttons before this refactor ships.