feat(@projects/@magic-civilization): 🛤️ Rail-1 Phase-1 — bench unit XP/veterancy in the Rust turn

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 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 08:48:35 -04:00
parent 081cddcab3
commit bd186b162a
4 changed files with 147 additions and 4 deletions

View file

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

View file

@ -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<String>,
/// 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

View file

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

View file

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