feat(@projects/@magic-civilization): ✨ add bloom streak tracking system
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
573fdec713
commit
b27cb408ba
7 changed files with 267 additions and 11 deletions
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue