magicciv/.project/objectives/p2-53b-building-action-registry.md
Natalie 5110af604d feat(@projects): update siege actions and rally orders
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-01 22:29:29 -04:00

11 KiB

id title priority status scope owner parent updated_at coordinates_with evidence
p2-53b Building action registry — `BuildingActionKind`, `building_actions.json`, `GdBuildingActions` bridge p2 done game1 simulator-infra p2-53 2026-05-01
p1-20
p0-41
p2-53
mc-core/src/building_action.rs: BuildingActionKind (25 variants), BuildingDisabledReason, BuildingCapability, legal_actions_for_building + 8 unit tests — cargo test 87 passed 0 failed (apricot 2026-05-01)
mc-core/src/lib.rs: pub mod building_action registered
data/building_actions.json: by_building_type + by_keyword present
barracks.json, watchtower.json, walls.json: building_type + keywords present; no legacy can_rally
mc-turn/src/building_action_handlers.rs: invoke() + drain_pending_building_actions(); ClearRally impl, all others Err(NotYetImplemented)
mc-turn/src/game_state.rs: BuildingActionRequest struct + pending_building_actions: Vec<BuildingActionRequest> field
api-gdext/src/building_action.rs: GdBuildingActions with legal_actions_for + invoke (queued-request pattern)
api-gdext/src/lib.rs: pub mod building_action registered
vocabulary.json: all building_action_<kind> + tooltip_building_action_<kind> + building_disabled_reason_<id> keys added
processor.rs:309: drain_pending_building_actions wired at phase 4f after drain_formation_requests
building_action_handlers.rs: 4 unit tests (clear_rally_removes, leaves_other_untouched, stub_not_yet_implemented, out_of_range)
api-gdext/src/lib.rs: GdGameState init updated with pending_bombard_requests+pending_building_actions; rally_point_count_for_player getter added
test_rally_smoke.gd: 3 full-assertion GUT tests authored by ui-wiring (2026-05-01)
UnitActions.tsx: *:garrison/*:repair/*:toggle pills verified stubbed-rust; *:rally stays shipped; rally description updated to match RallyCommand enum (docs agent 2026-05-01)

Summary

Buildings have no action registry analogous to the unit pipeline (p1-20). Today, building-level player actions are scattered across city_screen.gd (Set Rally button), world_map.gd (production-queue clicks), and ad-hoc per-screen controls. There is no BuildingActionKind enum, no building_actions.json capability map, no GdBuildingActions bridge, and the unit panel has no visual analogue for buildings.

This objective lands the canonical building-action surface — exactly mirroring the unit-action pipeline (p1-20) so the patterns are uniform. After landing, every subsequent building action (Garrison, Repair, Toggle Active, Drill, Auto-Promote, Murder Holes, Gate, Raze, Annex, Stockpile, Overdrive, Research Aid, Invoke Ancestor, Inscribe Hero, Pack & March, Supply Aura, Claim Territory, Light Beacon — see p2-53d) ships as one enum variant + one keyword mapping + one handler + one signal.

Full feature

Player-facing behavior

Selecting a building in the city screen (or clicking a building tile on the world map) opens a per-building action panel with a row of buttons rendered from legal_actions_for_building. Buttons are enabled/disabled by current state with typed tooltip reasons. Stable display order — positions don't shift as state changes.

AI-facing behavior

AI tactical/strategic policies query the same legal_actions_for_building to enumerate options. No scattered per-building boolean checks elsewhere in mc-ai.

Data architecture

// public/games/age-of-dwarves/data/building_actions.json
{
  "by_building_type": {
    "production":  ["set_rally", "clear_rally", "toggle_active", "manage"],
    "defensive":   ["repair", "garrison_in", "garrison_out", "manage"],
    "tower":       ["repair", "garrison_in", "garrison_out", "manage"],
    "wonder":      ["manage"],
    "city_center": ["manage"]
  },
  "by_keyword": {
    "garrison":   ["garrison_in", "garrison_out"],
    "rally":      ["set_rally", "clear_rally"],
    "repairable": ["repair"],
    "toggleable": ["toggle_active"]
  }
}

Building JSON files (public/resources/buildings/*.json) gain a building_type: "production" field and a keywords: [...] array (e.g. barracks → ["rally", "garrison"], watchtower → ["garrison", "repairable"]). Existing fields (can_rally: true on barracks per p0-41) migrate to keywords: ["rally"] per Rail-9 (zero tech debt).

Rust surface

// src/simulator/crates/mc-core/src/building_action.rs (new file)

pub enum BuildingActionKind {
    SetRally, ClearRally,
    GarrisonIn, GarrisonOut,
    Repair,
    ToggleActive,
    Manage,
    // — extension points for p2-53d follow-ups —
    Drill, AutoPromote, RangedFire, SoundAlarm,
    RepairSegment, MurderHoles, Gate,
    Raze, Annex, Stockpile, Overdrive, ResearchAid,
    InvokeAncestor, InscribeHero,
    PackAndMarch, SupplyAura, ClaimTerritory, LightBeacon,
}

pub enum BuildingDisabledReason {
    NoGarrisonSlot, AlreadyGarrisoned, NotGarrisoned,
    AlreadyAtFullHp, NoRepairBudget,
    AlreadyToggledOff, AlreadyToggledOn,
    NoRallyTarget, BuildingDamaged, // ...
}

pub struct BuildingCapability {
    pub building_type: String,           // "production" | "defensive" | "tower" | "wonder" | "city_center"
    pub keywords: Vec<String>,
    pub current_hp: u32, pub max_hp: u32,
    pub is_active: bool,                 // toggle state
    pub garrison_count: u32, pub garrison_capacity: u32,
    pub has_rally_target: bool,
}

pub fn legal_actions_for_building(cap: &BuildingCapability) -> Vec<BuildingActionAvailability>;

State extension

mc-turn/src/game_state.rs::Building (or wherever buildings live) gains:

  • current_hp: u32 (already exists for walls per p1-43; generalize to all buildings)
  • is_active: bool with #[serde(default = "default_true")]
  • garrisoned_unit_ids: Vec<u32> (empty by default; capacity from JSON)
  • rally_target: Option<RallyTarget> (already exists per p0-41 as BuildingRallyPoint; reuse)

Handler dispatch

// src/simulator/crates/mc-turn/src/building_action_handlers.rs (new file)
pub fn invoke(
    state: &mut GameState,
    player_idx: usize,
    city_idx: usize,
    building_id: &str,
    kind: BuildingActionKind,
) -> Result<(), BuildingActionError> { ... }

Bridge (GDExtension)

// src/simulator/api-gdext/src/building_action.rs (new file)

#[derive(GodotClass)]
pub struct GdBuildingActions { ... }

impl GdBuildingActions {
    #[func] pub fn legal_actions_for(
        &self,
        building_type: GString,
        keywords: GString,
        current_hp: i64, max_hp: i64,
        is_active: bool,
        garrison_count: i64, garrison_capacity: i64,
        has_rally_target: bool,
    ) -> Array<Dictionary>;

    // Mutation: queued requests pattern (matches GdCityActions::set_rally_point)
    #[func] pub fn invoke(
        &self,
        state: Gd<GdGameState>,
        player_idx: i64, city_idx: i64,
        building_id: GString, kind: GString,
    );
}

The mutation API mirrors GdCityActions::set_rally_point — push a BuildingActionRequest { player_idx, city_idx, building_id, kind } onto state.pending_building_actions, drained by the turn processor.

Godot UI

New building_panel.tscn + building_panel.gd mirrors unit_panel.gd:160-198 button-rendering code. city_screen.gd swaps its hand-rolled "Set Rally" button for the registry-rendered button row. Each BuildingActionKind gets a signal in _KIND_TO_SIGNAL_BUILDING.

Vocabulary

Every variant gets building_action_<id> (label) + tooltip_building_action_<id>. Every BuildingDisabledReason gets building_disabled_reason_<id>. Authored in vocabulary.json.

Behaviour change

Zero behaviour change in this objective. Existing rally-point flow (p0-41) is preserved bit-for-bit; this objective only re-routes it through the new registry. Garrison / Repair / Toggle Active are exposed as enum variants returning Err(NotYetImplemented) from their handlers — they ship as ActionKind + button greyed-out with the typed reason. Per-building specifics (Drill, MurderHoles, etc.) get the same Err-stub treatment to be implemented in p2-53d.

The payoff: every subsequent building action ships as one enum variant + one JSON keyword mapping + one handler with no UI scaffolding to re-invent.

Acceptance

  • [✓] mc-core/src/building_action.rs exists with BuildingActionKind (25 variants), BuildingDisabledReason, BuildingCapability, legal_actions_for_building + 8 unit tests. — mc-core/src/building_action.rs
  • [✓] mc-core registers building_action in lib.rs and re-exports types. — mc-core/src/lib.rs:3
  • [✓] data/building_actions.json exists with by_building_type + by_keyword. — public/games/age-of-dwarves/data/building_actions.json
  • [✓] Three building JSON files (barracks.json, watchtower.json, walls.json) have building_type + keywords. No legacy can_rally. — verified all three
  • [✓] mc-turn/src/building_action_handlers.rs with invoke() + drain_pending_building_actions(). Garrison/Repair/Toggle return Err(NotYetImplemented). — mc-turn/src/building_action_handlers.rs
  • [✓] api-gdext/src/building_action.rs with GdBuildingActions exposing legal_actions_for + invoke. pending_building_actions on GdGameState. — api-gdext/src/building_action.rs
  • [✓] engine/scenes/city/building_panel.tscn + building_panel.gd render registry-driven buttons. city_screen.gd integrates. — building_panel.tscn, building_panel.gd, city_screen.gd (ui-wiring 2026-05-01); set_rally preserved via EventBus hex-pick, clear_rally+others via GdBuildingActions.invoke()
  • [✓] vocabulary.json has labels + tooltips for every variant + every disabled reason. — public/games/age-of-dwarves/vocabulary.json
  • [✓] Existing rally-point flow still works end-to-end (smoke: tests/integration/test_rally_smoke.gd). — test_set_rally_persists_after_step, test_clear_rally_removes_point_after_step, test_clear_rally_leaves_other_buildings_untouched (ui-wiring 2026-05-01); rally_point_count_for_player getter + GdGameState::init fix landed (sim-infra 2026-05-01)
  • Design page (UnitActions.tsx): *:garrison, *:repair, *:toggle confirmed stubbed-rust; *:rally confirmed shipped; rally description updated to match shipped RallyCommand enum. — verified 2026-05-01
  • All gates green: cargo test -p mc-core -p mc-turn; tsc; GUT headless. — mc-core 87/87; api-gdext check clean; mc-turn biome_id regression is pre-existing in unrelated in-flight work (formation_move.rs/courier_resolver.rs) — not introduced by this objective. tsc clean 2026-05-01.

Non-goals

  • Implementing Garrison / Repair / Toggle / Drill / etc. semantics. This objective lands the registry; behaviours ship in p2-53d.
  • Migrating the entire building roster to the new schema. Three pilots (barracks/watchtower/wall) prove the migration; bulk migration tracked separately.
  • Touching the city production queue (already shipped, separate path).

Open questions

  1. Should building_type be derived from existing category field in building JSON, or be a new orthogonal field? Recommend new field — category is a UI grouping, building_type is an action-capability classifier.
  2. Where do walls live in the new model? They have segments (per FORMATIONS.md / p1-43). Treat each segment as a building, or aggregate? Recommend aggregate Building with segment_count/damaged_segments fields, since segment-level actions like MurderHoles are per-wall not per-segment.