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:
parent
66cf5b7e45
commit
e24c1a03d2
3 changed files with 275 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue