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:
parent
081cddcab3
commit
bd186b162a
4 changed files with 147 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
112
src/simulator/crates/mc-turn/tests/pvp_xp_award.rs
Normal file
112
src/simulator/crates/mc-turn/tests/pvp_xp_award.rs
Normal 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
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue