Files
settled-reach/server/src/atlas/tile_condition.rs
T
jpmschweitzerandClaude Opus 4.8 46b8688fa3 feat(simulation): foundation shared types + struct plumbing (#1006)
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>
2026-05-30 16:07:33 +02:00

200 lines
7.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Tile condition thresholds and derivation (D-217).
//!
//! A tile's visual condition is derived from the district's `prosperity_score`
//! (0.01.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.430.63. Scuff marks, minor discoloration, partial repairs.
Worn,
/// prosperity_score 0.230.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);
}
}