magicciv/.project/objectives/p1-20-unit-action-capability-registry.md
Natalie 6092a3966c docs(@projects): update unit action capability registry status
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-19 17:59:40 -07:00

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
src/simulator/crates/mc-core/src/action.rs
src/simulator/crates/mc-turn/src/action.rs
src/simulator/crates/mc-turn/src/action_handlers.rs
src/simulator/api-gdext/src/action.rs
src/game/engine/scenes/hud/unit_panel.gd
src/game/engine/scenes/hud/unit_panel.tscn
src/game/engine/scenes/hud/action_button.tscn
src/game/engine/src/entities/unit.gd
src/game/engine/src/autoloads/data_loader.gd
src/game/engine/tests/unit/entities/test_unit_actions.gd
src/simulator/crates/mc-ai/src/tactical/movement.rs
public/games/age-of-dwarves/data/unit_actions.json
public/games/age-of-dwarves/data/units/archer.json
public/games/age-of-dwarves/vocabulary.json

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_improvementskeywords: ["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_fortified for Fortify; deployed state for Bombard; friendly tile for FoundCity; 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.rs defines ActionKind, ActionAvailability, DisabledReason, and legal_actions(unit, state).
  • data/unit_actions.json exists and maps by_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: truekeywords: ["founder"]; can_build_improvements: truekeywords: ["worker"]. Legacy fields removed, not aliased.
  • GdUnitActions GDExtension class exposes legal_actions(unit_id) and invoke(unit_id, kind, args).
  • unit_panel.gd renders buttons from legal_actions; no hardcoded @onready action 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.rs consumes legal_actions to 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 verify passes with no flags / can_found_city / can_build_improvements references 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 flagskeywords; 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_N binding (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.