Define the type-definition layer the Phase-4 fill-seam tickets depend on (D-229/D-230/D-231/D-232/D-233), compiling with stubs/defaults; behavior logic lands in #982-985/#998. - New types in generator.rs: BuildingPropertyTag, FloorExtent + FloorHeightProfile (floor_at_voxel_z/voxel_range_for_floor, resolves Q-104), BuildingEntryClass, ConstructionEra, ZoneTypeId, MorphologyZone, BulkClass(5), ProductionUbiquity, DoorSpec, InteriorDescriptor, DistrictWorldState. - Rename spatial AccessTier -> ZoneAccessTier to free the name for the new per-building BuildingEntryClass. - CityGenerationContext: +morphology_zone, +trait_selection, +dominant_bulk_class, +dominant_production_ubiquity. - BodyWorldState: +districts (DistrictWorldState w/ block_tags). - GenCompletion::SkeletonGenerated carries body_id + DistrictWorldState; plugin handler inserts into BodyWorldState.districts. - Add smallvec as a direct dep (DoorSpec list stays Vec for now, TODO). cargo check --all-targets / clippy clean; 1259 lib tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
200 lines
7.3 KiB
Rust
200 lines
7.3 KiB
Rust
//! 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<TileCondition> {
|
||
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);
|
||
}
|
||
}
|