From 3b118dd845af00765a10c5f9d19e034680326df8 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:02:04 +1000 Subject: [PATCH] Integrate Ecaz Duke revival and bounded Mirror Weapon battles --- components/battle-preparation.tsx | 5 +- components/game-table.tsx | 47 ++- docs/CURRENT_STATUS.md | 22 +- docs/DUKE_VIDAL_RULES.md | 12 + docs/ECAZ_DUKE_REVIVAL_READINESS.md | 9 + docs/ECAZ_REVIVAL_RULES.md | 28 +- docs/EFFECTIVE_BATTLE_WEAPONS.md | 14 +- docs/IMPLEMENTATION_STATUS.md | 37 +++ docs/MIRROR_WEAPON_ENGINE_AUDIT.md | 37 +-- docs/RULE_DECISIONS.md | 3 +- game/battle-cards.ts | 12 +- game/battle-resolution-quote.ts | 42 ++- game/bots.ts | 9 +- game/card-presentation.ts | 8 +- game/duke-vidal.ts | 2 +- game/ecaz-duke-revival.ts | 2 +- game/effective-weapons.ts | 115 +++++-- game/engine.ts | 311 +++++++++++------- game/introduction-resources.ts | 1 + game/mirror-weapon-mode.ts | 24 ++ game/reference.ts | 10 +- game/revival-cancellation.ts | 27 ++ game/revival-resume.ts | 94 +++--- game/revival.ts | 41 ++- game/richese-cards.ts | 5 +- game/stronghold-battle.ts | 15 +- tests/battle-resolution-quote.test.ts | 37 +++ tests/duke-vidal.test.ts | 32 -- tests/ecaz-duke-acquisition.test.ts | 68 +++- tests/ecaz-duke-revival-recovery.test.ts | 113 +++++++ tests/ecaz-duke-revival.test.ts | 176 +++++++++- tests/ecaz-poison-income-engine.test.ts | 6 +- tests/effective-battle-effects.test.ts | 28 +- tests/effective-weapons.test.ts | 88 ++++- tests/mirror-weapon-engine.test.ts | 181 ++++++++++ ...rdinary-card-discard-continuations.test.ts | 9 +- tests/reference.test.ts | 29 -- tests/richese-card-presentation.test.ts | 177 +--------- tests/stone-burner-review.test.ts | 34 ++ 39 files changed, 1377 insertions(+), 533 deletions(-) create mode 100644 game/mirror-weapon-mode.ts create mode 100644 tests/ecaz-duke-revival-recovery.test.ts create mode 100644 tests/mirror-weapon-engine.test.ts diff --git a/components/battle-preparation.tsx b/components/battle-preparation.tsx index 71d5a379..9f366084 100644 --- a/components/battle-preparation.tsx +++ b/components/battle-preparation.tsx @@ -5,6 +5,7 @@ import { battleCardLabel, isWeaponCard, isStoneBurner, + isMirrorWeapon, isDefenseCard, VOICE_KINDS, } from '@/game/battle-cards'; @@ -20,6 +21,7 @@ import { type BattleCardSlot, } from '@/game/battle-card-slots'; import { canUsePlanetologistBattleSpecial } from '@/game/leader-skill-combat'; +import { mirrorWeaponModeBlock } from '@/game/mirror-weapon-mode'; /** Both selectors evaluate the complete physical pair, in either selection order. */ export function battlePlanCardOptions(game: GameView, slot: BattleCardSlot, leader: string, weapon: string, defense: string) { @@ -34,6 +36,7 @@ export function battlePlanCardOptions(game: GameView, slot: BattleCardSlot, lead }); return (card.id !== 'ecaz-reinforcements' || (!!game.battle?.reinforcements && !game.battle.reinforcements.blocked)) && (card.id !== 'ecaz-harass-withdraw' || (!!game.battle?.harassWithdraw && !game.battle.harassWithdraw.blocked)) && + (!isMirrorWeapon(card) || !mirrorWeaponModeBlock(game)) && battleCardSlotEligible(slot, card, { planetologistWeapon }) && (!fixed || fixedBattleInspectionMatches(slot, fixed.value, card.id, card)) && validBattleSlotPair(w, d, planetologistWeapon); @@ -154,7 +157,7 @@ export function BattlePreparation({ value={kind} onChange={(e) => setKind(e.target.value)} > - {VOICE_KINDS.map((k) => ( + {VOICE_KINDS.filter((name) => name !== 'mirrorWeapon' || !mirrorWeaponModeBlock(game)).map((k) => ( diff --git a/components/game-table.tsx b/components/game-table.tsx index 3b7b4363..4017e0dd 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -141,9 +141,11 @@ import { CHEAP_HERO_TRAITOR, matchingTraitor } from '@/game/traitors'; import { battleCardLabel, isStoneBurner, + isMirrorWeapon, } from '@/game/battle-cards'; import { validBattleSlotPair } from '@/game/battle-card-slots'; import { stoneBurnerPlanBlock } from '@/game/stone-burner'; +import { mirrorWeaponModeBlock } from '@/game/mirror-weapon-mode'; import { TECH_TOKENS, ownedTech } from '@/game/tech-tokens'; import { canUseAsKaramaRole, @@ -518,15 +520,14 @@ export function GameTable({ selectedWeaponId === 'ecaz-harass-withdraw' || selectedDefenseId === 'ecaz-harass-withdraw', committed.dial ? Number(committed.dial.value) : battleDial, me.faction === 'fremen' && !g.battle?.fremenSupportBlocked ? 0 : battleSupport); - const selectedStone = me.hand?.find( - (c) => - c.id === selectedWeaponId && - isStoneBurner(c), + const selectedSpecialWeapon = me.hand?.find( + (c) => c.id === selectedWeaponId && (isStoneBurner(c) || isMirrorWeapon(c)), ); + const mirrorSelected = isMirrorWeapon(selectedSpecialWeapon); const stoneContext = g.battle?.stoneBurnerContext; - const stonePlanReason = !selectedStone + const stonePlanReason = !selectedSpecialWeapon ? null - : (stoneContext?.blocked ?? + : ((mirrorSelected ? mirrorWeaponModeBlock(g) : null) ?? stoneContext?.blocked ?? (!ownBattleForces || !stoneContext?.opponentPools.length ? 'Stone Burner needs a supported physical force pool.' : (stoneContext.opponentPools @@ -2296,7 +2297,7 @@ export function GameTable({ : g.decision.kind === 'ecazSpice' ? 'Shared spice collection' : g.decision.kind === 'stoneBurner' - ? 'Stone Burner' + ? g.decision.copiedFrom ? 'Mirror Weapon copying Stone Burner' : 'Stone Burner' : g.decision.kind === 'nullentropy' ? 'Private Nullentropy Box search' : g.decision.kind === 'richeseAllyOpportunity' @@ -2695,7 +2696,7 @@ export function GameTable({ /> ) : g.decision.kind === 'stoneBurner' ? ( <> -

Choose Stone Burner’s effect

+

Choose {g.decision.copiedFrom ? 'Mirror Weapon’s copied Stone Burner' : 'Stone Burner'} effect

Choose whether both leaders die or surviving leaders contribute no strength. Both choices compare undialed @@ -2703,6 +2704,7 @@ export function GameTable({ comparison. Other weapon effects and traitor precedence still apply.

+ {g.decision.copiedFrom &&

Your physical Mirror Weapon copied the opposing Stone Burner. Your choice is independent of its owner’s; winning retains Mirror as Mirror.

} Read Stone Burner rules @@ -2729,6 +2731,7 @@ export function GameTable({ ) : g.decision.kind === 'poisonTooth' ? ( <> + {g.decision.copiedFrom &&

Your physical Mirror Weapon copied the opposing Poison Tooth. Choose its activation independently; a winning Mirror remains Mirror.

}

Choose after reviewing both plans. Activating attacks both leaders, including yours. Chemistry as a defense protects; @@ -2736,10 +2739,12 @@ export function GameTable({

{actionButton('Activate Poison Tooth', { type: 'decision', + event: g.decision.event, activate: true, })} {actionButton('Leave it unused', { type: 'decision', + event: g.decision.event, activate: false, })} @@ -3953,6 +3958,7 @@ export function GameTable({ {g.revival.leaders.map((choice) => (