//! Tile condition thresholds and derivation (D-217). //! //! A tile's visual condition is derived from the district's `prosperity_score` //! (0.0–1.0) using four threshold bands. The block's `EraCause` applies a //! minimum condition floor that prevents high-prosperity scores from masking //! historical decay. //! //! **Threshold bands (D-217):** //! | Band | Condition | prosperity_score | //! |------|-----------|-----------------| //! | 1 | Intact | > 0.63 | //! | 2 | Worn | 0.43 – 0.63 | //! | 3 | Cracked | 0.23 – 0.43 | //! | 4 | Broken | < 0.23 | //! //! **Era-based floor (D-217):** //! - `EconomicDisruption` (Decay-era): minimum Cracked. //! - `EmergencyExtension`: minimum Worn. //! - All other eras: no floor — condition follows prosperity_score freely. //! //! **Threshold crossing invalidation:** A tile's condition only changes when //! `prosperity_score` crosses a band boundary. Checked once per game-minute. //! //! Threshold values are authored constants (D-217): 0.63, 0.43, 0.23. use serde::{Deserialize, Serialize}; use crate::simulation::generator::EraCause; // --------------------------------------------------------------------------- // TileCondition // --------------------------------------------------------------------------- /// Visual condition band for a tile, derived from prosperity_score (D-217). #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum TileCondition { /// prosperity_score > 0.63. Clean, undamaged, well-maintained. Intact, /// prosperity_score 0.43–0.63. Scuff marks, minor discoloration, partial repairs. Worn, /// prosperity_score 0.23–0.43. Visible damage, incomplete repair, graffiti. Cracked, /// prosperity_score < 0.23. Structural damage, debris, derelict appearance. Broken, } // --------------------------------------------------------------------------- // Threshold constants (D-217 authored — do not compute at runtime) // --------------------------------------------------------------------------- pub const THRESHOLD_INTACT: f32 = 0.63; pub const THRESHOLD_WORN: f32 = 0.43; pub const THRESHOLD_CRACKED: f32 = 0.23; // --------------------------------------------------------------------------- // Derivation // --------------------------------------------------------------------------- /// Derive `TileCondition` from `prosperity_score` alone (no era floor). pub fn condition_from_score(prosperity_score: f32) -> TileCondition { if prosperity_score > THRESHOLD_INTACT { TileCondition::Intact } else if prosperity_score > THRESHOLD_WORN { TileCondition::Worn } else if prosperity_score > THRESHOLD_CRACKED { TileCondition::Cracked } else { TileCondition::Broken } } /// Era-based minimum condition floor (D-217). /// /// Returns the minimum `TileCondition` for a block with the given `EraCause`. /// `None` means no floor — condition follows prosperity_score freely. pub fn era_condition_floor(era_cause: Option<&EraCause>) -> Option { match era_cause { Some(EraCause::EconomicDisruption) => Some(TileCondition::Cracked), Some(EraCause::EmergencyExtension) => Some(TileCondition::Worn), _ => None, } } /// Derive `TileCondition` with era-based floor applied. /// /// If the era floor is stricter (lower condition) than the score-derived /// condition, the floor wins. pub fn tile_condition(prosperity_score: f32, era_cause: Option<&EraCause>) -> TileCondition { let from_score = condition_from_score(prosperity_score); match era_condition_floor(era_cause) { Some(floor) => { // Lower enum discriminant = better condition (Intact < Worn < Cracked < Broken). // Floor is a *minimum degradation* — we want the worse of the two. if floor > from_score { floor } else { from_score } } None => from_score, } } /// Check whether a threshold crossing occurred between two prosperity scores. /// /// Returns `true` if the tile's condition band changed between `old_score` and /// `new_score`. Used by the game-minute update loop to decide whether to /// apply a `ChunkMutation.tile_override`. pub fn threshold_crossed(old_score: f32, new_score: f32) -> bool { condition_from_score(old_score) != condition_from_score(new_score) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn intact_above_0_63() { assert_eq!(condition_from_score(0.64), TileCondition::Intact); assert_eq!(condition_from_score(1.0), TileCondition::Intact); } #[test] fn worn_between_0_43_and_0_63() { assert_eq!(condition_from_score(0.63), TileCondition::Worn); assert_eq!(condition_from_score(0.50), TileCondition::Worn); assert_eq!(condition_from_score(0.44), TileCondition::Worn); } #[test] fn cracked_between_0_23_and_0_43() { assert_eq!(condition_from_score(0.43), TileCondition::Cracked); assert_eq!(condition_from_score(0.30), TileCondition::Cracked); assert_eq!(condition_from_score(0.24), TileCondition::Cracked); } #[test] fn broken_below_0_23() { assert_eq!(condition_from_score(0.23), TileCondition::Broken); assert_eq!(condition_from_score(0.10), TileCondition::Broken); assert_eq!(condition_from_score(0.0), TileCondition::Broken); } #[test] fn era_floor_decay_enforces_cracked_minimum() { // Prosperous district in an EconomicDisruption-era block — still Cracked. let cond = tile_condition(0.90, Some(&EraCause::EconomicDisruption)); assert_eq!( cond, TileCondition::Cracked, "EconomicDisruption floor must prevent Intact/Worn" ); } #[test] fn era_floor_emergency_extension_enforces_worn_minimum() { // High prosperity EmergencyExtension block should never be Intact. let cond = tile_condition(0.80, Some(&EraCause::EmergencyExtension)); assert_eq!(cond, TileCondition::Worn); } #[test] fn era_floor_does_not_improve_condition() { // EconomicDisruption floor = Cracked; Broken score stays Broken. let cond = tile_condition(0.10, Some(&EraCause::EconomicDisruption)); assert_eq!( cond, TileCondition::Broken, "Era floor must not improve condition below score-derived value" ); } #[test] fn no_era_cause_follows_score() { let cond = tile_condition(0.90, None); assert_eq!(cond, TileCondition::Intact); } #[test] fn threshold_crossed_detects_band_change() { // 0.7 → 0.5 crosses the 0.63 boundary. assert!(threshold_crossed(0.70, 0.50)); // 0.55 → 0.48 stays in Worn band. assert!(!threshold_crossed(0.55, 0.48)); // 0.40 → 0.20 crosses 0.23 boundary. assert!(threshold_crossed(0.40, 0.20)); } #[test] fn condition_ordering_intact_is_best() { assert!(TileCondition::Intact < TileCondition::Worn); assert!(TileCondition::Worn < TileCondition::Cracked); assert!(TileCondition::Cracked < TileCondition::Broken); } }