feat(@projects/@magic-civilization): add bloom streak tracking system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-13 12:11:59 -07:00
parent 573fdec713
commit b27cb408ba
7 changed files with 267 additions and 11 deletions

View file

@ -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<SpeciesId>,
/// 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,
}
}
}

View file

@ -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<usize> {
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
{

View file

@ -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};

View file

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

View file

@ -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<mc_core::units::ActionPoints>,
}
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()
}
}

View file

@ -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");
}
}

View file

@ -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<u8>,
}
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();