From b27cb408ba2aeb97ef4bd04850cc3b48ba0464bb Mon Sep 17 00:00:00 2001 From: Natalie Date: Wed, 13 May 2026 12:11:59 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20bloom=20streak=20tracking=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/crates/mc-core/src/grid/mod.rs | 7 + .../crates/mc-ecology/src/biological.rs | 48 +++++- src/simulator/crates/mc-ecology/src/lib.rs | 2 +- .../crates/mc-player-api/tests/common/mod.rs | 2 + .../crates/mc-turn/src/game_state.rs | 26 ++- src/simulator/crates/mc-turn/src/lib.rs | 154 ++++++++++++++++++ src/simulator/crates/mc-units/src/catalog.rs | 39 +++++ 7 files changed, 267 insertions(+), 11 deletions(-) diff --git a/src/simulator/crates/mc-core/src/grid/mod.rs b/src/simulator/crates/mc-core/src/grid/mod.rs index c4cd7401..1cf39ecc 100644 --- a/src/simulator/crates/mc-core/src/grid/mod.rs +++ b/src/simulator/crates/mc-core/src/grid/mod.rs @@ -285,6 +285,12 @@ pub struct TileState { /// Populated by mc-ecology::fauna_select after worldgen; consumed by encounter roll. #[serde(default)] pub fauna_index: Vec, + /// Consecutive turns this tile has satisfied the bloom climate+flora window. + /// Advanced by `mc_ecology::biological::advance_bloom_streak` once per turn, + /// before `derive_biological_events`. Saturates at u8::MAX; resets to 0 on miss. + /// Bloom emission gates on `bloom_streak >= bloom_streak_min` (p3-13c). + #[serde(default)] + pub bloom_streak: u8, } impl Default for TileState { @@ -388,6 +394,7 @@ impl Default for TileState { riparian_distance: u8::MAX, fauna_density: 0.0, fauna_index: Vec::new(), + bloom_streak: 0, } } } diff --git a/src/simulator/crates/mc-ecology/src/biological.rs b/src/simulator/crates/mc-ecology/src/biological.rs index a40e3ba4..49f41791 100644 --- a/src/simulator/crates/mc-ecology/src/biological.rs +++ b/src/simulator/crates/mc-ecology/src/biological.rs @@ -87,6 +87,10 @@ pub struct BiologicalThresholds { pub bloom_canopy_min: f32, pub bloom_undergrowth_min: f32, pub bloom_trigger_chance: f32, + /// Minimum consecutive turns the bloom climate+flora window must hold before + /// a `Bloom` event can fire. Streak is tracked per-tile in + /// `TileState::bloom_streak`, advanced by [`advance_bloom_streak`]. + pub bloom_streak_min: u8, // MigrationPulse — high source fauna density next to depleted neighbour. pub migration_source_min: f32, @@ -112,6 +116,7 @@ impl Default for BiologicalThresholds { bloom_canopy_min: 0.40, bloom_undergrowth_min: 0.30, bloom_trigger_chance: 0.01, + bloom_streak_min: 3, migration_source_min: 0.60, migration_neighbour_max: 0.20, @@ -152,6 +157,9 @@ impl BiologicalThresholds { t.bloom_canopy_min = get_f32(b, "canopy_min", t.bloom_canopy_min); t.bloom_undergrowth_min = get_f32(b, "undergrowth_min", t.bloom_undergrowth_min); t.bloom_trigger_chance = get_f32(b, "trigger_chance", t.bloom_trigger_chance); + if let Some(s) = b.get("streak_min").and_then(|v| v.as_u64()) { + t.bloom_streak_min = s.min(u8::MAX as u64) as u8; + } } if let Some(m) = block.get("migration") { t.migration_source_min = get_f32(m, "source_min", t.migration_source_min); @@ -194,6 +202,36 @@ fn tile_idx(grid: &GridState, col: i32, row: i32) -> Option { Some((row * grid.width + col) as usize) } +/// Whether a tile currently satisfies the bloom climate+flora window. Pure; +/// shared between [`advance_bloom_streak`] and [`derive_biological_events`]. +#[inline] +fn bloom_window_holds(tile: &TileState, thresholds: &BiologicalThresholds) -> bool { + tile.mean_temp >= thresholds.bloom_temp_min + && tile.mean_temp <= thresholds.bloom_temp_max + && tile.mean_precip >= thresholds.bloom_precip_min + && tile.canopy_cover >= thresholds.bloom_canopy_min + && tile.undergrowth >= thresholds.bloom_undergrowth_min +} + +/// Advance the per-tile bloom streak counter once per turn. Call BEFORE +/// [`derive_biological_events`] so the streak observed during derivation +/// reflects this turn's window match. +/// +/// Behaviour per tile: +/// - Window holds → `bloom_streak = bloom_streak.saturating_add(1)`. +/// - Window misses → `bloom_streak = 0`. +/// +/// This is the "N consecutive turns" gate that bloom emission depends on. +pub fn advance_bloom_streak(grid: &mut GridState, thresholds: &BiologicalThresholds) { + for tile in &mut grid.tiles { + if bloom_window_holds(tile, thresholds) { + tile.bloom_streak = tile.bloom_streak.saturating_add(1); + } else { + tile.bloom_streak = 0; + } + } +} + /// Derive per-turn biological events. Pure: no grid mutation. /// /// Channel allocation (must stay disjoint from `mc-climate` weather channels @@ -252,12 +290,10 @@ pub fn derive_biological_events( // ── Bloom ─────────────────────────────────────────────────────────── // Growing-season window: mean_temp inside [min,max], mean_precip ≥ min, - // and flora layers thick enough to bloom. - if tile.mean_temp >= thresholds.bloom_temp_min - && tile.mean_temp <= thresholds.bloom_temp_max - && tile.mean_precip >= thresholds.bloom_precip_min - && tile.canopy_cover >= thresholds.bloom_canopy_min - && tile.undergrowth >= thresholds.bloom_undergrowth_min + // and flora layers thick enough to bloom. Window must have held for at + // least `bloom_streak_min` consecutive turns (see advance_bloom_streak). + if bloom_window_holds(tile, thresholds) + && tile.bloom_streak >= thresholds.bloom_streak_min && det_roll(seed, turn, tile.col, tile.row, 12) < thresholds.bloom_trigger_chance { diff --git a/src/simulator/crates/mc-ecology/src/lib.rs b/src/simulator/crates/mc-ecology/src/lib.rs index e3ae6292..6de6b6e4 100644 --- a/src/simulator/crates/mc-ecology/src/lib.rs +++ b/src/simulator/crates/mc-ecology/src/lib.rs @@ -42,7 +42,7 @@ pub use fauna_select::{TerrainFaunaIndex, FaunaSpec, FaunaManifest, SelectedFaun pub use fauna_glyphs::{FaunaGlyphCluster, lineage_to_glyph_cluster}; pub use config::{DispersalConfig, EcologyConfig, FloraFeedbackConfig}; pub use engine::{EcologyEngine, load_biome_emergence_multipliers_json}; -pub use biological::{derive_biological_events, BiologicalEvent, BiologicalThresholds}; +pub use biological::{advance_bloom_streak, derive_biological_events, BiologicalEvent, BiologicalThresholds}; pub use events::{EventCategory, EventTierData, load_event_categories}; pub use species::load_species_library; pub use evolution::{run_evolution, ClimateStep, EvolutionResult, EventConfig, WorldAgeConfig}; diff --git a/src/simulator/crates/mc-player-api/tests/common/mod.rs b/src/simulator/crates/mc-player-api/tests/common/mod.rs index 99414c01..f92358b2 100644 --- a/src/simulator/crates/mc-player-api/tests/common/mod.rs +++ b/src/simulator/crates/mc-player-api/tests/common/mod.rs @@ -208,11 +208,13 @@ pub fn build_runtime_units_catalog() -> UnitsCatalog { id: "dwarf_warrior".into(), base_moves: 2, domain: "land".into(), + action_point_capacity: None, }); cat.insert(UnitStats { id: "dwarf_founder".into(), base_moves: 2, domain: "land".into(), + action_point_capacity: None, }); cat } diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs index d9c6dea3..71fbeab3 100644 --- a/src/simulator/crates/mc-turn/src/game_state.rs +++ b/src/simulator/crates/mc-turn/src/game_state.rs @@ -1081,6 +1081,19 @@ pub struct MapUnit { /// directly via the constructor / `.with_moves` builder. #[serde(default)] pub movement_remaining: i32, + /// p3-11: action-point pool for Specialist civilians (Pioneer / + /// Engineer progression). `None` for all other unit types — military + /// units, scouts, founders without a configured capacity, etc. + /// + /// Set at spawn from `UnitsCatalog::get(unit_type).action_point_capacity` + /// (only populated for unit JSON that declares `action_point_capacity`). + /// Drained by per-action AP costs resolved through `mc_units::ap::cost_for`. + /// Recharged in full by [`crate::recharge_action_points`] when the unit + /// ends its turn on a friendly city tile. + /// + /// Serde `default = None` keeps old saves loadable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action_points: Option, } impl MapUnit { @@ -1102,16 +1115,21 @@ impl MapUnit { _owner: u8, catalog: &mc_units::UnitsCatalog, ) -> Self { - let base_moves = catalog - .get(unit_type) - .map(|s| s.base_moves) - .unwrap_or(0); + let stats = catalog.get(unit_type); + let base_moves = stats.map(|s| s.base_moves).unwrap_or(0); + // p3-11: spawn Specialist civilians with a full AP pool, sized from + // the per-unit JSON capacity. Unit types whose JSON omits + // `action_point_capacity` get `None` (no AP pool). + let action_points = stats + .and_then(|s| s.action_point_capacity) + .map(mc_core::units::ActionPoints::full); Self { unit_id: unit_type.to_string(), col, row, base_moves, movement_remaining: base_moves, + action_points, ..Self::default() } } diff --git a/src/simulator/crates/mc-turn/src/lib.rs b/src/simulator/crates/mc-turn/src/lib.rs index 20967b57..16245091 100644 --- a/src/simulator/crates/mc-turn/src/lib.rs +++ b/src/simulator/crates/mc-turn/src/lib.rs @@ -104,3 +104,157 @@ pub fn refresh_units(state: &mut game_state::GameState) { } } } + +/// p3-11 — Per-turn action-point recharge for Specialist civilians. +/// +/// For each player, every unit carrying an `action_points` pool that ends +/// the turn on one of that player's own city tiles (`city_positions`) is +/// refilled to capacity. Units off-city retain their remaining AP — the +/// pool only refills when the Specialist returns to a friendly hold, per +/// `public/games/age-of-dwarves/docs/units/SPECIALISTS.md`. +/// +/// "Friendly" is currently equated with same-player ownership: Game 1 +/// "Age of Dwarves" has no alliances. Widening to allied cities later is +/// a one-line change (extend the position set). +/// +/// Captive units (`captive_of.is_some()`) are skipped — mirrors the same +/// guard in [`refresh_units`]; a unit pinned in ransom-pending state +/// cannot reach a city in the first place, and explicit skip keeps the +/// invariant local rather than relying on positional accident. +/// +/// Wired alongside [`refresh_units`] in the end-turn step; ordering +/// against the movement refresh does not matter because the two touch +/// disjoint fields. +pub fn recharge_action_points(state: &mut game_state::GameState) { + for player in &mut state.players { + // Snapshot of friendly city tile coords — cheap (Vec<(i32,i32)>), + // and lets us iterate units mutably without overlapping borrow. + let city_positions: std::collections::HashSet<(i32, i32)> = + player.city_positions.iter().copied().collect(); + for unit in &mut player.units { + if unit.captive_of.is_some() { + continue; + } + let Some(ap) = unit.action_points.as_mut() else { + continue; + }; + if city_positions.contains(&(unit.col, unit.row)) { + ap.recharge_full(); + } + } + } +} + +#[cfg(test)] +mod ap_recharge_tests { + use super::*; + use crate::game_state::{GameState, MapUnit, PlayerState}; + use mc_core::units::ActionPoints; + + fn player_with_city_at(col: i32, row: i32) -> PlayerState { + PlayerState { + city_positions: vec![(col, row)], + ..PlayerState::default() + } + } + + #[test] + fn recharges_unit_on_friendly_city_tile() { + let mut state = GameState::default(); + let mut p = player_with_city_at(4, 7); + p.units.push(MapUnit { + unit_id: "dwarf_engineer".into(), + col: 4, + row: 7, + action_points: Some(ActionPoints { current: 2, capacity: 6 }), + ..MapUnit::default() + }); + state.players.push(p); + + recharge_action_points(&mut state); + + let ap = state.players[0].units[0].action_points.unwrap(); + assert_eq!(ap.current, 6, "on city → full refill"); + assert_eq!(ap.capacity, 6); + } + + #[test] + fn off_city_unit_is_not_recharged() { + let mut state = GameState::default(); + let mut p = player_with_city_at(0, 0); + p.units.push(MapUnit { + unit_id: "dwarf_engineer".into(), + col: 5, + row: 5, + action_points: Some(ActionPoints { current: 1, capacity: 6 }), + ..MapUnit::default() + }); + state.players.push(p); + + recharge_action_points(&mut state); + + let ap = state.players[0].units[0].action_points.unwrap(); + assert_eq!(ap.current, 1, "off-city units do not recharge"); + } + + #[test] + fn non_ap_unit_untouched() { + let mut state = GameState::default(); + let mut p = player_with_city_at(2, 2); + p.units.push(MapUnit { + unit_id: "dwarf_warrior".into(), + col: 2, + row: 2, + action_points: None, + ..MapUnit::default() + }); + state.players.push(p); + + // Must not panic, must leave field None. + recharge_action_points(&mut state); + assert!(state.players[0].units[0].action_points.is_none()); + } + + #[test] + fn captive_unit_is_skipped() { + let mut state = GameState::default(); + let mut p = player_with_city_at(3, 3); + p.units.push(MapUnit { + unit_id: "dwarf_engineer".into(), + col: 3, + row: 3, + action_points: Some(ActionPoints { current: 0, capacity: 6 }), + captive_of: Some(1), + ..MapUnit::default() + }); + state.players.push(p); + + recharge_action_points(&mut state); + + let ap = state.players[0].units[0].action_points.unwrap(); + assert_eq!(ap.current, 0, "captive units do not recharge"); + } + + #[test] + fn enemy_city_does_not_recharge_us() { + // Player 0 has unit at (5,5). Player 1 has a city at (5,5). + // Player 0's unit must NOT recharge — it's standing in an enemy city. + let mut state = GameState::default(); + let mut p0 = PlayerState::default(); + p0.units.push(MapUnit { + unit_id: "dwarf_engineer".into(), + col: 5, + row: 5, + action_points: Some(ActionPoints { current: 2, capacity: 6 }), + ..MapUnit::default() + }); + let p1 = player_with_city_at(5, 5); + state.players.push(p0); + state.players.push(p1); + + recharge_action_points(&mut state); + + let ap = state.players[0].units[0].action_points.unwrap(); + assert_eq!(ap.current, 2, "standing in enemy city must not refill our pool"); + } +} diff --git a/src/simulator/crates/mc-units/src/catalog.rs b/src/simulator/crates/mc-units/src/catalog.rs index 39b817a8..c988db0c 100644 --- a/src/simulator/crates/mc-units/src/catalog.rs +++ b/src/simulator/crates/mc-units/src/catalog.rs @@ -27,6 +27,12 @@ pub struct UnitStats { /// passability gates in `mc-pathfinding`. #[serde(default = "default_domain")] pub domain: String, + /// Action-point capacity for Specialist units (Pioneer / Engineer + /// progression). `None` for unit types that don't carry an AP pool — + /// military units, scouts, etc. Sourced from JSON key + /// `"action_point_capacity"`. See objective p3-11. + #[serde(default)] + pub action_point_capacity: Option, } fn default_domain() -> String { @@ -128,12 +134,45 @@ mod tests { id: "dwarf_warrior".into(), base_moves: 2, domain: "land".into(), + action_point_capacity: None, }); assert_eq!(cat.len(), 1); assert_eq!(cat.get("dwarf_warrior").unwrap().base_moves, 2); assert!(cat.get("missing").is_none()); } + #[test] + fn parses_action_point_capacity() { + // dwarf_engineer.json shape — pioneer/engineer JSON carries + // action_point_capacity. Other unit types omit the field and + // deserialise with `None`. + let raw = r#" + [ + { + "id": "dwarf_engineer", + "movement": 2, + "domain": "land", + "action_point_capacity": 6 + }, + { + "id": "dwarf_warrior", + "movement": 2, + "domain": "land" + } + ]"#; + let mut cat = UnitsCatalog::new(); + let n = cat.load_json_str(raw).expect("parse"); + assert_eq!(n, 2); + assert_eq!( + cat.get("dwarf_engineer").unwrap().action_point_capacity, + Some(6) + ); + assert_eq!( + cat.get("dwarf_warrior").unwrap().action_point_capacity, + None + ); + } + #[test] fn unknown_top_level_returns_zero() { let mut cat = UnitsCatalog::new();