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:
parent
4c41ecf66e
commit
a3dac211e3
6 changed files with 367 additions and 17 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
1
src/simulator/Cargo.lock
generated
1
src/simulator/Cargo.lock
generated
|
|
@ -2005,6 +2005,7 @@ dependencies = [
|
|||
"mc-comms",
|
||||
"mc-core",
|
||||
"mc-culture",
|
||||
"mc-items",
|
||||
"mc-observation",
|
||||
"mc-replay",
|
||||
"mc-tech",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue