feat(@projects/@magic-civilization): ⛵ p3-18 P2 — auto-embark on move + embarked combat penalty
Civ-VI auto-embark model (owner choice): a land unit that ends its move on a water tile becomes embarked; stepping back onto land disembarks it — no explicit action needed. Wires the previously-dead is_embarked field + embarked_defence_penalty. - mc-pathfinding: pub is_water_at(grid, pos) helper. - mc-turn process_one_move: after a land unit moves, set MapUnit::is_embarked = (destination is water). The dragged escort protectee's embarked state is kept consistent with the tile it lands on too. Naval units never toggle (they belong on water). - mc-combat: CombatParams gains defender_is_embarked; resolve() halves the defender's defence via the canonical embarked_defence_penalty when set. The apply_attack caller passes the defender unit's is_embarked. So an army can cross water (P1 gate + tech) and is vulnerable while doing so (the Civ embarked rule), exactly as the half-built scaffolding intended. Tests: mc-combat embarked_defender_takes_more_damage (high-defence case so the 50% penalty clears damage-rounding); full mc-combat (146) + mc-turn (248) green, no regression (penalty only activates when embarked). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
27f4a4ea41
commit
10e99af962
3 changed files with 76 additions and 1 deletions
|
|
@ -201,6 +201,10 @@ pub struct CombatParams {
|
|||
pub defender_is_braced: bool,
|
||||
/// True when the defender is in ShieldWall posture (+50% def vs ranged).
|
||||
pub defender_shield_wall: bool,
|
||||
/// p3-18 — true when the defender is a land unit currently embarked on water.
|
||||
/// Its defence is halved ([`crate::siege::embarked_defence_penalty`]) — the
|
||||
/// Civ embarked-vulnerability rule.
|
||||
pub defender_is_embarked: bool,
|
||||
/// Rage turns remaining on the attacker (> 0 = +40% attack).
|
||||
pub attacker_rage_turns: u8,
|
||||
/// True when the attacker has aimed_shot_pending (ignores 50% of defender's defence modifier).
|
||||
|
|
@ -346,6 +350,7 @@ impl Default for CombatParams {
|
|||
attacker_charging: false,
|
||||
defender_is_braced: false,
|
||||
defender_shield_wall: false,
|
||||
defender_is_embarked: false,
|
||||
attacker_rage_turns: 0,
|
||||
attacker_aimed_shot: false,
|
||||
attacker_war_cry_debuff: false,
|
||||
|
|
@ -585,7 +590,15 @@ fn compute_predicted_damage(params: &CombatParams) -> PredictedDamage {
|
|||
|
||||
let attacker_strength = (atk_base * (1.0 + atk_mod) * wall_penalty).max(1.0);
|
||||
|
||||
let def_base = params.defender.defense as f32 + params.defender.attack as f32 * 0.5;
|
||||
// p3-18 — an embarked land unit (caught on water) fights at halved defence
|
||||
// (the Civ embarked-vulnerability rule). Apply the canonical penalty to the
|
||||
// defence stat before composing the base.
|
||||
let defender_defense = if params.defender_is_embarked {
|
||||
crate::siege::embarked_defence_penalty(params.defender.defense)
|
||||
} else {
|
||||
params.defender.defense
|
||||
};
|
||||
let def_base = defender_defense as f32 + params.defender.attack as f32 * 0.5;
|
||||
let def_mod = bonuses::total_defense_modifier(¶ms.defender_bonuses, ignore_terrain)
|
||||
+ keywords::keyword_defense_bonus(¶ms.defender_keywords, &def_kw_ctx);
|
||||
|
||||
|
|
@ -1119,6 +1132,40 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
// ── p3-18 embarked vulnerability ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn embarked_defender_takes_more_damage() {
|
||||
// High defender defence so the 50% embarked penalty is observable above
|
||||
// damage-rounding (a low-defence unit halves to the same rounded result).
|
||||
let base = CombatParams {
|
||||
attacker: UnitStats {
|
||||
hp: 100, max_hp: 100, attack: 30, defense: 10,
|
||||
ranged_attack: 0, range: 0, movement: 2,
|
||||
},
|
||||
defender: UnitStats {
|
||||
hp: 100, max_hp: 100, attack: 0, defense: 40,
|
||||
ranged_attack: 0, range: 0, movement: 2,
|
||||
},
|
||||
combat_type: CombatType::Melee,
|
||||
..Default::default()
|
||||
};
|
||||
let base_result = CombatResolver::resolve(&base);
|
||||
|
||||
let embarked = CombatParams {
|
||||
defender_is_embarked: true,
|
||||
..base
|
||||
};
|
||||
let embarked_result = CombatResolver::resolve(&embarked);
|
||||
|
||||
assert!(
|
||||
embarked_result.defender_damage > base_result.defender_damage,
|
||||
"embarked defender (halved defence) should take more damage: {} vs {}",
|
||||
embarked_result.defender_damage,
|
||||
base_result.defender_damage
|
||||
);
|
||||
}
|
||||
|
||||
// ── Ranged: no retaliation ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -253,6 +253,14 @@ pub fn find_path(
|
|||
Vec::new()
|
||||
}
|
||||
|
||||
/// p3-18 — whether the tile at `pos` is a water biome. Embarked land units sit
|
||||
/// on water; the move handler uses this to auto-toggle `MapUnit::is_embarked`
|
||||
/// after a move (Civ-VI auto-embark). Out-of-bounds / unknown tiles → `false`.
|
||||
#[inline]
|
||||
pub fn is_water_at(grid: &GridState, pos: HexCoord) -> bool {
|
||||
tile_biome_at(grid, pos).is_some_and(|b| has_tag(&b, BiomeTag::IsWater))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn tile_biome_at(grid: &GridState, pos: HexCoord) -> Option<String> {
|
||||
if pos.0 < 0 || pos.0 >= grid.width || pos.1 < 0 || pos.1 >= grid.height {
|
||||
|
|
|
|||
|
|
@ -2546,6 +2546,8 @@ impl TurnProcessor {
|
|||
defender_at_last_city,
|
||||
defender_cities_lost,
|
||||
defender_solo_city_grace_mult,
|
||||
// p3-18 — halve defence when the defender is embarked on water.
|
||||
defender_is_embarked: defender.is_embarked,
|
||||
// p2-55 civilian-capture surface.
|
||||
defender_capturable: cap_flag,
|
||||
posture_resolution: posture_res,
|
||||
|
|
@ -4844,10 +4846,21 @@ fn process_one_move(state: &mut GameState, req: &crate::game_state::MoveRequest)
|
|||
|
||||
// Apply.
|
||||
{
|
||||
// p3-18 — auto-embark (Civ-VI): a land unit that ends its move on a water
|
||||
// tile is embarked; stepping back onto land disembarks it. Drives the
|
||||
// embarked defence penalty in combat. Naval units are never "embarked"
|
||||
// (they belong on water), so only toggle for non-naval domains.
|
||||
let dest_is_water = matches!(domain, UnitDomain::Land)
|
||||
&& state
|
||||
.grid
|
||||
.as_ref()
|
||||
.map(|g| mc_pathfinding::is_water_at(g, target))
|
||||
.unwrap_or(false);
|
||||
let u = &mut state.players[req.player_idx].units[req.unit_idx];
|
||||
u.col = target.0;
|
||||
u.row = target.1;
|
||||
u.movement_remaining = (u.movement_remaining - cost).max(0);
|
||||
u.is_embarked = dest_is_water;
|
||||
}
|
||||
|
||||
// p2-59 — drag the linked protected unit into the escort's vacated tile.
|
||||
|
|
@ -4870,6 +4883,13 @@ fn process_one_move(state: &mut GameState, req: &crate::game_state::MoveRequest)
|
|||
f.col = from.0;
|
||||
f.row = from.1;
|
||||
f.movement_remaining = (f.movement_remaining - cost).max(0);
|
||||
// p3-18 — keep the dragged protectee's embarked state consistent
|
||||
// with the tile it lands on.
|
||||
f.is_embarked = state
|
||||
.grid
|
||||
.as_ref()
|
||||
.map(|g| mc_pathfinding::is_water_at(g, from))
|
||||
.unwrap_or(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue