From 06807097997da354e5c1e3da3598d99c7945d87d Mon Sep 17 00:00:00 2001 From: autocommit Date: Fri, 1 May 2026 18:42:20 -0700 Subject: [PATCH] =?UTF-8?q?feat(simulator):=20=E2=9C=A8=20Add=20new=20grid?= =?UTF-8?q?=20state=20types=20and=20core=20simulation=20logic=20for=20adva?= =?UTF-8?q?nced=20grid=20configurations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../crates/mc-core/src/building_action.rs | 412 ++++++++++++++++++ src/simulator/crates/mc-core/src/formation.rs | 12 +- src/simulator/crates/mc-core/src/grid/mod.rs | 97 ++++- src/simulator/crates/mc-core/src/lib.rs | 1 + 4 files changed, 517 insertions(+), 5 deletions(-) create mode 100644 src/simulator/crates/mc-core/src/building_action.rs diff --git a/src/simulator/crates/mc-core/src/building_action.rs b/src/simulator/crates/mc-core/src/building_action.rs new file mode 100644 index 00000000..da1b7c34 --- /dev/null +++ b/src/simulator/crates/mc-core/src/building_action.rs @@ -0,0 +1,412 @@ +//! Building action capability registry. +//! +//! `legal_actions_for_building` is the single source of truth for "what can +//! building B do right now in state S?". UI and AI both consume this list — +//! no scattered per-building boolean checks elsewhere. + +use serde::{Deserialize, Serialize}; + +/// Every action a building can ever expose to the player or AI. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BuildingActionKind { + SetRally, + ClearRally, + GarrisonIn, + GarrisonOut, + Repair, + ToggleActive, + Manage, + // Extension points for p2-53d — stubbed here, handlers return Err(NotYetImplemented). + Drill, + AutoPromote, + RangedFire, + SoundAlarm, + RepairSegment, + MurderHoles, + Gate, + Raze, + Annex, + Stockpile, + Overdrive, + ResearchAid, + InvokeAncestor, + InscribeHero, + PackAndMarch, + SupplyAura, + ClaimTerritory, + LightBeacon, +} + +impl BuildingActionKind { + /// Stable display order for UI button layout. + pub fn display_order(self) -> u8 { + match self { + BuildingActionKind::SetRally => 0, + BuildingActionKind::ClearRally => 1, + BuildingActionKind::GarrisonIn => 2, + BuildingActionKind::GarrisonOut => 3, + BuildingActionKind::Repair => 4, + BuildingActionKind::ToggleActive => 5, + BuildingActionKind::Manage => 6, + BuildingActionKind::Drill => 7, + BuildingActionKind::AutoPromote => 8, + BuildingActionKind::RangedFire => 9, + BuildingActionKind::SoundAlarm => 10, + BuildingActionKind::RepairSegment => 11, + BuildingActionKind::MurderHoles => 12, + BuildingActionKind::Gate => 13, + BuildingActionKind::Raze => 14, + BuildingActionKind::Annex => 15, + BuildingActionKind::Stockpile => 16, + BuildingActionKind::Overdrive => 17, + BuildingActionKind::ResearchAid => 18, + BuildingActionKind::InvokeAncestor => 19, + BuildingActionKind::InscribeHero => 20, + BuildingActionKind::PackAndMarch => 21, + BuildingActionKind::SupplyAura => 22, + BuildingActionKind::ClaimTerritory => 23, + BuildingActionKind::LightBeacon => 24, + } + } + + pub fn as_str(self) -> &'static str { + match self { + BuildingActionKind::SetRally => "set_rally", + BuildingActionKind::ClearRally => "clear_rally", + BuildingActionKind::GarrisonIn => "garrison_in", + BuildingActionKind::GarrisonOut => "garrison_out", + BuildingActionKind::Repair => "repair", + BuildingActionKind::ToggleActive => "toggle_active", + BuildingActionKind::Manage => "manage", + BuildingActionKind::Drill => "drill", + BuildingActionKind::AutoPromote => "auto_promote", + BuildingActionKind::RangedFire => "ranged_fire", + BuildingActionKind::SoundAlarm => "sound_alarm", + BuildingActionKind::RepairSegment => "repair_segment", + BuildingActionKind::MurderHoles => "murder_holes", + BuildingActionKind::Gate => "gate", + BuildingActionKind::Raze => "raze", + BuildingActionKind::Annex => "annex", + BuildingActionKind::Stockpile => "stockpile", + BuildingActionKind::Overdrive => "overdrive", + BuildingActionKind::ResearchAid => "research_aid", + BuildingActionKind::InvokeAncestor => "invoke_ancestor", + BuildingActionKind::InscribeHero => "inscribe_hero", + BuildingActionKind::PackAndMarch => "pack_and_march", + BuildingActionKind::SupplyAura => "supply_aura", + BuildingActionKind::ClaimTerritory => "claim_territory", + BuildingActionKind::LightBeacon => "light_beacon", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "set_rally" => Some(BuildingActionKind::SetRally), + "clear_rally" => Some(BuildingActionKind::ClearRally), + "garrison_in" => Some(BuildingActionKind::GarrisonIn), + "garrison_out" => Some(BuildingActionKind::GarrisonOut), + "repair" => Some(BuildingActionKind::Repair), + "toggle_active" => Some(BuildingActionKind::ToggleActive), + "manage" => Some(BuildingActionKind::Manage), + "drill" => Some(BuildingActionKind::Drill), + "auto_promote" => Some(BuildingActionKind::AutoPromote), + "ranged_fire" => Some(BuildingActionKind::RangedFire), + "sound_alarm" => Some(BuildingActionKind::SoundAlarm), + "repair_segment" => Some(BuildingActionKind::RepairSegment), + "murder_holes" => Some(BuildingActionKind::MurderHoles), + "gate" => Some(BuildingActionKind::Gate), + "raze" => Some(BuildingActionKind::Raze), + "annex" => Some(BuildingActionKind::Annex), + "stockpile" => Some(BuildingActionKind::Stockpile), + "overdrive" => Some(BuildingActionKind::Overdrive), + "research_aid" => Some(BuildingActionKind::ResearchAid), + "invoke_ancestor" => Some(BuildingActionKind::InvokeAncestor), + "inscribe_hero" => Some(BuildingActionKind::InscribeHero), + "pack_and_march" => Some(BuildingActionKind::PackAndMarch), + "supply_aura" => Some(BuildingActionKind::SupplyAura), + "claim_territory" => Some(BuildingActionKind::ClaimTerritory), + "light_beacon" => Some(BuildingActionKind::LightBeacon), + _ => None, + } + } +} + +/// Why a building action is disabled right now. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BuildingDisabledReason { + NoGarrisonSlot, + AlreadyGarrisoned, + NotGarrisoned, + AlreadyAtFullHp, + NoRepairBudget, + AlreadyToggledOff, + AlreadyToggledOn, + NoRallyTarget, + BuildingDamaged, + NotYetImplemented, +} + +impl BuildingDisabledReason { + pub fn vocab_key(self) -> &'static str { + match self { + BuildingDisabledReason::NoGarrisonSlot => "building_disabled_reason_no_garrison_slot", + BuildingDisabledReason::AlreadyGarrisoned => "building_disabled_reason_already_garrisoned", + BuildingDisabledReason::NotGarrisoned => "building_disabled_reason_not_garrisoned", + BuildingDisabledReason::AlreadyAtFullHp => "building_disabled_reason_already_at_full_hp", + BuildingDisabledReason::NoRepairBudget => "building_disabled_reason_no_repair_budget", + BuildingDisabledReason::AlreadyToggledOff => "building_disabled_reason_already_toggled_off", + BuildingDisabledReason::AlreadyToggledOn => "building_disabled_reason_already_toggled_on", + BuildingDisabledReason::NoRallyTarget => "building_disabled_reason_no_rally_target", + BuildingDisabledReason::BuildingDamaged => "building_disabled_reason_building_damaged", + BuildingDisabledReason::NotYetImplemented => "building_disabled_reason_not_yet_implemented", + } + } +} + +/// One entry in the capability list returned by `legal_actions_for_building`. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BuildingActionAvailability { + pub kind: BuildingActionKind, + pub enabled: bool, + pub disabled_reason: Option, +} + +impl BuildingActionAvailability { + fn enabled(kind: BuildingActionKind) -> Self { + Self { kind, enabled: true, disabled_reason: None } + } + + fn disabled(kind: BuildingActionKind, reason: BuildingDisabledReason) -> Self { + Self { kind, enabled: false, disabled_reason: Some(reason) } + } +} + +/// Building capability descriptor derived from building JSON + runtime state. +/// Passed into `legal_actions_for_building` by the GDExtension bridge. +#[derive(Clone, Debug)] +pub struct BuildingCapability { + /// Action-capability classifier: `"production"`, `"defensive"`, `"tower"`, + /// `"wonder"`, `"city_center"`. + pub building_type: String, + /// Keywords from building JSON, e.g. `["rally", "garrison"]`. + pub keywords: Vec, + pub current_hp: u32, + pub max_hp: u32, + /// Toggle state (e.g. an active smithy vs a shut-down one). + pub is_active: bool, + pub garrison_count: u32, + pub garrison_capacity: u32, + pub has_rally_target: bool, +} + +/// Compute the set of actions available to a building. +/// +/// Returned list is sorted by `BuildingActionKind::display_order` for stable +/// UI rendering. +pub fn legal_actions_for_building(cap: &BuildingCapability) -> Vec { + let has_rally = cap.keywords.iter().any(|k| k == "rally"); + let has_garrison = cap.keywords.iter().any(|k| k == "garrison"); + let has_repairable = cap.keywords.iter().any(|k| k == "repairable"); + let has_toggleable = cap.keywords.iter().any(|k| k == "toggleable"); + + let is_at_full_hp = cap.current_hp >= cap.max_hp; + let garrison_full = cap.garrison_count >= cap.garrison_capacity; + let garrison_empty = cap.garrison_count == 0; + + let mut out: Vec = Vec::new(); + + // Rally actions — production-type buildings with "rally" keyword + if has_rally { + if cap.has_rally_target { + out.push(BuildingActionAvailability::enabled(BuildingActionKind::SetRally)); + out.push(BuildingActionAvailability::enabled(BuildingActionKind::ClearRally)); + } else { + out.push(BuildingActionAvailability::enabled(BuildingActionKind::SetRally)); + out.push(BuildingActionAvailability::disabled( + BuildingActionKind::ClearRally, + BuildingDisabledReason::NoRallyTarget, + )); + } + } + + // Garrison actions — buildings with "garrison" keyword + if has_garrison { + if garrison_full { + out.push(BuildingActionAvailability::disabled( + BuildingActionKind::GarrisonIn, + BuildingDisabledReason::NoGarrisonSlot, + )); + } else { + out.push(BuildingActionAvailability::enabled(BuildingActionKind::GarrisonIn)); + } + + if garrison_empty { + out.push(BuildingActionAvailability::disabled( + BuildingActionKind::GarrisonOut, + BuildingDisabledReason::NotGarrisoned, + )); + } else { + out.push(BuildingActionAvailability::enabled(BuildingActionKind::GarrisonOut)); + } + } + + // Repair action — buildings with "repairable" keyword + if has_repairable { + if is_at_full_hp { + out.push(BuildingActionAvailability::disabled( + BuildingActionKind::Repair, + BuildingDisabledReason::AlreadyAtFullHp, + )); + } else { + out.push(BuildingActionAvailability::enabled(BuildingActionKind::Repair)); + } + } + + // Toggle active — buildings with "toggleable" keyword + if has_toggleable { + if cap.is_active { + out.push(BuildingActionAvailability::enabled(BuildingActionKind::ToggleActive)); + } else { + out.push(BuildingActionAvailability::enabled(BuildingActionKind::ToggleActive)); + } + } + + // Manage — all buildings always get this + out.push(BuildingActionAvailability::enabled(BuildingActionKind::Manage)); + + out.sort_by_key(|a| a.kind.display_order()); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn production_cap() -> BuildingCapability { + BuildingCapability { + building_type: "production".into(), + keywords: vec!["rally".into(), "garrison".into()], + current_hp: 100, + max_hp: 100, + is_active: true, + garrison_count: 0, + garrison_capacity: 2, + has_rally_target: false, + } + } + + fn defensive_cap() -> BuildingCapability { + BuildingCapability { + building_type: "defensive".into(), + keywords: vec!["garrison".into(), "repairable".into()], + current_hp: 80, + max_hp: 100, + is_active: true, + garrison_count: 1, + garrison_capacity: 2, + has_rally_target: false, + } + } + + #[test] + fn stable_display_order() { + let actions = legal_actions_for_building(&production_cap()); + let orders: Vec = actions.iter().map(|a| a.kind.display_order()).collect(); + let mut sorted = orders.clone(); + sorted.sort(); + assert_eq!(orders, sorted, "legal_actions_for_building must be in stable display order"); + } + + #[test] + fn production_building_has_rally_and_garrison_and_manage() { + let actions = legal_actions_for_building(&production_cap()); + let kinds: Vec = actions.iter().map(|a| a.kind).collect(); + assert!(kinds.contains(&BuildingActionKind::SetRally), "production building has set_rally"); + assert!(kinds.contains(&BuildingActionKind::ClearRally), "production building has clear_rally"); + assert!(kinds.contains(&BuildingActionKind::GarrisonIn), "production building has garrison_in"); + assert!(kinds.contains(&BuildingActionKind::Manage), "all buildings have manage"); + } + + #[test] + fn civilian_building_no_garrison_keywords() { + let cap = BuildingCapability { + building_type: "wonder".into(), + keywords: vec![], + current_hp: 100, + max_hp: 100, + is_active: true, + garrison_count: 0, + garrison_capacity: 0, + has_rally_target: false, + }; + let actions = legal_actions_for_building(&cap); + let kinds: Vec = actions.iter().map(|a| a.kind).collect(); + assert!(!kinds.contains(&BuildingActionKind::GarrisonIn), "wonder has no garrison_in"); + assert!(!kinds.contains(&BuildingActionKind::SetRally), "wonder has no set_rally"); + assert!(kinds.contains(&BuildingActionKind::Manage), "wonder always has manage"); + } + + #[test] + fn garrison_full_disables_garrison_in() { + let mut cap = production_cap(); + cap.garrison_count = 2; + cap.garrison_capacity = 2; + let actions = legal_actions_for_building(&cap); + let garrison_in = actions.iter().find(|a| a.kind == BuildingActionKind::GarrisonIn).unwrap(); + assert!(!garrison_in.enabled, "garrison_in disabled when full"); + assert_eq!(garrison_in.disabled_reason, Some(BuildingDisabledReason::NoGarrisonSlot)); + } + + #[test] + fn no_rally_target_disables_clear_rally() { + let cap = production_cap(); // has_rally_target: false + let actions = legal_actions_for_building(&cap); + let clear = actions.iter().find(|a| a.kind == BuildingActionKind::ClearRally).unwrap(); + assert!(!clear.enabled, "clear_rally disabled when no rally target"); + assert_eq!(clear.disabled_reason, Some(BuildingDisabledReason::NoRallyTarget)); + } + + #[test] + fn repair_disabled_at_full_hp() { + let cap = BuildingCapability { + building_type: "defensive".into(), + keywords: vec!["repairable".into()], + current_hp: 100, + max_hp: 100, + is_active: true, + garrison_count: 0, + garrison_capacity: 0, + has_rally_target: false, + }; + let actions = legal_actions_for_building(&cap); + let repair = actions.iter().find(|a| a.kind == BuildingActionKind::Repair).unwrap(); + assert!(!repair.enabled, "repair disabled at full hp"); + assert_eq!(repair.disabled_reason, Some(BuildingDisabledReason::AlreadyAtFullHp)); + } + + #[test] + fn repair_enabled_when_damaged() { + let cap = defensive_cap(); // current_hp: 80, max_hp: 100 + let actions = legal_actions_for_building(&cap); + let repair = actions.iter().find(|a| a.kind == BuildingActionKind::Repair).unwrap(); + assert!(repair.enabled, "repair enabled when damaged"); + } + + #[test] + fn round_trip_from_str() { + for kind in [ + BuildingActionKind::SetRally, + BuildingActionKind::ClearRally, + BuildingActionKind::GarrisonIn, + BuildingActionKind::GarrisonOut, + BuildingActionKind::Repair, + BuildingActionKind::ToggleActive, + BuildingActionKind::Manage, + ] { + assert_eq!(BuildingActionKind::from_str(kind.as_str()), Some(kind), + "round-trip failed for {:?}", kind); + } + } +} diff --git a/src/simulator/crates/mc-core/src/formation.rs b/src/simulator/crates/mc-core/src/formation.rs index 21c59fa5..3670de0c 100644 --- a/src/simulator/crates/mc-core/src/formation.rs +++ b/src/simulator/crates/mc-core/src/formation.rs @@ -76,8 +76,18 @@ pub struct RallyPointRequest { pub building_id: String, /// None = clear the rally point. pub hex: Option<(i32, i32)>, - /// Standing order for freshly spawned units ("Defend", "Advance", "Patrol"). + /// Standing order for freshly spawned units ("hold", "defend", "fortify", + /// "join_formation", "patrol", "advance"). pub command: String, + /// Second waypoint for Patrol command. -1/-1 = not set (non-Patrol commands). + #[serde(default = "default_minus_one")] + pub waypoint_2_col: i32, + #[serde(default = "default_minus_one")] + pub waypoint_2_row: i32, +} + +fn default_minus_one() -> i32 { + -1 } /// Request to issue a command to a formation. Queued on GameState. diff --git a/src/simulator/crates/mc-core/src/grid/mod.rs b/src/simulator/crates/mc-core/src/grid/mod.rs index 3bc6906e..8f79f740 100644 --- a/src/simulator/crates/mc-core/src/grid/mod.rs +++ b/src/simulator/crates/mc-core/src/grid/mod.rs @@ -96,6 +96,10 @@ impl School { } } +pub type SubstrateId = String; // values from substrate.json::substrates[].id +pub type FloraCoverId = String; // closed_canopy / open_canopy / grass / scrub / bare / wetland_cover / lichen_moss / aquatic_cover +pub type BiomeLabelId = String; // derived display name + /// Per-tile simulation state — field names match `types.ts TileState` exactly. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TileState { @@ -104,7 +108,9 @@ pub struct TileState { pub temperature: f32, pub moisture: f32, pub elevation: f32, - pub biome_id: String, + /// Derived display biome name — renamed from `biome_id`; JSON key stays `"biome_id"` for wire compat. + #[serde(rename = "biome_id")] + pub biome_label_id: String, pub wind_direction: i32, pub wind_speed: f32, pub pressure: f32, @@ -130,8 +136,11 @@ pub struct TileState { pub wonder_anchor_school: School, pub wonder_anchor_schools: Vec, pub wonder_tier: i32, - // Substrate fields + // Substrate / flora-cover / biome-label fields pub substrate_id: String, + /// Derived flora cover class: closed_canopy / open_canopy / grass / scrub / bare / wetland_cover / lichen_moss / aquatic_cover. + #[serde(default)] + pub flora_cover_id: String, pub water_body_id: i32, pub depth_from_coast: i32, // Flora fields @@ -207,6 +216,64 @@ pub struct TileState { pub aerosol_mitigation: f32, // Resource from ecological events pub resource_id: String, + // ── Tectonic prepass fields (p1-50) ───────────────────────────────────── + /// Voronoi plate this tile belongs to. 0 = unassigned. + #[serde(default)] + pub plate_id: u8, + /// Plate kind packed as u8. See mc_mapgen::tectonics::PlateKind for values. + /// 0 = Unassigned, 1 = Continental, 2 = Oceanic, 3 = VolcanicArc, 4 = Rift, 5 = Hotspot. + #[serde(default)] + pub plate_kind: u8, + /// Boundary kind packed as u8. 0 = None, 1 = Convergent, 2 = Divergent, 3 = Transform. + #[serde(default)] + pub boundary_kind: u8, + /// Proximity to nearest convergent boundary mountain arc, 0.0 (far) – 1.0 (at boundary). + #[serde(default)] + pub mountain_proximity: f32, + /// Proximity to coast (from plate geometry), 0.0 (deep interior) – 1.0 (at coast). + #[serde(default)] + pub coast_proximity: f32, + // ── Climate axes fields (p2-49) ────────────────────────────────────────── + /// Signed latitude −1 (south pole) … 0 (equator) … +1 (north pole). + #[serde(default)] + pub latitude: f32, + /// Graph distance to nearest water, normalised 0 (coastal) – 1 (deep interior). + #[serde(default)] + pub continentality: f32, + /// Normalised mean annual temperature 0 (coldest) – 1 (hottest). + #[serde(default)] + pub mean_temp: f32, + /// Normalised mean annual precipitation 0 (driest) – 1 (wettest). + #[serde(default)] + pub mean_precip: f32, + /// Annual temperature amplitude 0 (stable) – 1 (extreme seasonal swing). + #[serde(default)] + pub seasonality: f32, + /// Aridity index (mean_precip / potential_ET). <0.5 = arid, >1.0 = humid. + #[serde(default)] + pub aridity_index: f32, + /// 5-bucket temperature band 0 (polar) – 4 (hot). See climate.json t_band_thresholds. + #[serde(default)] + pub t_band: u8, + /// 5-bucket precipitation band 0 (hyper_arid) – 4 (wet). See climate.json p_band_thresholds. + #[serde(default)] + pub p_band: u8, + // ── Hydrology fields (p1-47) ───────────────────────────────────────────── + /// Outflow direction index 0..=5 (AXIAL_DIRECTIONS). u8::MAX = no outflow (sink/border). + #[serde(default = "default_no_flow")] + pub flow_out: u8, + /// Number of hexes whose flow path passes through this hex (including itself). + #[serde(default)] + pub drainage_area: u32, + /// Strahler stream order. 1 = headwater; increments at same-order confluences. + #[serde(default = "default_stream_order")] + pub stream_order: u8, + /// Lake basin identifier. None = not part of a lake. + #[serde(default)] + pub lake_id: Option, + /// BFS distance to nearest river/lake hex. 0 = on water; u8::MAX = beyond MAX_RIPARIAN_DISTANCE. + #[serde(default = "default_riparian_distance")] + pub riparian_distance: u8, } impl Default for TileState { @@ -217,7 +284,7 @@ impl Default for TileState { temperature: 0.0, moisture: 0.0, elevation: 0.0, - biome_id: String::new(), + biome_label_id: String::new(), wind_direction: 0, wind_speed: 0.5, pressure: 1013.0, @@ -244,6 +311,7 @@ impl Default for TileState { wonder_anchor_schools: Vec::new(), wonder_tier: 0, substrate_id: String::new(), + flora_cover_id: String::new(), water_body_id: -1, depth_from_coast: -1, canopy_cover: 0.0, @@ -289,11 +357,32 @@ impl Default for TileState { fish_stock: 0.0, aerosol_mitigation: 0.0, resource_id: String::new(), + plate_id: 0, + plate_kind: 0, + boundary_kind: 0, + mountain_proximity: 0.0, + coast_proximity: 0.0, + latitude: 0.0, + continentality: 0.5, + mean_temp: 0.5, + mean_precip: 0.5, + seasonality: 0.0, + aridity_index: 1.0, + t_band: 2, + p_band: 2, + flow_out: u8::MAX, + drainage_area: 0, + stream_order: 1, + lake_id: None, + riparian_distance: u8::MAX, } } } fn default_one_f32() -> f32 { 1.0 } +fn default_no_flow() -> u8 { u8::MAX } +fn default_stream_order() -> u8 { 1 } +fn default_riparian_distance() -> u8 { u8::MAX } /// Global grid simulation state — field names match `types.ts GridState`. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -580,7 +669,7 @@ impl GridState { /// the registry's per-biome cap. pub fn stamp_terrain_tier_caps(&mut self) { for tile in &mut self.tiles { - tile.terrain_tier_cap = biome_registry::terrain_tier_cap(&tile.biome_id); + tile.terrain_tier_cap = biome_registry::terrain_tier_cap(&tile.biome_label_id); } } diff --git a/src/simulator/crates/mc-core/src/lib.rs b/src/simulator/crates/mc-core/src/lib.rs index 5389e0f0..f3fb9f8b 100644 --- a/src/simulator/crates/mc-core/src/lib.rs +++ b/src/simulator/crates/mc-core/src/lib.rs @@ -1,5 +1,6 @@ pub mod action; pub mod algorithms; +pub mod building_action; pub mod collectibles; pub mod formation; pub mod gd_compat;