feat(simulator): Add new grid state types and core simulation logic for advanced grid configurations

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-01 18:42:20 -07:00
parent ea9c7d6bcb
commit 0680709799
4 changed files with 517 additions and 5 deletions

View file

@ -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<Self> {
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<BuildingDisabledReason>,
}
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<String>,
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<BuildingActionAvailability> {
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<BuildingActionAvailability> = 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<u8> = 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<BuildingActionKind> = 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<BuildingActionKind> = 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);
}
}
}

View file

@ -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.

View file

@ -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<LeySchool>,
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<u32>,
/// 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);
}
}

View file

@ -1,5 +1,6 @@
pub mod action;
pub mod algorithms;
pub mod building_action;
pub mod collectibles;
pub mod formation;
pub mod gd_compat;