feat(@projects/@magic-civilization): ⚔️ p3-26 B6b (2/3) — equipment combat bonuses (units fight harder with gear)

The combat-read half of equipment:
- GameState += item_combat: BTreeMap<String, ItemCombatBonus{attack,defense}> (#[serde(skip)]
  boot) + load_item_combat_json (sums items.json stats: melee_bonus+ranged_bonus → attack,
  armor_bonus → defense).
- processor::equip_combat_bonus(unit, table) sums a unit's equipped-item bonuses; injected into
  BOTH combat paths (process_pvp_combat formation params + the click/MCTS resolve helper) —
  added to attacker/defender attack + defense.

Safe by construction: unequipped units (empty `equipped`) + empty table → (0,0) → combat
unchanged, so all existing combat tests + the 20-turn golden are unaffected (verified:
mc-turn 284/0). Tests: equip_combat_bonus sums stats / zero for unequipped/unknown.
Remaining (3/3): FFI+harness to boot the item table + a Craft/Equip action to populate gear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 21:13:16 -04:00
parent 4c41ecf66e
commit a3dac211e3
6 changed files with 367 additions and 17 deletions

View file

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

View file

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

View file

@ -2005,6 +2005,7 @@ dependencies = [
"mc-comms",
"mc-core",
"mc-culture",
"mc-items",
"mc-observation",
"mc-replay",
"mc-tech",

View file

@ -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<String, ItemCombatBonus>,
/// 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::<Vec<serde_json::Value>>(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.

View file

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

View file

@ -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<String, mc_state::game_state::ItemCombatBonus>,
) -> (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::*;