diff --git a/src/game/engine/scenes/headless/player_api_main.gd b/src/game/engine/scenes/headless/player_api_main.gd index bf94cd2f..0e2838ce 100644 --- a/src/game/engine/scenes/headless/player_api_main.gd +++ b/src/game/engine/scenes/headless/player_api_main.gd @@ -245,6 +245,11 @@ func _hydrate_player_api(num_players: int) -> void: # build or yield in headless play. _apply_improvement_defs() + # p3-26 B6a: stamp the recipe bundle so the headless recipe phase refines resources + # (raw → processed) each turn. Same `#[serde(skip)]` re-stamp pattern — load AFTER + # `load_state_json`. Without this no resource refinement runs. + _apply_recipes() + ## p3-26 B3: stamp improvement definitions (DataLoader's improvements: id, build_turns, ## yields:{food,production}) onto `GdPlayerApi` via `set_improvement_defs_json`. Consumed by @@ -258,6 +263,19 @@ func _apply_improvement_defs() -> void: _emit_event("improvement_defs_api_loaded", {"count": n}) +## p3-26 B6a: stamp the recipe bundle (`public/resources/recipes/recipes.json`, the +## `{recipes:[…]}` shape RecipeRegistry::from_json parses) onto `GdPlayerApi` via +## `set_recipes_json`. Consumed by the headless recipe-refinement phase. +func _apply_recipes() -> void: + const RECIPES_PATH: String = "res://public/resources/recipes/recipes.json" + var raw: String = FileAccess.get_file_as_string(RECIPES_PATH) + if raw == "": + _emit_protocol_error("recipes.json empty/missing — headless resource refinement will not run") + return + var n: int = int(_api.set_recipes_json(raw)) + _emit_event("recipes_api_loaded", {"bytes": n}) + + ## p3-26 gap 2: stamp the natural-event category configs (DataLoader's merged ## `{category: {base_frequency, severity_weights, tiers, …}}`) onto `GdPlayerApi` via ## `set_events_config_json`. Consumed by `mc-turn`'s climate-phase event dispatch. diff --git a/src/game/engine/scenes/menus/replay_viewer.gd b/src/game/engine/scenes/menus/replay_viewer.gd index 90ef96dc..6f429e50 100644 --- a/src/game/engine/scenes/menus/replay_viewer.gd +++ b/src/game/engine/scenes/menus/replay_viewer.gd @@ -36,6 +36,18 @@ var _events_cursor: int = 0 @onready var _speed_label: Label = %SpeedLabel @onready var _back_button: Button = %BackButton +## Max chronicle feed lines shown at once (most-recent window). +const CHRONICLE_MAX_LINES: int = 12 + +## Clan id -> display name / colour, projected once from the Rust standings at +## the final turn (covers every clan that ever recorded a snapshot). Used to +## label the event chronicle without re-deriving anything in GDScript. +var _clan_names: Dictionary = {} +var _clan_colors: Dictionary = {} +## Chronicle panels built into the WorldMapPlaceholder stage at _ready. +var _standings_box: VBoxContainer = null +var _events_box: VBoxContainer = null + func _ready() -> void: _back_button.text = ThemeVocabulary.lookup("replay_back") diff --git a/src/simulator/api-gdext/src/player_api.rs b/src/simulator/api-gdext/src/player_api.rs index 3247f7c6..b2e394fa 100644 --- a/src/simulator/api-gdext/src/player_api.rs +++ b/src/simulator/api-gdext/src/player_api.rs @@ -191,6 +191,15 @@ impl GdPlayerApi { .load_improvement_defs_json(json.to_string().as_str()) as i64 } + /// p3-26 B6a: load the recipe bundle JSON (`{"recipes":[…]}` from + /// `public/resources/recipes/recipes.json`) so the headless recipe phase can + /// refine resources each turn. Returns the byte length stored. Call AFTER + /// `load_state_json` (the field is `#[serde(skip)]`). + #[func] + pub fn set_recipes_json(&mut self, json: GString) -> i64 { + self.state.load_recipes_json(json.to_string().as_str()) as i64 + } + /// Stamp the runtime `UnitsCatalog` (id → `UnitStats`) onto the held /// `GameState`. Distinct from `set_units_catalog_json` (which loads the /// tactical `ai_unit_catalog`): this is the same `mc_units::UnitsCatalog` diff --git a/src/simulator/api-gdext/src/replay.rs b/src/simulator/api-gdext/src/replay.rs index 4459f395..d449837e 100644 --- a/src/simulator/api-gdext/src/replay.rs +++ b/src/simulator/api-gdext/src/replay.rs @@ -546,7 +546,17 @@ impl GdReplayArchive { let pack_id = PackId(pack.to_string()); let game_id = GameId::new_v4(); - let clan = ClanId(1); + + // Four clans with diverging trajectories so the standings ladder + // reshuffles across turns — a meaningful fixture for the replay + // chronicle (not a single-row ladder). Tuple: (id, name, colour, base + // score, per-turn score slope). + let clan_defs: [(u32, &str, u32, f32, f32); 4] = [ + (1, "Stonebeard", 0xCC_88_22_FF, 100.0, 4.5), + (2, "Goldvein", 0xE6_33_33_FF, 60.0, 7.0), + (3, "Ironhold", 0x33_66_FF_FF, 140.0, 3.0), + (4, "Deepforge", 0x33_CC_4D_FF, 90.0, 5.2), + ]; let mut hist = GameHistory::new( game_id, @@ -558,61 +568,73 @@ impl GdReplayArchive { 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()), - }], + clan_defs + .iter() + .map(|&(id, name, colour, _, _)| ClanDescriptor { + id: ClanId(id), + name: name.into(), + sigil_key: format!("{}.png", name.to_lowercase()), + colour_rgba: colour, + starting_leader: LeaderId(format!("leader_{id}")), + }) + .collect(), ); - // One snapshot per turn for 50 turns. + // One snapshot per turn per clan 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, - buildings_built_total: t, - culture_total: 50.0 + t as f32 * 1.5, - }); + for &(id, _, _, base, slope) in &clan_defs { + hist.snapshots.push(TurnSnapshot { + turn: t, + clan_id: ClanId(id), + population: 1000 + t * 20 * id, + cities: 1 + (t / 10) + (id % 2), + army_strength: 10.0 + t as f32 * 0.5 * id as f32, + 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) + id, + land_area: 12 + t * 2, + score: base + t as f32 * slope, + buildings_built_total: t, + culture_total: 50.0 + t as f32 * 1.5, + }); + } } - // A few events sprinkled across turns. + // A few events sprinkled across turns (clan IDs reference clan_defs). let mut collector = TurnEventCollector::new(); collector.push(TurnEvent::CityFounded { turn: 1, - clan, + clan: ClanId(1), hex: TileCoord::new(4, 3), name: CityName("Karak Dûm".into()), }); collector.push(TurnEvent::TechResearched { turn: 5, - clan, + clan: ClanId(2), tech: TechId("mining".into()), }); collector.push(TurnEvent::CityFounded { turn: 10, - clan, + clan: ClanId(4), hex: TileCoord::new(9, 7), name: CityName("Ironhall".into()), }); - collector.push(TurnEvent::TechResearched { - turn: 20, - clan, - tech: TechId("smelting".into()), + collector.push(TurnEvent::WarDeclared { + turn: 15, + aggressor: ClanId(2), + target: ClanId(3), + }); + collector.push(TurnEvent::CityCaptured { + turn: 22, + attacker: ClanId(2), + defender: ClanId(3), + hex: TileCoord::new(11, 6), + name: CityName("Grimhold".into()), }); collector.push(TurnEvent::TechResearched { turn: 40, - clan, + clan: ClanId(1), tech: TechId("siege_craft".into()), }); collector.flush_to_history(&mut hist); @@ -752,6 +774,44 @@ impl GdReplayPlayer { } } + /// Project the full standings ladder as of `turn_idx`: one Dictionary per + /// clan that has a snapshot at-or-before the turn, ranked by score. Each + /// row carries `rank`, `clan_id`, `name` (GString), `colour_rgba`, the + /// snapshot `turn`, and the stat fields (`population`, `cities`, + /// `army_strength`, `gold`, `tech_count`, `land_area`, `score`). + /// + /// Unlike [`Self::goto_turn`] (first clan only), this exposes every clan, + /// ranked — the replay viewer renders it directly without re-deriving the + /// projection in GDScript (Rail 1). Returns an empty array if no history is + /// loaded. + #[func] + pub fn standings_at(&self, turn_idx: u32) -> Array { + let mut out: Array = Array::new(); + let Some(ref hist) = self.history else { + godot_error!( + "GdReplayPlayer.standings_at: no history loaded — call load_history first" + ); + return out; + }; + for row in hist.standings_at(turn_idx) { + let mut d = Dictionary::new(); + d.set("rank", row.rank as i64); + d.set("clan_id", row.clan_id.0 as i64); + d.set("name", GString::from(row.name.as_str())); + d.set("colour_rgba", row.colour_rgba as i64); + d.set("turn", row.turn as i64); + d.set("population", row.population as i64); + d.set("cities", row.cities as i64); + d.set("army_strength", row.army_strength as f64); + d.set("gold", row.gold); + d.set("tech_count", row.tech_count as i64); + d.set("land_area", row.land_area as i64); + d.set("score", row.score as f64); + out.push(&d); + } + out + } + /// Total number of events in the loaded history. /// /// Returns `0` if no history is loaded. diff --git a/src/simulator/crates/mc-replay/src/history.rs b/src/simulator/crates/mc-replay/src/history.rs index 5f187725..fd8906f0 100644 --- a/src/simulator/crates/mc-replay/src/history.rs +++ b/src/simulator/crates/mc-replay/src/history.rs @@ -45,6 +45,39 @@ pub struct ClanDescriptor { pub starting_leader: LeaderId, } +/// One projected standings row: a clan's most recent [`TurnSnapshot`] at-or- +/// before a given turn, joined with its static [`ClanDescriptor`] (name + +/// colour) and ranked by score. The replay viewer renders these directly — the +/// per-clan snapshot selection and ranking are done here in Rust (Rail 1), not +/// re-derived in GDScript. +#[derive(Debug, Clone, PartialEq)] +pub struct StandingRow { + /// 1-based placement, by `score` descending (ties broken by `clan_id`). + pub rank: u32, + /// Clan this row describes. + pub clan_id: ClanId, + /// Clan display name (from [`ClanDescriptor`]). + pub name: String, + /// Packed `0xRRGGBBAA` clan colour (from [`ClanDescriptor`]). + pub colour_rgba: u32, + /// Turn the underlying snapshot was actually taken at (≤ the requested turn). + pub turn: u32, + /// Total population across the clan's cities. + pub population: u32, + /// Number of cities controlled. + pub cities: u32, + /// Composite military strength. + pub army_strength: f32, + /// Treasury balance. + pub gold: i64, + /// Techs researched. + pub tech_count: u32, + /// Hexes owned. + pub land_area: u32, + /// Composite score (the ranking key). + pub score: f32, +} + /// The complete archived record of a single game. /// /// Serialized with bincode at game-end into `history.bin` inside the per-game @@ -130,6 +163,56 @@ impl GameHistory { }) .collect() } + + /// Project the full standings ladder as of `turn`: for every clan, its most + /// recent [`TurnSnapshot`] at-or-before `turn`, joined with the clan's + /// static descriptor and ranked by `score` descending (ties broken by + /// `clan_id` for determinism). Clans with no snapshot at-or-before `turn` + /// (the game hadn't reached their first recorded row, or they were never + /// seen) are omitted. + /// + /// This is the replay viewer's source of truth: [`Self::goto_turn`-equivalent + /// projection][`crate::history`] only exposed the *first* clan's row; this + /// exposes every clan, ranked, so the GDScript layer renders without + /// re-implementing the projection (Rail 1). + #[must_use] + pub fn standings_at(&self, turn: u32) -> Vec { + let mut rows: Vec = self + .clans + .iter() + .filter_map(|clan| { + let snap = self + .snapshots + .iter() + .filter(|s| s.clan_id == clan.id && s.turn <= turn) + .max_by_key(|s| s.turn)?; + Some(StandingRow { + rank: 0, + clan_id: clan.id, + name: clan.name.clone(), + colour_rgba: clan.colour_rgba, + turn: snap.turn, + population: snap.population, + cities: snap.cities, + army_strength: snap.army_strength, + gold: snap.gold, + tech_count: snap.tech_count, + land_area: snap.land_area, + score: snap.score, + }) + }) + .collect(); + rows.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.clan_id.0.cmp(&b.clan_id.0)) + }); + for (i, row) in rows.iter_mut().enumerate() { + row.rank = (i + 1) as u32; + } + rows + } } /// Per-turn buffer of [`TurnEvent`]s. @@ -264,4 +347,91 @@ mod tests { assert!(hist.events.is_empty()); assert_eq!(hist.final_turn, 0); } + + fn clan(id: u32, name: &str) -> ClanDescriptor { + ClanDescriptor { + id: ClanId(id), + name: name.into(), + sigil_key: format!("sigil_{id}"), + colour_rgba: 0x1020_30FF + id, + starting_leader: crate::ids::LeaderId(format!("leader_{id}")), + } + } + + fn snap(turn: u32, clan: u32, score: f32, cities: u32) -> TurnSnapshot { + TurnSnapshot { + turn, + clan_id: ClanId(clan), + population: 100 * clan, + cities, + army_strength: 10.0 * clan as f32, + gold: 50, + gold_per_turn: 5, + culture_per_turn: 1.0, + tech_count: clan, + land_area: 10 * clan, + buildings_built_total: clan, + culture_total: 5.0, + score, + } + } + + #[test] + fn standings_at_ranks_all_clans_by_score() { + let mut hist = empty_history(); + hist.clans = vec![clan(1, "Ironhold"), clan(2, "Goldvein"), clan(3, "Deepforge")]; + // Two turns of snapshots; turn 2 reshuffles the order. + hist.snapshots = vec![ + snap(1, 1, 100.0, 1), + snap(1, 2, 80.0, 1), + snap(1, 3, 60.0, 1), + snap(2, 1, 110.0, 2), + snap(2, 2, 200.0, 3), + snap(2, 3, 90.0, 1), + ]; + + let at2 = hist.standings_at(2); + assert_eq!(at2.len(), 3, "all three clans appear"); + // Goldvein (200) > Ironhold (110) > Deepforge (90). + assert_eq!(at2[0].name, "Goldvein"); + assert_eq!(at2[0].rank, 1); + assert_eq!(at2[0].cities, 3); + assert_eq!(at2[1].name, "Ironhold"); + assert_eq!(at2[1].rank, 2); + assert_eq!(at2[2].name, "Deepforge"); + assert_eq!(at2[2].rank, 3); + // Colour + clan id carried through from the descriptor. + assert_eq!(at2[0].clan_id, ClanId(2)); + assert_eq!(at2[0].colour_rgba, 0x1020_30FF + 2); + } + + #[test] + fn standings_at_uses_latest_snapshot_at_or_before_turn() { + let mut hist = empty_history(); + hist.clans = vec![clan(1, "Ironhold")]; + hist.snapshots = vec![snap(1, 1, 10.0, 1), snap(3, 1, 30.0, 3)]; + + // At turn 2, only the turn-1 snapshot is at-or-before; turn-3 excluded. + let at2 = hist.standings_at(2); + assert_eq!(at2.len(), 1); + assert_eq!(at2[0].turn, 1); + assert_eq!(at2[0].cities, 1); + + // At turn 5, the latest (turn-3) snapshot wins. + let at5 = hist.standings_at(5); + assert_eq!(at5[0].turn, 3); + assert_eq!(at5[0].cities, 3); + } + + #[test] + fn standings_at_omits_clans_with_no_snapshot_yet() { + let mut hist = empty_history(); + hist.clans = vec![clan(1, "Ironhold"), clan(2, "Goldvein")]; + // Goldvein's first snapshot is turn 5; at turn 2 it is not yet present. + hist.snapshots = vec![snap(1, 1, 10.0, 1), snap(5, 2, 99.0, 4)]; + + let at2 = hist.standings_at(2); + assert_eq!(at2.len(), 1, "Goldvein omitted before its first snapshot"); + assert_eq!(at2[0].name, "Ironhold"); + } } diff --git a/src/simulator/crates/mc-replay/src/lib.rs b/src/simulator/crates/mc-replay/src/lib.rs index f3e996a3..d277d42b 100644 --- a/src/simulator/crates/mc-replay/src/lib.rs +++ b/src/simulator/crates/mc-replay/src/lib.rs @@ -32,7 +32,7 @@ pub use archive::{ }; pub use awards::{compute_awards, AwardDef, AwardDefs, AwardWinner}; pub use event::{LeaderChangeCause, TurnEvent}; -pub use history::{ClanDescriptor, GameHistory, MetSet, TurnEventCollector}; +pub use history::{ClanDescriptor, GameHistory, MetSet, StandingRow, TurnEventCollector}; pub use ids::{ CityName, ClanId, EraId, GameId, LeaderId, PackId, PackVersion, TechId, TileCoord, UnitKind, WonderId,