feat(@projects/@magic-civilization): add replay bridge and player integration

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-07 11:32:15 -07:00
parent 4dc11e5913
commit 800071cdb3
5 changed files with 205 additions and 2 deletions

View file

@ -1,5 +1,5 @@
{
"generated_at": "2026-05-07T18:26:01Z",
"generated_at": "2026-05-07T18:27:20Z",
"totals": {
"done": 175,
"in_progress": 1,

View file

@ -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

View file

@ -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
}

View file

@ -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);
}
}

View file

@ -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}"
);
}
}