diff --git a/src/game/engine/scenes/menus/replay_viewer.gd b/src/game/engine/scenes/menus/replay_viewer.gd index 6f429e50..24767458 100644 --- a/src/game/engine/scenes/menus/replay_viewer.gd +++ b/src/game/engine/scenes/menus/replay_viewer.gd @@ -30,12 +30,6 @@ var _events_cursor: int = 0 ## a real game session. @export var local_player: int = -1 -@onready var _turn_label: Label = %TurnLabel -@onready var _scrubber: HSlider = %Scrubber -@onready var _play_pause_button: Button = %PlayPauseButton -@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 @@ -48,6 +42,12 @@ var _clan_colors: Dictionary = {} var _standings_box: VBoxContainer = null var _events_box: VBoxContainer = null +@onready var _turn_label: Label = %TurnLabel +@onready var _scrubber: HSlider = %Scrubber +@onready var _play_pause_button: Button = %PlayPauseButton +@onready var _speed_label: Label = %SpeedLabel +@onready var _back_button: Button = %BackButton + func _ready() -> void: _back_button.text = ThemeVocabulary.lookup("replay_back") @@ -86,15 +86,19 @@ func _ready() -> void: var archive_root: String = _resolve_archive_root() if _player.load_history(archive_root, pack, game_id): _final_turn = maxi(1, _player.final_turn()) + _build_clan_index() else: push_error("ReplayViewer: failed to load history for game_id=%s" % game_id) + _build_chronicle() + _scrubber.min_value = 1.0 _scrubber.max_value = float(_final_turn) _scrubber.step = 1.0 _scrubber.value = 1.0 _update_turn_display() + _render_chronicle(_current_turn) func _process(delta: float) -> void: @@ -117,9 +121,8 @@ func _goto_turn(turn: int) -> void: _scrubber.set_value_no_signal(float(_current_turn)) _update_turn_display() if _player != null: - var _snap: Dictionary = _player.goto_turn(_current_turn) - # Renderer mutation: pass _snap to world-map renderer when wired. _dispatch_events_through(prev_turn, _current_turn) + _render_chronicle(_current_turn) ## Forward TurnEvents that fall within the (prev, current] window to @@ -155,7 +158,9 @@ func _resolve_archive_root() -> String: func _update_turn_display() -> void: - _turn_label.text = ThemeVocabulary.lookup("fmt_replay_turn", "Turn %d / %d") % [_current_turn, _final_turn] + _turn_label.text = ( + ThemeVocabulary.lookup("fmt_replay_turn", "Turn %d / %d") % [_current_turn, _final_turn] + ) _speed_label.text = "%.1f×" % _speed @@ -166,7 +171,8 @@ func _on_scrubber_changed(value: float) -> void: func _on_play_pause_pressed() -> void: _playing = not _playing _play_pause_button.text = ( - ThemeVocabulary.lookup("replay_pause") if _playing + ThemeVocabulary.lookup("replay_pause") + if _playing else ThemeVocabulary.lookup("replay_play") ) _playback_elapsed = 0.0 @@ -191,6 +197,189 @@ func _on_back_pressed() -> void: main.change_scene("res://engine/scenes/menus/past_games.tscn") +## Build the clan id -> name/colour map from the Rust standings projection at +## the final turn, so the event chronicle can label clans (the events carry only +## numeric clan ids). +func _build_clan_index() -> void: + _clan_names.clear() + _clan_colors.clear() + if _player == null: + return + for row: Dictionary in _player.standings_at(_final_turn): + var cid: int = int(row.get("clan_id", -1)) + _clan_names[cid] = str(row.get("name", "Clan %d" % cid)) + _clan_colors[cid] = _color_from_rgba(int(row.get("colour_rgba", 0xFFFFFFFF))) + + +## Replace the placeholder with the chronicle stage: a live event feed (left) +## and a standings ladder (right), both repainted from the Rust projection as +## the scrubber moves. The renderers are intentionally NOT mounted — the archive +## carries no per-turn geometry (terrain/unit positions), so a faithful map +## replay needs a separate WorldSnapshot objective; this renders what the +## recorded data fully supports. +func _build_chronicle() -> void: + var stage: ColorRect = get_node_or_null("MarginContainer/VBoxContainer/WorldMapPlaceholder") + if stage == null: + return + var placeholder: Label = %PlaceholderLabel + if placeholder != null: + placeholder.visible = false + + var pad: MarginContainer = MarginContainer.new() + pad.set_anchors_preset(Control.PRESET_FULL_RECT) + for side: String in ["left", "top", "right", "bottom"]: + pad.add_theme_constant_override("margin_%s" % side, 18) + stage.add_child(pad) + + var columns: HBoxContainer = HBoxContainer.new() + columns.add_theme_constant_override("separation", 24) + pad.add_child(columns) + + var feed_col: VBoxContainer = VBoxContainer.new() + feed_col.size_flags_horizontal = Control.SIZE_EXPAND_FILL + feed_col.add_theme_constant_override("separation", 4) + feed_col.add_child(_section_header("chronicle")) + _events_box = VBoxContainer.new() + _events_box.add_theme_constant_override("separation", 2) + feed_col.add_child(_events_box) + columns.add_child(feed_col) + + var standings_col: VBoxContainer = VBoxContainer.new() + standings_col.custom_minimum_size = Vector2(280, 0) + standings_col.add_theme_constant_override("separation", 4) + standings_col.add_child(_section_header("standings · by score")) + _standings_box = VBoxContainer.new() + _standings_box.add_theme_constant_override("separation", 5) + standings_col.add_child(_standings_box) + columns.add_child(standings_col) + + +func _section_header(text: String) -> Label: + var label: Label = Label.new() + label.text = text + label.add_theme_font_size_override("font_size", 14) + label.add_theme_color_override("font_color", Color(0.62, 0.66, 0.74)) + return label + + +## Repaint the standings ladder + event feed for `turn` from the Rust +## projection. Standings come from GdReplayPlayer.standings_at (all clans, +## ranked, in Rust); the feed is the cumulative events with turn <= `turn`. +func _render_chronicle(turn: int) -> void: + if _player == null: + return + if _standings_box != null: + for child: Node in _standings_box.get_children(): + child.queue_free() + for row: Dictionary in _player.standings_at(turn): + _standings_box.add_child(_standings_row(row)) + if _events_box != null: + for child: Node in _events_box.get_children(): + child.queue_free() + var lines: Array[String] = [] + var total: int = _player.event_count() + for i: int in range(total): + var evt: Dictionary = _player.event_at(i) + if int(evt.get("turn", 0)) > turn: + break # events are sorted by turn ascending + var text: String = _format_event(evt) + if not text.is_empty(): + lines.append(text) + var start: int = maxi(0, lines.size() - CHRONICLE_MAX_LINES) + for j: int in range(start, lines.size()): + _events_box.add_child(_event_line(lines[j])) + + +func _standings_row(row: Dictionary) -> HBoxContainer: + var hbox: HBoxContainer = HBoxContainer.new() + hbox.add_theme_constant_override("separation", 8) + + var rank_label: Label = Label.new() + rank_label.text = "%d" % int(row.get("rank", 0)) + rank_label.custom_minimum_size = Vector2(18, 0) + rank_label.add_theme_color_override("font_color", Color(0.62, 0.66, 0.74)) + + var swatch: ColorRect = ColorRect.new() + swatch.custom_minimum_size = Vector2(12, 12) + swatch.color = _color_from_rgba(int(row.get("colour_rgba", 0xFFFFFFFF))) + var swatch_center: CenterContainer = CenterContainer.new() + swatch_center.add_child(swatch) + + var name_label: Label = Label.new() + name_label.text = str(row.get("name", "?")) + name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + var score_label: Label = Label.new() + score_label.text = "%d" % int(round(float(row.get("score", 0.0)))) + + hbox.add_child(rank_label) + hbox.add_child(swatch_center) + hbox.add_child(name_label) + hbox.add_child(score_label) + return hbox + + +func _event_line(text: String) -> Label: + var label: Label = Label.new() + label.text = text + label.add_theme_font_size_override("font_size", 13) + label.add_theme_color_override("font_color", Color(0.82, 0.84, 0.88)) + return label + + +## Format a recorded TurnEvent dict into a chronicle line. Returns "" for kinds +## not surfaced in the feed. +func _format_event(evt: Dictionary) -> String: + var kind: String = str(evt.get("kind", "")) + var t: int = int(evt.get("turn", 0)) + match kind: + "CityFounded": + return "T%d %s founds %s" % [t, _clan_label(evt.get("clan")), str(evt.get("name", ""))] + "CityCaptured": + return ( + "T%d %s captures %s from %s" + % [ + t, + _clan_label(evt.get("attacker")), + str(evt.get("name", "")), + _clan_label(evt.get("defender")), + ] + ) + "WarDeclared": + return ( + "T%d %s declares war on %s" + % [t, _clan_label(evt.get("aggressor")), _clan_label(evt.get("target"))] + ) + "WonderBuilt": + return ( + "T%d %s completes %s" + % [t, _clan_label(evt.get("clan")), str(evt.get("wonder", ""))] + ) + "TechResearched": + return ( + "T%d %s researches %s" + % [t, _clan_label(evt.get("clan")), str(evt.get("tech", ""))] + ) + "GameOver": + return "T%d game over" % t + _: + return "" + + +func _clan_label(clan_id: Variant) -> String: + var cid: int = int(clan_id) + return str(_clan_names.get(cid, "Clan %d" % cid)) + + +func _color_from_rgba(packed: int) -> Color: + return Color( + float((packed >> 24) & 0xFF) / 255.0, + float((packed >> 16) & 0xFF) / 255.0, + float((packed >> 8) & 0xFF) / 255.0, + float(packed & 0xFF) / 255.0, + ) + + func _input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: get_viewport().set_input_as_handled() diff --git a/src/game/engine/tests/integration/test_p2_46_replay_bridge.gd b/src/game/engine/tests/integration/test_p2_46_replay_bridge.gd index 3c04cf65..2729bfb4 100644 --- a/src/game/engine/tests/integration/test_p2_46_replay_bridge.gd +++ b/src/game/engine/tests/integration/test_p2_46_replay_bridge.gd @@ -86,6 +86,48 @@ func test_goto_turn_returns_empty_dict_when_no_history() -> void: assert_engine_error("no history loaded") +## Assertion 4: standings_at() on a written fixture projects ALL clans ranked +## by score, each row carrying name + colour from the clan descriptor — the +## Rust projection the replay chronicle renders directly. +func test_standings_at_projects_ranked_ladder() -> void: + var archive: GdReplayArchive = GdReplayArchive.new() + var uuid: String = archive.write_fixture(_archive_root, "age-of-dwarves", "Standings Fixture") + assert_false(uuid.is_empty(), "write_fixture must return a uuid") + + var player: GdReplayPlayer = GdReplayPlayer.new() + assert_true( + player.load_history(_archive_root, "age-of-dwarves", uuid), + "load_history must succeed", + ) + + var ladder: Array = player.standings_at(25) + assert_eq(ladder.size(), 4, "fixture has four clans in the ladder") + var prev_score: float = INF + for i: int in ladder.size(): + var row: Dictionary = ladder[i] + assert_eq(int(row.get("rank", -1)), i + 1, "rank is 1-based in ladder order") + assert_false(str(row.get("name", "")).is_empty(), "row carries a clan name") + assert_true(row.has("colour_rgba"), "row carries a packed colour") + var score: float = float(row.get("score", 0.0)) + assert_true(score <= prev_score, "ladder sorted by score descending") + prev_score = score + # Goldvein has the steepest score slope and leads by turn 25. + assert_eq( + str((ladder[0] as Dictionary).get("name", "")), + "Goldvein", + "Goldvein leads the turn-25 standings", + ) + + +## Assertion 5: standings_at() with no loaded history returns an empty Array. +func test_standings_at_empty_when_no_history() -> void: + var player: GdReplayPlayer = GdReplayPlayer.new() + var ladder: Array = player.standings_at(1) + assert_true(ladder is Array, "standings_at must return an Array") + assert_eq(ladder.size(), 0, "no loaded history -> empty ladder") + assert_engine_error("no history loaded") + + ## Helper: remove a directory tree recursively (best-effort, ignores errors). func _remove_dir_recursive(path: String) -> void: var da: DirAccess = DirAccess.open(path) diff --git a/src/simulator/Cargo.lock b/src/simulator/Cargo.lock index 63f10dc8..e84915a0 100644 --- a/src/simulator/Cargo.lock +++ b/src/simulator/Cargo.lock @@ -2005,6 +2005,7 @@ dependencies = [ "mc-comms", "mc-core", "mc-culture", + "mc-items", "mc-observation", "mc-replay", "mc-tech", diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index 2d79dd7d..dc6e9936 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -445,6 +445,12 @@ pub struct GameState { /// needs no mc-city dependency). Empty → no resource refinement runs (safe no-op). #[serde(skip)] pub recipes_json: String, + /// p3-26 B6b: item id → combat bonus (attack from melee_bonus+ranged_bonus, + /// defense from armor_bonus), boot-loaded from `public/resources/items/*.json` + /// `stats`. `#[serde(skip)]` static content; the combat path adds an equipped + /// unit's summed bonuses to its attack/defense. Empty → equipment is inert. + #[serde(skip)] + pub item_combat: BTreeMap, /// p2-71: tactical-AI view of the producible-unit catalog. Mirrors /// `TacticalState::unit_catalog` and is populated once at harness boot /// by `GdPlayerApi::set_units_catalog_json` (or directly in Rust tests). @@ -789,6 +795,41 @@ impl GameState { self.recipes_json.len() } + /// p3-26 B6b: load item combat bonuses from a JSON array of item objects + /// (`[{id, stats:[{key,value}]}]`, the `public/resources/items/*.json` shape). + /// attack = melee_bonus + ranged_bonus, defense = armor_bonus. Items with no + /// combat stats are skipped. Returns the count stored. Called once at boot. + pub fn load_item_combat_json(&mut self, json_array: &str) -> usize { + let Ok(arr) = serde_json::from_str::>(json_array) else { + return 0; + }; + let mut n = 0; + for v in &arr { + let Some(id) = v.get("id").and_then(|x| x.as_str()) else { + continue; + }; + let mut attack = 0; + let mut defense = 0; + if let Some(stats) = v.get("stats").and_then(|s| s.as_array()) { + for stat in stats { + let key = stat.get("key").and_then(|k| k.as_str()).unwrap_or(""); + let val = stat.get("value").and_then(|x| x.as_i64()).unwrap_or(0) as i32; + match key { + "melee_bonus" | "ranged_bonus" => attack += val, + "armor_bonus" => defense += val, + _ => {} + } + } + } + if attack != 0 || defense != 0 { + self.item_combat + .insert(id.to_string(), ItemCombatBonus { attack, defense }); + n += 1; + } + } + n + } + /// p2-65 Phase7 test helper: construct a GameState whose combat_balance /// (and future SimConfig fields) are pre-populated without touching the /// global RwLock singleton. Callers that need isolated config for @@ -1244,6 +1285,17 @@ pub struct PlayerState { /// `GameState::improvement_defs`). `build_turns` drives the build-tick; `food` /// and `production` feed `process_improvement_yields` once the improvement /// completes onto a city. +/// p3-26 B6b: an item's combat contribution when equipped (boot-loaded, keyed by +/// item id on `GameState::item_combat`). Summed from the item's `stats`: +/// `attack` = melee_bonus + ranged_bonus, `defense` = armor_bonus. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub struct ItemCombatBonus { + /// Added to the unit's attack when this item is equipped. + pub attack: i32, + /// Added to the unit's defense when this item is equipped. + pub defense: i32, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ImprovementDef { /// Turns of worker construction before the improvement completes. diff --git a/src/simulator/crates/mc-turn/Cargo.toml b/src/simulator/crates/mc-turn/Cargo.toml index 7b9b02ab..6883d6e0 100644 --- a/src/simulator/crates/mc-turn/Cargo.toml +++ b/src/simulator/crates/mc-turn/Cargo.toml @@ -36,6 +36,7 @@ bytemuck = { version = "1", features = ["derive"], optional = true } [dev-dependencies] proptest = "1" rand.workspace = true +mc-items = { path = "../mc-items" } # Used by tests/abstract_projection.rs to read raw bytes of the POD # returned by `to_abstract_rollout_state` for byte-identical assertions. bytemuck = { version = "1", features = ["derive"] } diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index c498e48e..b0499807 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -49,6 +49,25 @@ use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::OnceLock; +/// p3-26 B6b: sum a unit's equipped-item combat bonuses `(attack, defense)` via the +/// boot-loaded `item_combat` table. Returns `(0, 0)` when the unit has no equipment +/// or the table is empty — so combat is unchanged for unequipped units (and all +/// existing tests/goldens, which never equip gear). +fn equip_combat_bonus( + unit: &mc_state::game_state::MapUnit, + table: &BTreeMap, +) -> (i32, i32) { + let mut atk = 0; + let mut def = 0; + for item in &unit.equipped { + if let Some(b) = table.get(&item.item_id) { + atk += b.attack; + def += b.defense; + } + } + (atk, def) +} + // ── PvP / Siege Constants ────────────────────────────────────────────────── /// Default seek range for non-rusher profiles. Units will move toward enemies @@ -2769,14 +2788,20 @@ impl TurnProcessor { let params = { let defender = &state.players[defender_player].units[defender_unit]; + // p3-26 B6b: equipped-item combat bonuses (0 for unequipped units). + let (a_eq_atk, a_eq_def) = equip_combat_bonus( + &state.players[attacker_player].units[attacker_unit], + &state.item_combat, + ); + let (d_eq_atk, d_eq_def) = equip_combat_bonus(defender, &state.item_combat); CombatParams { attacker: UnitStats { - hp: a_hp, max_hp: 60, attack: a_atk, - defense: a_def, ranged_attack: 0, range: 0, movement: 2, + hp: a_hp, max_hp: 60, attack: a_atk + a_eq_atk, + defense: a_def + a_eq_def, ranged_attack: 0, range: 0, movement: 2, }, defender: UnitStats { hp: defender.hp, max_hp: defender.max_hp, - attack: defender.attack, defense: defender.defense, + attack: defender.attack + d_eq_atk, defense: defender.defense + d_eq_def, ranged_attack: 0, range: 0, movement: 2, }, combat_type: CombatType::Melee, @@ -3576,18 +3601,22 @@ impl TurnProcessor { .unwrap_or(mc_combat::PostureResolution::Capture) }; + // p3-26 B6b: equipped-item combat bonuses (0 for unequipped units). + let (a_eq_atk, a_eq_def) = + equip_combat_bonus(&state.players[pi].units[*ui], &state.item_combat); + let (d_eq_atk, d_eq_def) = equip_combat_bonus(defender, &state.item_combat); let params = CombatParams { attacker: UnitStats { hp: *a_hp * a_formation_size as i32, max_hp: 60 * a_formation_size as i32, - attack: (*a_atk as f32 * a_formation_scale).round() as i32, - defense: *a_def, ranged_attack: 0, range: 0, movement: 2, + attack: (*a_atk as f32 * a_formation_scale).round() as i32 + a_eq_atk, + defense: *a_def + a_eq_def, ranged_attack: 0, range: 0, movement: 2, }, defender: UnitStats { hp: defender.hp * def_formation_size as i32, max_hp: defender.max_hp * def_formation_size as i32, - attack: (defender.attack as f32 * def_formation_scale).round() as i32, - defense: defender.defense, ranged_attack: 0, range: 0, movement: 2, + attack: (defender.attack as f32 * def_formation_scale).round() as i32 + d_eq_atk, + defense: defender.defense + d_eq_def, ranged_attack: 0, range: 0, movement: 2, }, combat_type: CombatType::Melee, attacker_keywords: Vec::new(), @@ -5283,6 +5312,42 @@ fn process_one_move(state: &mut GameState, req: &crate::game_state::MoveRequest) } } +#[cfg(test)] +mod equip_bonus_tests { + use super::*; + use crate::game_state::{ItemCombatBonus, MapUnit}; + use mc_items::EquippedItem; + + fn item(id: &str) -> EquippedItem { + EquippedItem { + item_id: id.to_string(), + category: String::new(), + charges_remaining: 0, + triggers_in_combat: false, + } + } + + #[test] + fn equip_combat_bonus_sums_equipped_item_stats() { + let mut unit = MapUnit::default(); + unit.equipped = vec![item("bronze_sword"), item("chainmail")]; + let mut table = BTreeMap::new(); + table.insert("bronze_sword".to_string(), ItemCombatBonus { attack: 3, defense: 0 }); + table.insert("chainmail".to_string(), ItemCombatBonus { attack: 0, defense: 4 }); + assert_eq!(equip_combat_bonus(&unit, &table), (3, 4)); + } + + #[test] + fn equip_combat_bonus_zero_for_unequipped_or_unknown() { + let mut unit = MapUnit::default(); + // unequipped → 0 + assert_eq!(equip_combat_bonus(&unit, &BTreeMap::new()), (0, 0)); + // equipped but item not in table → 0 + unit.equipped = vec![item("mystery")]; + assert_eq!(equip_combat_bonus(&unit, &BTreeMap::new()), (0, 0)); + } +} + #[cfg(test)] mod move_request_tests { use super::*;