From bd186b162a7f1f26926dbf254747d2961d5c0e18 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 27 Jun 2026 08:48:35 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=9B=A4=EF=B8=8F=20Rail-1=20Phase-1=20=E2=80=94=20bench=20?= =?UTF-8?q?unit=20XP/veterancy=20in=20the=20Rust=20turn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Units gained no XP in the headless/bench turn (only GDScript UnitScript tracked it). The XP amounts were already Rust-authoritative (mc-combat: BASE_COMBAT_XP=5 × xp_from_combat strength scaling; resolver zeroes dead-defender XP / suppresses capture XP). This wires the award into the bench turn so the unified game has veterancy: - MapUnit.experience: i32 (#[serde(default)]; all 110 literals use ..default()). - resolve_single_pvp_attack accumulates attacker_xp/defender_xp onto survivors, survival-gated exactly like combat_resolver.gd:215-223. - project_units surfaces UnitView.experience + promotion_available from XP threshold eligibility (mc_combat::check_promotion), replacing the 0 stub. - new test pvp_combat_awards_xp_to_survivors (queued-attack path, no kills → both survivors gain XP). Deferred (cited, out of scope): the veteran_level/promotion stat-growth pick subsystem (bench uses flat UnitStats, not the D20 path) and the pre-existing Rust↔JSON promotion-threshold divergence (promotions.json [15,30,45,60] vs Rust [10,30,60,100]) — a Rail-2 content/code gap tracked separately. Dispatched combat-dev; verify gate: mc-combat+mc-turn+mc-player-api 0 failed. Co-Authored-By: Claude Opus 4.8 --- .../crates/mc-player-api/src/projection.rs | 14 ++- .../crates/mc-state/src/game_state.rs | 8 ++ src/simulator/crates/mc-turn/src/processor.rs | 17 +++ .../crates/mc-turn/tests/pvp_xp_award.rs | 112 ++++++++++++++++++ 4 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 src/simulator/crates/mc-turn/tests/pvp_xp_award.rs diff --git a/src/simulator/crates/mc-player-api/src/projection.rs b/src/simulator/crates/mc-player-api/src/projection.rs index 1c735298..e34c4dcc 100644 --- a/src/simulator/crates/mc-player-api/src/projection.rs +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -361,10 +361,16 @@ fn project_units( // is the per-turn maximum. movement_left: unit.movement_remaining, movement_max: unit.base_moves, - // XP is not yet on the bench MapUnit — surfaced once the SOT - // flip (Phase 1) widens the model. Stays 0 until then. - experience: 0, - promotion_available: unit.pending_promotion.is_some(), + // Rail-1 Phase 1: project the real combat XP the bench turn now + // accumulates onto MapUnit at the PvP combat site. Promotion is + // available when the unit has an explicit queued pick OR its XP + // has reached the first promotion threshold + // (`mc_combat::check_promotion` against level 0 — the bench flat- + // stat path does not yet track veteran level, so eligibility is + // gauged from XP alone; see deferred scope in the Phase-1 note). + experience: unit.experience, + promotion_available: unit.pending_promotion.is_some() + || mc_combat::check_promotion(unit.experience, 0).is_some(), fortified: unit.is_fortified, sentry: unit.is_sentrying, // Own units carry their formation + full posture; enemies do diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index af822d15..e9c6aac2 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -1632,6 +1632,14 @@ pub struct MapUnit { /// `None` means no promotion is queued. #[serde(default, skip_serializing_if = "Option::is_none")] pub pending_promotion: Option, + /// Rail-1 Phase 1: combat experience accumulated by this unit. Awarded at + /// the bench PvP combat site (`mc_turn::processor::resolve_single_pvp_attack`) + /// from `CombatResult::attacker_xp` / `defender_xp`, mirroring the live + /// GDScript `UnitScript.gain_xp` semantics. Promotion eligibility is + /// derived from this via `mc_combat::check_promotion`. `#[serde(default)]` + /// keeps old saves loadable (absent → 0). + #[serde(default)] + pub experience: i32, /// p2-67 Phase 9: movement points remaining this turn. Decremented /// by `mc_turn::processor::process_move_requests` as the unit /// pathfinds; reset to `base_moves` at turn start by diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 2c5303a7..8630a7e9 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -2949,6 +2949,23 @@ impl TurnProcessor { // semantics. The capture-specific events below carry the richer story. let defender_survived = combat_result.defender_outcome == CombatOutcome::Survived; + // Rail-1 Phase 1: accumulate combat XP onto the surviving units, + // mirroring the live GDScript `CombatResolver` (combat_resolver.gd:218-223): + // a participant earns XP only if it survives the engagement. The resolver + // already zeroes `defender_xp` for a slain defender and suppresses + // `attacker_xp` on capture, so the survival gate here is the same belt-and- + // braces the live game applies. Indices are still valid — the swap_remove + // blocks below have not run yet. Promotion eligibility is then refreshed + // from the new XP total via `mc_combat::check_promotion`. + if attacker_survived { + let a = &mut state.players[attacker_player].units[attacker_unit]; + a.experience += combat_result.attacker_xp; + } + if defender_survived { + let d = &mut state.players[defender_player].units[defender_unit]; + d.experience += combat_result.defender_xp; + } + // Branch on capture outcomes BEFORE the legacy kill-handling block so // we don't swap_remove a captured / ransomed unit. Each branch carries // its own state mutation + event emission. diff --git a/src/simulator/crates/mc-turn/tests/pvp_xp_award.rs b/src/simulator/crates/mc-turn/tests/pvp_xp_award.rs new file mode 100644 index 00000000..077cab0f --- /dev/null +++ b/src/simulator/crates/mc-turn/tests/pvp_xp_award.rs @@ -0,0 +1,112 @@ +//! Rail-1 Phase 1 regression: a bench PvP engagement must accumulate combat +//! XP onto the surviving units' `MapUnit::experience`, mirroring the live +//! GDScript `CombatResolver` (combat_resolver.gd:218-223, `gain_xp`). +//! +//! Pre-fix RC: `MapUnit` had no `experience` field and +//! `resolve_single_pvp_attack` discarded `CombatResult::attacker_xp` / +//! `defender_xp`, so the headless model never simulated unit veterancy even +//! though the resolver already computed the XP amounts. +//! +//! Fixture: minimal 2-player setup, both combatants tanky enough that neither +//! dies. The two units are placed far apart (non-adjacent) so the proximity- +//! discovery loop never fires — only the single explicit queued attack resolves. +//! The queued PvP path does not check adjacency (see `queued_pvp_unit_killed.rs`), +//! so the attack still lands. One clean exchange = both survive = both earn XP. +//! - p0 attacker at (10, 10), high HP / high defense, modest attack. +//! - p1 defender at (40, 40), high HP / high defense, modest attack. +//! - Single `AttackRequest` queued via `pending_pvp_attacks`. +//! +//! Assertions: exactly one combat resolves, both units survive, and both have +//! `experience > 0`. + +use mc_state::game_state::{AttackRequest, GameState, MapUnit, PlayerState}; +use mc_turn::TurnProcessor; + +#[test] +fn pvp_combat_awards_xp_to_survivors() { + let mut state = GameState { + turn: 0, + players: vec![ + PlayerState { + player_index: 0, + ..Default::default() + }, + PlayerState { + player_index: 1, + ..Default::default() + }, + ], + grid: None, + ..Default::default() + }; + + // p0 attacker at (10, 10) — very tanky (high HP + defense, low attack) so it + // survives every exchange this turn. + state.players[0].units.push(MapUnit { + id: 300, + col: 10, + row: 10, + hp: 100, + max_hp: 100, + attack: 6, + defense: 30, + unit_id: "dwarf_warrior".to_string(), + ..Default::default() + }); + // p1 defender placed FAR from the attacker — non-adjacent so the proximity- + // discovery loop never fires; only the explicit queued attack resolves. + state.players[1].units.push(MapUnit { + id: 200, + col: 40, + row: 40, + hp: 100, + max_hp: 100, + attack: 6, + defense: 30, + unit_id: "dwarf_warrior".to_string(), + ..Default::default() + }); + + // Pre-condition: both start at zero XP. + assert_eq!(state.players[0].units[0].experience, 0); + assert_eq!(state.players[1].units[0].experience, 0); + + // Queue the explicit PvP attack — resolver doesn't check adjacency, so a + // co-located stack with `defender_unit: 0` is sufficient. + state.pending_pvp_attacks.push(AttackRequest { + attacker_player: 0, + attacker_unit: 0, + defender_player: 1, + defender_unit: 0, + }); + + let processor = TurnProcessor::new(400); + let result = processor.step(&mut state); + + // Sanity: exactly one combat ran (queued path only — no discovery) and + // nobody died, so both are XP-eligible. + assert_eq!(result.pvp_battles, 1, "exactly one PvP combat must resolve"); + assert_eq!(result.pvp_kills, 0, "no unit should die in this exchange"); + assert_eq!( + state.players[0].units.len(), + 1, + "attacker must survive the exchange" + ); + assert_eq!( + state.players[1].units.len(), + 1, + "defender must survive the exchange" + ); + + // The fix: both survivors accumulate combat XP onto MapUnit::experience. + assert!( + state.players[0].units[0].experience > 0, + "attacker must gain XP from a survived combat, got {}", + state.players[0].units[0].experience + ); + assert!( + state.players[1].units[0].experience > 0, + "defender must gain XP from a survived combat, got {}", + state.players[1].units[0].experience + ); +}