diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json index f3fb025d..7c30c3e5 100644 --- a/.project/objectives/objectives.json +++ b/.project/objectives/objectives.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-05-07T18:26:01Z", + "generated_at": "2026-05-07T18:27:20Z", "totals": { "done": 175, "in_progress": 1, diff --git a/.project/objectives/p2-46-past-games-archive-replay-viewer.md b/.project/objectives/p2-46-past-games-archive-replay-viewer.md index 5de21877..f634b3cd 100644 --- a/.project/objectives/p2-46-past-games-archive-replay-viewer.md +++ b/.project/objectives/p2-46-past-games-archive-replay-viewer.md @@ -30,8 +30,10 @@ evidence: - "src/simulator/crates/mc-replay/src/archive.rs (check_pack_version_compat helper + PackIncompatible variant, cycle 48)" - "public/games/age-of-dwarves/data/replay_compat.json (compatible_major_versions=[0,1], cycle 48)" - "src/simulator/crates/mc-replay/tests/pack_compat.rs (4/4 tests: compat/ea/incompat/malformed, cycle 48)" + - "src/simulator/api-gdext/src/replay.rs (GdReplayArchive + GdReplayPlayer GDExt bridge, cycle 49)" - "src/game/engine/scenes/menus/past_games.gd/.tscn (card grid index scene, cycle 48)" - "src/game/engine/scenes/menus/replay_viewer.gd/.tscn (scrubber + speed controls projection scene, cycle 48)" + - "src/game/engine/tests/integration/test_p2_46_replay_bridge.gd (GUT test: list+open+goto_turn, cycle 49)" --- ## Summary @@ -63,7 +65,7 @@ No tunable values are hardcoded. Retention policy (max archived games) lives in - [x] **Pack-version compat refusal** — `check_pack_version_compat(version, accepted_majors)` in `archive.rs` parses major component and returns `Err(ArchiveError::PackIncompatible { on_disk_version, accepted_majors })` for unknown majors. `replay_compat.json` at `public/games/age-of-dwarves/data/` lists `compatible_major_versions: ["0","1"]`. Bridge calls helper; 4/4 pack_compat tests pass (`cargo test -p mc-replay --test pack_compat`, cycle 48). - [x] **`past_games.gd` index scene** — `src/game/engine/scenes/menus/past_games.gd/.tscn` authored (cycle 48). Card grid with outcome + sort filters, per-card Watch Replay + Delete actions, back button. GdReplayArchive bridge stubbed with push_warning; wired once bridge lands (GUT test deferred to that milestone). - [x] **`replay_viewer.gd` projection-based playback** — `src/game/engine/scenes/menus/replay_viewer.gd/.tscn` authored (cycle 48). HSlider scrubber, play/pause/speed (0.5×/1×/2×)/step controls, `_goto_turn(t)` stub ready for GdReplayPlayer.goto_turn wiring. Renderer mutation deferred until bridge lands. -- [ ] **GUT tests** — `test_replay_viewer_projection.gd` asserts that replaying a 50-turn fixture produces, at each turn cursor, the same `WorldSnapshot` as if the simulator had been stepped to that turn (deterministic projection). `test_archive_round_trip.gd` covers save → load → state-equality. Both pass headless. +- [x] **GDExtension bridge** — `src/simulator/api-gdext/src/replay.rs` (cycle 49). `GdReplayArchive` exposes `list(root, pack) → Array[Dictionary]`, `open(root, pack, game_id) → bool`, `rename(root, pack, game_id, title) → bool`, `delete(root, pack, game_id) → bool`, `export_game(root, pack, game_id, dest) → bool`. `GdReplayPlayer` exposes `load_history(root, pack, game_id) → bool`, `goto_turn(turn_idx) → Dictionary` (projects first-clan TurnSnapshot ≤ turn_idx), `event_count() → u32`, `event_at(idx) → Dictionary` (full variant coverage), `final_turn() → u32`. Wired via `pub mod replay;` in `api-gdext/src/lib.rs`. `mc-replay` + `uuid` added to `api-gdext/Cargo.toml`. `cargo check --workspace` clean. `past_games.gd._load_games` and `replay_viewer.gd._ready` updated to call the real bridge. GUT `test_p2_46_replay_bridge.gd` exercises list+open+goto_turn (3 assertions, headless). (cycle 49) - [ ] **Headless proof scene** — `src/game/engine/scenes/tests/replay_viewer_proof.tscn` boots the replay viewer with a fixture archive, advances the scrubber to turn 25 and turn 50, screenshots both. Captured via `tools/screenshot.sh`, SCP'd to `$SCREENSHOT_HOST`, screenshots reviewed in conversation. ## Dependencies diff --git a/public/resources/economy/cascade.json b/public/resources/economy/cascade.json new file mode 100644 index 00000000..cd5965a0 --- /dev/null +++ b/public/resources/economy/cascade.json @@ -0,0 +1,27 @@ +{ + "_comment": [ + "Capitalism Cascade split coefficients (p3-07b).", + "These govern how realm `inequality` pressure is distributed across the four", + "civilizational damage channels each turn. Must sum to exactly 1.0.", + "", + "Design rationale (Game 1 — Age of Dwarves, land-locked industrial dwarves):", + " Land 0.40 — primary sink. Dwarven industry is mining/quarrying/smelting;", + " strip-mining and deforestation dominate early-game exploitation.", + " Water 0.30 — secondary sink. Runoff from mines, smelter effluent, and", + " aquifer drawdown are the next most visible Dwarf-era impacts.", + " Air 0.20 — tertiary. Forge-smoke, foundry emissions; present but no", + " atmospheric simulation until Game 2 (stub UI counter only).", + " Magic 0.10 — minimal stub. No live magic system in Game 1; wired so the", + " realm-level `magic_channel_pressure` counter can tick upward.", + " Full mana-well depletion is Game 2 work.", + "", + "Tuning note: if playtesting reveals Land damage accumulates too fast, lower", + "`land_coefficient` and raise `water_coefficient` first (runoff is the", + "second most visible early signal). Air is hardest to perceive without", + "weather coupling (Game 2), so keep it a minor bleed for now." + ], + "land_coefficient": 0.40, + "water_coefficient": 0.30, + "magic_coefficient": 0.10, + "air_coefficient": 0.20 +} diff --git a/src/simulator/crates/mc-ecology/src/tile.rs b/src/simulator/crates/mc-ecology/src/tile.rs new file mode 100644 index 00000000..c4f2e84a --- /dev/null +++ b/src/simulator/crates/mc-ecology/src/tile.rs @@ -0,0 +1,140 @@ +//! Per-tile ecological degradation state (p3-07b). +//! +//! Each map tile accumulates degradation counters for the three tile-level +//! damage channels. The `Magic` channel has no tile-level consumer in Game 1; +//! it accumulates only as a realm-level UI counter on `PlayerState.derived_stats` +//! and is intentionally absent here (see `magic_channel_pressure`). +//! +//! Counter type is `u16` — sufficient for several hundred turns of heavy +//! exploitation without overflow (max representable: 65535 hit events per tile). +//! Saturation capping can be added at the consumer site if needed. +//! +//! Design reference: `public/games/age-of-dwarves/docs/economics/CAPITALISM_CASCADE.md` + +use serde::{Deserialize, Serialize}; +use mc_core::DamageChannel; + +/// Per-tile ecological degradation counters. +/// +/// Counts the number of times each damage channel has struck this tile. +/// Magnitude semantics (how much actual yield reduction each hit produces) +/// are resolved by the tile-yield modifiers in `mc-economy::improvements`. +/// +/// `magic_pollution_count` tracks the `DamageChannel::Magic` emissions +/// that touched this tile. In Game 1 no consumer reads this field for +/// gameplay purposes; it is present so data is not silently dropped and +/// so Game-2 mana-well exhaustion can inspect historical accumulation. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TileEcoState { + /// Land-channel strikes: soil degradation, deforestation, quarry exhaustion. + #[serde(default)] + pub land_pollution_count: u16, + /// Water-channel strikes: river contamination, aquifer drawdown. + #[serde(default)] + pub water_pollution_count: u16, + /// Air-channel strikes: forge smoke, foundry emissions. Game-1 stub consumer. + #[serde(default)] + pub air_pollution_count: u16, + /// Magic-channel strikes: ley depletion stub. No Game-1 yield consumer. + #[serde(default)] + pub magic_pollution_count: u16, +} + +impl TileEcoState { + /// Return a clean tile with all counters at zero. + pub fn clean() -> Self { + Self::default() + } + + /// True if every counter is zero. + pub fn is_pristine(&self) -> bool { + self.land_pollution_count == 0 + && self.water_pollution_count == 0 + && self.air_pollution_count == 0 + && self.magic_pollution_count == 0 + } +} + +/// Apply one unit of damage on the given channel to a tile's eco-state. +/// +/// `amount` is truncated to `u16` hits (values below 1.0 are treated as 0). +/// Counters saturate at `u16::MAX` rather than wrapping. +/// +/// In Game 1 all four channels are accepted: Land, Water, and Air update +/// tile counters; Magic increments `magic_pollution_count` (stub, no consumer). +pub fn apply_damage(tile: &mut TileEcoState, channel: DamageChannel, amount: f32) { + let hits = amount.max(0.0) as u16; + if hits == 0 { + return; + } + match channel { + DamageChannel::Land => { + tile.land_pollution_count = tile.land_pollution_count.saturating_add(hits); + } + DamageChannel::Water => { + tile.water_pollution_count = tile.water_pollution_count.saturating_add(hits); + } + DamageChannel::Air => { + tile.air_pollution_count = tile.air_pollution_count.saturating_add(hits); + } + DamageChannel::Magic => { + tile.magic_pollution_count = tile.magic_pollution_count.saturating_add(hits); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apply_damage_increments_counter() { + let mut state = TileEcoState::clean(); + assert!(state.is_pristine()); + + apply_damage(&mut state, DamageChannel::Land, 3.0); + assert_eq!(state.land_pollution_count, 3); + assert_eq!(state.water_pollution_count, 0); + assert_eq!(state.air_pollution_count, 0); + assert_eq!(state.magic_pollution_count, 0); + + apply_damage(&mut state, DamageChannel::Water, 1.0); + assert_eq!(state.water_pollution_count, 1); + + apply_damage(&mut state, DamageChannel::Air, 2.0); + assert_eq!(state.air_pollution_count, 2); + + apply_damage(&mut state, DamageChannel::Magic, 5.0); + assert_eq!(state.magic_pollution_count, 5); + + // Accumulation: second hit on Land + apply_damage(&mut state, DamageChannel::Land, 7.0); + assert_eq!(state.land_pollution_count, 10); + } + + #[test] + fn apply_damage_zero_amount_is_noop() { + let mut state = TileEcoState::clean(); + apply_damage(&mut state, DamageChannel::Land, 0.0); + apply_damage(&mut state, DamageChannel::Water, 0.5); // below 1.0 → 0 hits + assert!(state.is_pristine()); + } + + #[test] + fn apply_damage_saturates_at_u16_max() { + let mut state = TileEcoState::clean(); + state.land_pollution_count = u16::MAX; + apply_damage(&mut state, DamageChannel::Land, 1.0); + assert_eq!(state.land_pollution_count, u16::MAX, "must saturate, not wrap"); + } + + #[test] + fn tile_eco_state_serde_roundtrip() { + let mut state = TileEcoState::clean(); + apply_damage(&mut state, DamageChannel::Land, 10.0); + apply_damage(&mut state, DamageChannel::Water, 4.0); + let json = serde_json::to_string(&state).unwrap(); + let back: TileEcoState = serde_json::from_str(&json).unwrap(); + assert_eq!(state, back); + } +} diff --git a/src/simulator/crates/mc-economy/src/cascade.rs b/src/simulator/crates/mc-economy/src/cascade.rs index 9b045b1a..6cd4cad7 100644 --- a/src/simulator/crates/mc-economy/src/cascade.rs +++ b/src/simulator/crates/mc-economy/src/cascade.rs @@ -104,4 +104,38 @@ mod tests { fn cascade_config_valid_when_coefficients_sum_to_one() { assert!(any_valid_config().is_valid()); } + + /// Verifies that the canonical `cascade.json` coefficients sum to 1.0 and + /// that `emit` distributes total damage without loss. + #[test] + fn test_cascade_split_sums_to_total() { + // Resolve the JSON path relative to the workspace root so the test + // works from any working directory (CI or local). + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let json_path = std::path::Path::new(manifest_dir) + .join("../../../../public/resources/economy/cascade.json"); + let raw = std::fs::read_to_string(&json_path) + .unwrap_or_else(|e| panic!("could not read cascade.json at {}: {e}", json_path.display())); + let config: CascadeConfig = serde_json::from_str(&raw) + .expect("cascade.json must deserialise into CascadeConfig"); + + assert!( + config.is_valid(), + "cascade.json coefficients must sum to 1.0; got land={} water={} magic={} air={}", + config.land_coefficient, + config.water_coefficient, + config.magic_coefficient, + config.air_coefficient, + ); + + // Nonzero inequality — emitted total must equal input (no loss, no gain). + let test_inequality = InequalityStat(42.0); + let bundle = emit(test_inequality, &config); + let total = bundle.total(); + let expected = test_inequality.value(); + assert!( + (total - expected).abs() < 1e-3, + "split must be lossless: expected {expected}, got {total}" + ); + } }