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:
parent
dd1a537ab5
commit
5edd20ced6
6 changed files with 303 additions and 34 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue