feat(@projects/@magic-civilization): ⚒️ p3-26 B6a (2/2) — FFI + harness boot the recipe bundle

- GdPlayerApi::set_recipes_json — stores the recipe bundle JSON on GameState. Mirrors
  set_improvement_defs_json.
- player_api_main._apply_recipes — reads public/resources/recipes/recipes.json (the
  {recipes:[…]} bundle, 12 recipes) at boot and stamps it via the FFI, emitting
  recipes_api_loaded.

Resource refinement now runs end-to-end in real headless play: boot recipes → recipe phase
consumes raw + produces refined into strategic_ledger each turn. B6a (recipe refinement)
complete. gdext compiles; dylib rebuild in progress.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 20:51:11 -04:00
parent dd1a537ab5
commit 5edd20ced6
6 changed files with 303 additions and 34 deletions

View file

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

View file

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

View file

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

View file

@ -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<Dictionary> {
let mut out: Array<Dictionary> = 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.

View file

@ -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<StandingRow> {
let mut rows: Vec<StandingRow> = 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");
}
}

View file

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