From 10e99af962221431aedd818c73facbabdccadc51 Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 25 Jun 2026 04:49:24 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9B=B5=20p3-18=20P2=20=E2=80=94=20auto-embark=20on=20move=20?= =?UTF-8?q?+=20embarked=20combat=20penalty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../crates/mc-combat/src/resolver.rs | 49 ++++++++++++++++++- .../crates/mc-pathfinding/src/lib.rs | 8 +++ src/simulator/crates/mc-turn/src/processor.rs | 20 ++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/simulator/crates/mc-combat/src/resolver.rs b/src/simulator/crates/mc-combat/src/resolver.rs index 71184ae8..38efa9c3 100644 --- a/src/simulator/crates/mc-combat/src/resolver.rs +++ b/src/simulator/crates/mc-combat/src/resolver.rs @@ -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] diff --git a/src/simulator/crates/mc-pathfinding/src/lib.rs b/src/simulator/crates/mc-pathfinding/src/lib.rs index 80c446b8..a2880b54 100644 --- a/src/simulator/crates/mc-pathfinding/src/lib.rs +++ b/src/simulator/crates/mc-pathfinding/src/lib.rs @@ -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 { if pos.0 < 0 || pos.0 >= grid.width || pos.1 < 0 || pos.1 >= grid.height { diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index f8d58e0b..2a05bc32 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -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); } } }