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 |
|
|
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: boolwith#[serde(default = "default_true")]garrisoned_unit_ids: Vec<u32>(empty by default; capacity from JSON)rally_target: Option<RallyTarget>(already exists per p0-41 asBuildingRallyPoint; 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.rsexists withBuildingActionKind(25 variants),BuildingDisabledReason,BuildingCapability,legal_actions_for_building+ 8 unit tests. —mc-core/src/building_action.rs - [✓]
mc-coreregistersbuilding_actioninlib.rsand re-exports types. —mc-core/src/lib.rs:3 - [✓]
data/building_actions.jsonexists withby_building_type+by_keyword. —public/games/age-of-dwarves/data/building_actions.json - [✓] Three building JSON files (
barracks.json,watchtower.json,walls.json) havebuilding_type+keywords. No legacycan_rally. — verified all three - [✓]
mc-turn/src/building_action_handlers.rswithinvoke()+drain_pending_building_actions(). Garrison/Repair/Toggle returnErr(NotYetImplemented). —mc-turn/src/building_action_handlers.rs - [✓]
api-gdext/src/building_action.rswithGdBuildingActionsexposinglegal_actions_for+invoke.pending_building_actionsonGdGameState. —api-gdext/src/building_action.rs - [✓]
engine/scenes/city/building_panel.tscn+building_panel.gdrender registry-driven buttons.city_screen.gdintegrates. —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.jsonhas 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_playergetter +GdGameState::initfix landed (sim-infra 2026-05-01) - Design page (
UnitActions.tsx):*:garrison,*:repair,*:toggleconfirmedstubbed-rust;*:rallyconfirmedshipped; 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
- Should
building_typebe derived from existingcategoryfield in building JSON, or be a new orthogonal field? Recommend new field —categoryis a UI grouping,building_typeis an action-capability classifier. - 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_segmentsfields, since segment-level actions like MurderHoles are per-wall not per-segment.