feat(turn): consume promotion picks + inject promotion modifiers into combat

Add consume_pending_promotions phase to the end-turn step (after combat XP):
validates each unit's pending_promotion against its applied promotions
(requires-chain, no-dupes, existence), records it, folds hp_bonus into max_hp,
applies heal_on_promote (clamped), clears the pick. Illegal picks are dropped.

Inject per-unit promotion combat modifiers at both PvP combat sites, mirroring
equip_combat_bonus: attacker offence keys off its tile biome + defender flags,
defender defence off its own tile + whether the attack is ranged. Percentages
fold into flat atk/def/ranged against base stats. Projection now gauges
promotion_available against the real level (promotions.len()).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 15:46:54 -04:00
parent 66cf5b7e45
commit e24c1a03d2
3 changed files with 275 additions and 13 deletions

View file

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

View file

@ -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::*;

View file

@ -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(),