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:
Natalie 2026-06-25 04:49:24 -04:00
parent 27f4a4ea41
commit 10e99af962
3 changed files with 76 additions and 1 deletions

View file

@ -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(&params.defender_bonuses, ignore_terrain)
+ keywords::keyword_defense_bonus(&params.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]

View file

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

View file

@ -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);
}
}
}