diff --git a/src/simulator/crates/mc-player-api/src/projection.rs b/src/simulator/crates/mc-player-api/src/projection.rs index 636d3da3..c9f58d78 100644 --- a/src/simulator/crates/mc-player-api/src/projection.rs +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -373,13 +373,14 @@ fn project_units( // 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). + // has reached the threshold for its NEXT promotion level. The + // level is the count of already-applied promotions + // (`unit.promotions.len()`), so a maxed-out unit (level == + // max_promotion_level) correctly reports unavailable. experience: unit.experience, promotion_available: unit.pending_promotion.is_some() - || mc_combat::check_promotion(unit.experience, 0).is_some(), + || mc_combat::check_promotion(unit.experience, unit.promotions.len() as i32) + .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-turn/src/lib.rs b/src/simulator/crates/mc-turn/src/lib.rs index 18aa9b67..a9ad5cab 100644 --- a/src/simulator/crates/mc-turn/src/lib.rs +++ b/src/simulator/crates/mc-turn/src/lib.rs @@ -171,6 +171,168 @@ pub fn recharge_action_points(state: &mut game_state::GameState) { } } +/// Rail-1 Phase 1 (promotion completeness) — land every unit's pending +/// promotion pick. +/// +/// `mc_player_api::dispatch::apply_promote` (player + AI action) only *queues* +/// a pick by setting `MapUnit.pending_promotion`. This end-of-turn phase is the +/// single consumption site: for each unit with a queued pick it +/// +/// 1. validates the pick against the unit's already-taken `promotions` via +/// `mc_combat::validate_promotion_pick` (existence, no-dupes, requires-chain +/// + exclusions). An illegal pick is dropped (pending cleared, no mutation) — +/// a stale/invalid queue entry never corrupts the roster. +/// 2. on success pushes the id onto `promotions`, applies the promotion heal +/// (`mc_combat::heal_on_promote` of max_hp, clamped to max_hp), folds any +/// `hp_bonus` effect into `max_hp`, and clears `pending_promotion`. +/// +/// Wired in the end-turn step after combat XP is awarded so a pick queued the +/// same turn the unit became eligible still lands. Captive units are skipped +/// for symmetry with [`refresh_units`] (a pinned unit takes no actions). +pub fn consume_pending_promotions(state: &mut game_state::GameState) { + for player in &mut state.players { + for unit in &mut player.units { + if unit.captive_of.is_some() { + continue; + } + let Some(pick) = unit.pending_promotion.take() else { + continue; + }; + // Illegal pick → dropped (pending already cleared by take()). + if mc_combat::validate_promotion_pick(&pick, &unit.promotions).is_err() { + continue; + } + // Fold any flat max-hp bonus the new promotion grants (e.g. + // `reinforced`). Evaluated context-free — hp_bonus carries no + // condition in the JSON. + let mods = mc_combat::promotion_combat_modifiers( + std::slice::from_ref(&pick), + &mc_combat::PromotionCombatContext::default(), + ); + unit.promotions.push(pick); + if mods.hp_bonus != 0 { + unit.max_hp += mods.hp_bonus; + } + // Heal on promotion, clamped to (possibly raised) max_hp. + let heal = mc_combat::heal_on_promote(unit.max_hp); + unit.hp = (unit.hp + heal).min(unit.max_hp); + } + } +} + +#[cfg(test)] +mod promotion_consume_tests { + use super::*; + use crate::game_state::{GameState, MapUnit, PlayerState}; + + fn state_with_unit(unit: MapUnit) -> GameState { + let mut state = GameState::default(); + let mut p = PlayerState::default(); + p.units.push(unit); + state.players.push(p); + state + } + + #[test] + fn consumes_valid_pick_records_heals_clears() { + let mut state = state_with_unit(MapUnit { + unit_id: "dwarf_warrior".into(), + hp: 40, + max_hp: 60, + pending_promotion: Some("shock_i".into()), + ..MapUnit::default() + }); + + consume_pending_promotions(&mut state); + + let u = &state.players[0].units[0]; + assert_eq!(u.promotions, vec!["shock_i".to_string()]); + assert!(u.pending_promotion.is_none()); + // heal 30% of 60 = 18 → 40 + 18 = 58 (< max, no clamp). + assert_eq!(u.hp, 58); + } + + #[test] + fn heal_clamps_to_max_hp() { + let mut state = state_with_unit(MapUnit { + hp: 55, + max_hp: 60, + pending_promotion: Some("shock_i".into()), + ..MapUnit::default() + }); + consume_pending_promotions(&mut state); + assert_eq!(state.players[0].units[0].hp, 60); + } + + #[test] + fn hp_bonus_promotion_raises_max_hp() { + let mut state = state_with_unit(MapUnit { + hp: 30, + max_hp: 30, + pending_promotion: Some("reinforced".into()), + ..MapUnit::default() + }); + consume_pending_promotions(&mut state); + let u = &state.players[0].units[0]; + // reinforced: +5 max_hp → 35; heal 30% of 35 = 10 (round) → 30+10=40 clamp 35. + assert_eq!(u.max_hp, 35); + assert_eq!(u.hp, 35); + } + + #[test] + fn illegal_pick_is_dropped_without_mutation() { + // shock_ii requires shock_i, which the unit does not have. + let mut state = state_with_unit(MapUnit { + hp: 40, + max_hp: 60, + pending_promotion: Some("shock_ii".into()), + ..MapUnit::default() + }); + consume_pending_promotions(&mut state); + let u = &state.players[0].units[0]; + assert!(u.promotions.is_empty()); + assert!(u.pending_promotion.is_none()); + assert_eq!(u.hp, 40, "no heal on a dropped illegal pick"); + } + + #[test] + fn second_tier_pick_lands_after_prereq() { + let mut state = state_with_unit(MapUnit { + hp: 60, + max_hp: 60, + promotions: vec!["shock_i".into()], + pending_promotion: Some("shock_ii".into()), + ..MapUnit::default() + }); + consume_pending_promotions(&mut state); + assert_eq!( + state.players[0].units[0].promotions, + vec!["shock_i".to_string(), "shock_ii".to_string()] + ); + } + + #[test] + fn deterministic_same_state_same_result() { + let mk = || { + state_with_unit(MapUnit { + hp: 40, + max_hp: 60, + pending_promotion: Some("drill_i".into()), + ..MapUnit::default() + }) + }; + let mut a = mk(); + let mut b = mk(); + consume_pending_promotions(&mut a); + consume_pending_promotions(&mut b); + assert_eq!(a.players[0].units[0].hp, b.players[0].units[0].hp); + assert_eq!( + a.players[0].units[0].promotions, + b.players[0].units[0].promotions + ); + } +} + #[cfg(test)] mod ap_recharge_tests { use super::*; diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index fe4e0d1c..df897f42 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -68,6 +68,34 @@ fn equip_combat_bonus( (atk, def) } +/// Rail-1 Phase 1 — flat attack/defense/ranged bonuses a unit's applied +/// promotions contribute to a single combat, given the combat context. +/// +/// Mirrors [`equip_combat_bonus`]: the resolver works in flat strength points, +/// so the percentage modifiers from `promotion_combat_modifiers` are converted +/// into flat additions against the unit's *base* `attack` / `defense` / +/// `ranged_attack` (the same base the percentages describe in the JSON, e.g. +/// "+15% attack"). Returns `(atk, def, ranged_atk)` flat deltas. +/// +/// `base_ranged` is the unit's catalog ranged attack (0 for melee units), +/// passed in by the caller which already sources it from the units catalog. +fn promotion_combat_bonus( + unit: &mc_state::game_state::MapUnit, + base_ranged: i32, + ctx: &mc_combat::PromotionCombatContext, +) -> (i32, i32, i32) { + if unit.promotions.is_empty() { + return (0, 0, 0); + } + let m = mc_combat::promotion_combat_modifiers(&unit.promotions, ctx); + let atk = (unit.attack as f32 * m.attack_pct).round() as i32; + let def = (unit.defense as f32 * m.defense_pct).round() as i32; + // Ranged channel: percent-of-base-ranged plus any flat ranged bonus. + let ranged = + (base_ranged as f32 * m.ranged_attack_pct).round() as i32 + m.ranged_attack_flat; + (atk, def, ranged) +} + // ── PvP / Siege Constants ────────────────────────────────────────────────── /// Default seek range for non-rusher profiles. Units will move toward enemies @@ -692,6 +720,14 @@ impl TurnProcessor { } } + // Rail-1 Phase 1 — land queued promotion picks. Runs after the combat + // phases (5b/5c/5d) so a pick queued the same turn a unit became + // eligible (XP awarded above) still consumes. Validates each pick + // against the unit's existing promotions, records it, and applies the + // promotion heal + any hp_bonus. Single consumption site for + // `MapUnit::pending_promotion`. + crate::consume_pending_promotions(state); + // ── Derived stats recompute (SINGLE SITE — all future derived scalars land here) ── // // Must run last so all phase mutations (economy, siege, science, culture, @@ -2928,27 +2964,61 @@ impl TurnProcessor { } else { (0, 0) }; + // Rail-1 Phase 1 — promotion combat context. The attacker's offensive + // promotions key off ITS tile + the defender's flags; the defender's + // defensive promotions key off ITS tile + whether the incoming attack + // is ranged. PvP targets are units, never cities (`target_is_city` + // stays false; city siege is a separate path). + let attacker_biome = tile_biome(a_col, a_row, &state.grid); + let defender_biome = tile_biome(def_col, def_row, &state.grid); + let defender_in_city = state.players[defender_player] + .city_positions + .iter() + .any(|&(c, r)| c == def_col && r == def_row); let params = { let defender = &state.players[defender_player].units[defender_unit]; + let attacker_promo_ctx = mc_combat::PromotionCombatContext { + own_biome: attacker_biome.clone(), + in_city: false, + target_is_city: false, + target_is_fortified: defender.is_fortified, + target_is_ranged: false, + }; + let defender_promo_ctx = mc_combat::PromotionCombatContext { + own_biome: defender_biome.clone(), + in_city: defender_in_city, + target_is_city: false, + target_is_fortified: false, + target_is_ranged: is_ranged, + }; // 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); + // Rail-1 Phase 1: per-unit promotion bonuses (0 for un-promoted units). + let (a_pr_atk, _a_pr_def, a_pr_ranged) = promotion_combat_bonus( + &state.players[attacker_player].units[attacker_unit], + a_ranged_attack, + &attacker_promo_ctx, + ); + let (_d_pr_atk, d_pr_def, _d_pr_ranged) = + promotion_combat_bonus(defender, 0, &defender_promo_ctx); CombatParams { attacker: UnitStats { - hp: a_hp, max_hp: 60, attack: a_atk + a_eq_atk, + hp: a_hp, max_hp: 60, attack: a_atk + a_eq_atk + a_pr_atk, defense: a_def + a_eq_def, - // Ranged attackers add their equipped attack bonus onto the - // ranged channel (the resolver reads `ranged_attack` when - // `combat_type == Ranged`); melee keeps these zeroed. - ranged_attack: if is_ranged { a_ranged_attack + a_eq_atk } else { 0 }, + // Ranged attackers add their equipped + promotion attack + // bonus onto the ranged channel (the resolver reads + // `ranged_attack` when `combat_type == Ranged`); melee + // keeps these zeroed. + ranged_attack: if is_ranged { a_ranged_attack + a_eq_atk + a_pr_ranged } else { 0 }, range: a_range, movement: 2, }, defender: UnitStats { hp: defender.hp, max_hp: defender.max_hp, - attack: defender.attack + d_eq_atk, defense: defender.defense + d_eq_def, + attack: defender.attack + d_eq_atk, defense: defender.defense + d_eq_def + d_pr_def, ranged_attack: 0, range: 0, movement: 2, }, combat_type: if is_ranged { CombatType::Ranged } else { CombatType::Melee }, @@ -3770,18 +3840,47 @@ impl TurnProcessor { 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); + // Rail-1 Phase 1 — promotion bonuses (melee path). Attacker + // offence keys off its tile `(*uc,*ur)` + defender flags; + // defender defence keys off its own tile. Melee → the + // defender's vs_ranged Cover line does not fire. + let attacker_promo_ctx = mc_combat::PromotionCombatContext { + own_biome: tile_biome(*uc, *ur, &state.grid), + in_city: false, + target_is_city: false, + target_is_fortified: defender.is_fortified, + target_is_ranged: false, + }; + let defender_in_city = state.players[di] + .city_positions + .iter() + .any(|&(c, r)| c == defender.col && r == defender.row); + let defender_promo_ctx = mc_combat::PromotionCombatContext { + own_biome: tile_biome(defender.col, defender.row, &state.grid), + in_city: defender_in_city, + target_is_city: false, + target_is_fortified: false, + target_is_ranged: false, + }; + let (a_pr_atk, _a_pr_def, _a_pr_ranged) = promotion_combat_bonus( + &state.players[pi].units[*ui], + 0, + &attacker_promo_ctx, + ); + let (_d_pr_atk, d_pr_def, _d_pr_ranged) = + promotion_combat_bonus(defender, 0, &defender_promo_ctx); 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 + a_eq_atk, + attack: (*a_atk as f32 * a_formation_scale).round() as i32 + a_eq_atk + a_pr_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 + d_eq_atk, - defense: defender.defense + d_eq_def, ranged_attack: 0, range: 0, movement: 2, + defense: defender.defense + d_eq_def + d_pr_def, ranged_attack: 0, range: 0, movement: 2, }, combat_type: CombatType::Melee, attacker_keywords: Vec::new(),