diff --git a/.project/objectives/DASHBOARD_CATEGORIES.md b/.project/objectives/DASHBOARD_CATEGORIES.md
index 454b5b77..0cc37608 100644
--- a/.project/objectives/DASHBOARD_CATEGORIES.md
+++ b/.project/objectives/DASHBOARD_CATEGORIES.md
@@ -340,7 +340,7 @@
| [p3-05e](p3-05e-civic-modifier-propagation.md) | ๐ด stub | P3 | Civic modifier propagation โ apply civic effects to per-city yields | [unassigned](../team-leads/unassigned.md) | ๐ p3-05b, p3-05c, p3-05d |
| [p3-06](p3-06-civic-anarchy-and-axis-switching.md) | ๐ก partial | P3 | Civic anarchy โ 5-turn anarchy on axis switch | [unassigned](../team-leads/unassigned.md) | ๐ข |
| [p3-07a](p3-07a-cv-wealth-and-authority-amplifier.md) | โ
done | P3 | CV-of-wealth + Authority amplifier โ inequality stat | [unassigned](../team-leads/unassigned.md) | ๐ข |
-| [p3-07b](p3-07b-four-damage-channels.md) | ๐ก partial | P3 | Four damage channels โ Land/Water/Magic/Air emission from inequality | [unassigned](../team-leads/unassigned.md) | ๐ข |
+| [p3-07b](p3-07b-four-damage-channels.md) | โ
done | P3 | Four damage channels โ Land/Water/Magic/Air emission from inequality | [unassigned](../team-leads/unassigned.md) | ๐ข |
| [p3-10a](p3-10a-lair-assault-mode.md) | ๐ก partial | P3 | Lair assault mode โ enter-and-clear | [unassigned](../team-leads/unassigned.md) | ๐ข |
| [p3-10b](p3-10b-lair-siege-mode.md) | ๐ก partial | P3 | Lair siege mode โ multi-turn pressure from adjacent | [unassigned](../team-leads/unassigned.md) | ๐ p3-10a |
| [p3-10c](p3-10c-lair-raid-mode.md) | โ
done | P3 | Lair raid mode โ grab-and-exit | [combat-dev](../team-leads/combat-dev.md) | ๐ข |
diff --git a/.project/objectives/DASHBOARD_COMPLETED.md b/.project/objectives/DASHBOARD_COMPLETED.md
index d218417d..e8b4e221 100644
--- a/.project/objectives/DASHBOARD_COMPLETED.md
+++ b/.project/objectives/DASHBOARD_COMPLETED.md
@@ -192,6 +192,7 @@
| [p3-03](p3-03-courier-route-resolver.md) | Courier route resolver โ real hex pathfinding, per-tier movement, severable infrastructure | โ | [envoy](../team-leads/envoy.md) | 2026-04-28 |
| [p3-04](p3-04-per-hex-improvement-layer.md) | Per-hex improvement layer in `mc-core` / `mc-turn` โ anchor improvements at (col,row) | โ | [envoy](../team-leads/envoy.md) | 2026-04-28 |
| [p3-07a](p3-07a-cv-wealth-and-authority-amplifier.md) | CV-of-wealth + Authority amplifier โ inequality stat | โ | [unassigned](../team-leads/unassigned.md) | 2026-05-07 |
+| [p3-07b](p3-07b-four-damage-channels.md) | Four damage channels โ Land/Water/Magic/Air emission from inequality | โ | [unassigned](../team-leads/unassigned.md) | 2026-05-07 |
| [p3-10c](p3-10c-lair-raid-mode.md) | Lair raid mode โ grab-and-exit | โ | [combat-dev](../team-leads/combat-dev.md) | 2026-05-07 |
| [p3-12](p3-12-fauna-stat-derivation-from-traits.md) | Fauna combat stat derivation โ regenerate from traits | โ | [terraformer](../team-leads/terraformer.md) | 2026-05-04 |
| [p3-13a](p3-13a-extend-meteorological-events.md) | Extend meteorological events โ drought, flood, dust_storm | โ | [unassigned](../team-leads/unassigned.md) | 2026-05-04 |
diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index 76c89c28..967a9adf 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -17,8 +17,8 @@
| **P0** | 0 | 0 | 0 | 0 | 0 | 44 | 44 |
| **P1** | 1 | 13 | 1 | 5 | 1 | 55 | 76 |
| **P2** | 0 | 9 | 11 | 0 | 6 | 68 | 94 |
-| **P3 (oos)** | 0 | 8 | 6 | 0 | 21 | 8 | 43 |
-| **total** | **1** | **30** | **18** | **5** | **28** | **175** | **257** |
+| **P3 (oos)** | 0 | 7 | 6 | 0 | 21 | 9 | 43 |
+| **total** | **1** | **29** | **18** | **5** | **28** | **176** | **257** |
@@ -26,7 +26,7 @@
| Team Lead | Remaining |
|---|---|
-| [unassigned](../team-leads/unassigned.md) | 20 |
+| [unassigned](../team-leads/unassigned.md) | 19 |
| [asset-sprite](../team-leads/asset-sprite.md) | 6 |
| [combat-dev](../team-leads/combat-dev.md) | 6 |
| [shipwright](../team-leads/shipwright.md) | 5 |
diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json
index 7c30c3e5..3833f6cd 100644
--- a/.project/objectives/objectives.json
+++ b/.project/objectives/objectives.json
@@ -1,9 +1,9 @@
{
- "generated_at": "2026-05-07T18:27:20Z",
+ "generated_at": "2026-05-07T18:35:10Z",
"totals": {
- "done": 175,
+ "done": 176,
"in_progress": 1,
- "partial": 30,
+ "partial": 29,
"stub": 18,
"missing": 5,
"oos": 28,
@@ -2718,7 +2718,7 @@
"id": "p3-07b",
"title": "Four damage channels โ Land/Water/Magic/Air emission from inequality",
"priority": "p3",
- "status": "partial",
+ "status": "done",
"scope": "game1",
"owner": "unassigned",
"updated_at": "2026-05-07",
@@ -2986,7 +2986,7 @@
"remaining_by_lead": [
{
"owner": "unassigned",
- "remaining": 20
+ "remaining": 19
},
{
"owner": "asset-sprite",
diff --git a/.project/objectives/p3-07b-four-damage-channels.md b/.project/objectives/p3-07b-four-damage-channels.md
index 79a4beac..9496bdd4 100644
--- a/.project/objectives/p3-07b-four-damage-channels.md
+++ b/.project/objectives/p3-07b-four-damage-channels.md
@@ -2,12 +2,13 @@
id: p3-07b
title: "Four damage channels โ Land/Water/Magic/Air emission from inequality"
priority: p3
-status: partial
+status: done
scope: game1
category: economy
owner: unassigned
created: 2026-05-03
updated_at: 2026-05-07
+closed_at: 2026-05-07
blocked_by: [p3-07a]
follow_ups: []
---
@@ -22,9 +23,9 @@ In Game 1 these channels accumulate as tile-level degradation counters; full man
- โ `mc-core::DamageChannel` enum (`Land`, `Water`, `Magic`, `Air`). Implemented in `mc-core::damage_channel` with `ChannelDamageBundle` typed map, `Index`/`IndexMut` by channel, and `DamageChannel::ALL` constant.
- โ `mc-economy::cascade::emit(inequality, config) -> ChannelDamageBundle` with `CascadeConfig` carrying JSON-driven split coefficients. Zero-inequality โ zero-emission invariant holds. Split formula pending `cascade.json` authorship (blocked on p3-05b/c/d civic schema).
-- โ `mc-ecology::tile::apply_damage(tile, channel, amount)` updates per-tile degradation counters. Blocked: tile-degradation counters not yet on tile state; requires separate ecology ticket.
+- โ `mc-ecology::tile::apply_damage(tile, channel, amount)` updates per-tile degradation counters. `TileEcoState` carries `land_pollution_count`, `water_pollution_count`, `air_pollution_count`, `magic_pollution_count: u16` with serde + default. `apply_damage` saturates at `u16::MAX`. Cited: `src/simulator/crates/mc-ecology/src/tile.rs`.
- โ Realm-level Magic counter `PlayerState.derived_stats.magic_channel_pressure: f32`. `DerivedStats` struct added (p3-07a plumbing); field present and zeroed in Game 1. `TurnProcessor::recompute_derived_stats` writes `magic_channel_pressure = 0.0` each turn; full `cascade::emit` wiring follows in Game 2 (p3-07b out of scope for Game 1). Cited: `src/simulator/crates/mc-core/src/derived_stats.rs`, `src/simulator/crates/mc-turn/src/processor.rs`.
-- โ `test_zero_inequality_zero_emission` green. `test_cascade_split_sums_to_total` blocked โ requires `cascade.json` with JSON-specified coefficients; placeholder even-split was rejected (no stubs rule); will close when cascade.json is authored.
+- โ `test_zero_inequality_zero_emission` green. `test_cascade_split_sums_to_total` green โ `cascade.json` authored at `public/resources/economy/cascade.json` (Land 0.40 / Water 0.30 / Magic 0.10 / Air 0.20); test loads real JSON, verifies sum=1.0 and lossless split.
## Source-of-truth rails
diff --git a/src/game/engine/scenes/tests/capture_screenshot.gd b/src/game/engine/scenes/tests/capture_screenshot.gd
index c4fa08f9..6da78ac1 100644
--- a/src/game/engine/scenes/tests/capture_screenshot.gd
+++ b/src/game/engine/scenes/tests/capture_screenshot.gd
@@ -132,6 +132,12 @@ func _ready() -> void:
"res://engine/scenes/tests/fauna_render/fauna_render_proof.tscn"
)
return
+ elif _scene == "replay_viewer_proof":
+ await get_tree().create_timer(0.5).timeout
+ get_tree().change_scene_to_file(
+ "res://engine/scenes/tests/proof_replay_viewer.tscn"
+ )
+ return
elif _scene == "world_map":
await get_tree().create_timer(0.5).timeout
GameState.initialize_game({
diff --git a/src/simulator/api-gdext/src/replay.rs b/src/simulator/api-gdext/src/replay.rs
index 6d3cde34..f1120ab3 100644
--- a/src/simulator/api-gdext/src/replay.rs
+++ b/src/simulator/api-gdext/src/replay.rs
@@ -31,9 +31,11 @@
//! ```
use godot::prelude::*;
-use mc_replay::archive::{game_dir, read_game, read_meta};
-use mc_replay::history::GameHistory;
-use mc_replay::ids::{GameId, PackId};
+use mc_replay::archive::{game_dir, read_game, read_meta, write_game, GameOutcome, MapDescriptor};
+use mc_replay::history::{ClanDescriptor, GameHistory, TurnEventCollector};
+use mc_replay::ids::{
+ CityName, ClanId, GameId, LeaderId, PackId, PackVersion, TechId, TileCoord,
+};
use mc_replay::{TurnEvent, TurnSnapshot};
// โโ helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -341,6 +343,104 @@ impl GdReplayArchive {
}
}
+ /// Write a synthetic 50-turn fixture into the archive and return the game UUID.
+ ///
+ /// Constructs a deterministic `GameHistory` with:
+ /// - One clan (Stonebeard, clan_id 1)
+ /// - One `TurnSnapshot` per turn (turns 1โ50)
+ /// - `CityFounded` events at turns 1 and 10
+ /// - `TechResearched` events at turns 5, 20, and 40
+ ///
+ /// Returns the UUID string of the written game, or an empty `GString` on
+ /// failure (error logged via `godot_error!`).
+ #[func]
+ pub fn write_fixture(&self, root: GString, pack: GString, title: GString) -> GString {
+ let root_path = std::path::PathBuf::from(root.to_string());
+ let pack_id = PackId(pack.to_string());
+
+ let game_id = GameId::new_v4();
+ let clan = ClanId(1);
+
+ let mut hist = GameHistory::new(
+ game_id,
+ pack_id,
+ PackVersion("0.1.0".into()),
+ 12345,
+ MapDescriptor {
+ kind: "continents".into(),
+ width: 32,
+ height: 24,
+ },
+ vec![ClanDescriptor {
+ id: clan,
+ name: "Stonebeard".into(),
+ sigil_key: "stonebeard.png".into(),
+ colour_rgba: 0xCC_88_22_FF,
+ starting_leader: LeaderId("durin".into()),
+ }],
+ );
+
+ // One snapshot per turn for 50 turns.
+ for t in 1u32..=50 {
+ hist.snapshots.push(TurnSnapshot {
+ turn: t,
+ clan_id: clan,
+ population: 1000 + t * 20,
+ cities: 1 + (t / 10),
+ army_strength: 10.0 + t as f32 * 0.5,
+ gold: 50 + t as i64 * 3,
+ gold_per_turn: 8 + (t / 5) as i64,
+ culture_per_turn: 2.0 + t as f32 * 0.1,
+ tech_count: (t / 10),
+ land_area: 12 + t * 2,
+ score: 100.0 + t as f32 * 4.5,
+ });
+ }
+
+ // A few events sprinkled across turns.
+ let mut collector = TurnEventCollector::new();
+ collector.push(TurnEvent::CityFounded {
+ turn: 1,
+ clan,
+ hex: TileCoord::new(4, 3),
+ name: CityName("Karak Dรปm".into()),
+ });
+ collector.push(TurnEvent::TechResearched {
+ turn: 5,
+ clan,
+ tech: TechId("mining".into()),
+ });
+ collector.push(TurnEvent::CityFounded {
+ turn: 10,
+ clan,
+ hex: TileCoord::new(9, 7),
+ name: CityName("Ironhall".into()),
+ });
+ collector.push(TurnEvent::TechResearched {
+ turn: 20,
+ clan,
+ tech: TechId("smelting".into()),
+ });
+ collector.push(TurnEvent::TechResearched {
+ turn: 40,
+ clan,
+ tech: TechId("siege_craft".into()),
+ });
+ collector.flush_to_history(&mut hist);
+
+ hist.final_turn = 50;
+ hist.outcome = GameOutcome::TurnLimit { turn_limit: 50 };
+
+ let written_at = "2026-05-07T00:00:00Z".to_string();
+ match write_game(&root_path, &hist, title.to_string(), written_at) {
+ Ok(_) => GString::from(game_id.as_uuid().to_string()),
+ Err(e) => {
+ godot_error!("GdReplayArchive.write_fixture: {e}");
+ GString::new()
+ }
+ }
+ }
+
/// Copy the per-game directory to `dest_path` (a new directory path).
///
/// Creates `dest_path` if absent. Copies `meta.json` and `history.bin`;
diff --git a/src/simulator/crates/mc-ecology/src/lib.rs b/src/simulator/crates/mc-ecology/src/lib.rs
index 6fe4eff9..e3ae6292 100644
--- a/src/simulator/crates/mc-ecology/src/lib.rs
+++ b/src/simulator/crates/mc-ecology/src/lib.rs
@@ -33,6 +33,7 @@ pub mod population;
pub mod species;
pub mod tier;
pub mod traits;
+pub mod tile;
pub mod wilds;
pub use classification::{ClassificationConfig, ClassificationBreakdown};
@@ -53,6 +54,7 @@ pub use grudge::{is_grudge_eligible, GrudgeEntry, GrudgeKey, GrudgeLedger, Grudg
pub use food_drain::{apex_food_drain_factor, DRAIN_PER_FOOD_UNIT_POP, MIN_DRAIN_FACTOR};
pub use generation::TerrainAffinityIndex;
pub use fauna_product::{FaunaProduct, fauna_product_supply};
+pub use tile::{TileEcoState, apply_damage};
pub use wilds::{generate_lairs, check_lair_formation, check_lair_abandonment,
check_lair_state_transitions, lair_inheritable, LairConfig,
locomotion_str_from_u8, size_str_from_u8, LairType, LairState,
diff --git a/tools/huge-map-5clan.sh b/tools/huge-map-5clan.sh
index e58be5c4..e7e7685a 100755
--- a/tools/huge-map-5clan.sh
+++ b/tools/huge-map-5clan.sh
@@ -42,9 +42,19 @@ DIM='\033[2m'; NC='\033[0m'
# MAP_SIZE=huge once POD's MAX_PLAYERS=4 limit is
# lifted and the game supports >8 AI slots.
# p1-22: bound MCTS per-decision wall-clock cost. 2000 ms caps each AI
-# decision so slow seeds finish in ~5s/turn ร 5 players ร 500 turns โ 3.5 hr
-# per game โ well within the 3600s safety timeout.
+# decision. Empirically (cycle 57, 2026-05-07): 5-player MCTS on a standard
+# map runs ~34s/turn wall-clock, so T=300 needs ~10,200s + 25% buffer โ 12,750s.
+# autoplay-batch.sh's default formula (TURN_LIMIT * 3 + 300 = 1200s for T=300)
+# is calibrated for 2-player smoke โ it is far too short here and killed all
+# 10 cycle-57 games at T32-41 (exit code 124). We set SAFETY_TIMEOUT_OVERRIDE
+# to TURN_LIMIT * 45 + 600 (14,100s for T=300, ~3.9h) so the per-game `timeout`
+# guard in autoplay-batch.sh is appropriate for 5-clan huge-map runs.
+# This value can be overridden via env if needed.
: "${MCTS_DECISION_BUDGET_MS:=2000}"
+# Per-game safety timeout for autoplay-batch.sh (seconds).
+# Formula: TURN_LIMIT * 45 + 600 (empirically derived โ see comment above).
+: "${SAFETY_TIMEOUT_OVERRIDE:=$(( TURN_LIMIT * 45 + 600 ))}"
+export SAFETY_TIMEOUT_OVERRIDE
for arg in "$@"; do
case "$arg" in
|