From 23ce40687276d2e3dd24c2eed4c53eaa760ad39d Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:07:27 +1000 Subject: [PATCH] Connect bounded Reinforcements Battle Plan --- components/battle-preparation.tsx | 2 +- components/game-table.tsx | 22 +++- docs/CURRENT_STATUS.md | 17 ++- docs/ECAZ_TREACHERY_RULES.md | 16 +++ docs/HARASS_WITHDRAW_RUNTIME.md | 7 +- docs/IMPLEMENTATION_STATUS.md | 29 +++++ docs/REINFORCEMENTS_RUNTIME.md | 11 ++ docs/RULE_DECISIONS.md | 2 +- game/battle-resolution-quote.ts | 35 +++++- game/bots.ts | 10 +- game/engine.ts | 77 ++++++++++++- game/reference.ts | 30 ++++- game/reinforcements.ts | 31 ++++++ tests/ecaz-battle-controls.test.tsx | 4 +- tests/harass-withdraw.test.ts | 12 +- tests/reinforcements-controls.test.tsx | 111 +++++++++++++++++++ tests/reinforcements-engine.test.ts | 131 ++++++++++++++++++++++ tests/reinforcements-recovery.test.ts | 88 +++++++++++++++ tests/reinforcements.test.ts | 145 +++++++++++++++++++++++++ 19 files changed, 745 insertions(+), 35 deletions(-) create mode 100644 docs/REINFORCEMENTS_RUNTIME.md create mode 100644 game/reinforcements.ts create mode 100644 tests/reinforcements-controls.test.tsx create mode 100644 tests/reinforcements-engine.test.ts create mode 100644 tests/reinforcements-recovery.test.ts create mode 100644 tests/reinforcements.test.ts diff --git a/components/battle-preparation.tsx b/components/battle-preparation.tsx index 4cfab7f4..71d5a379 100644 --- a/components/battle-preparation.tsx +++ b/components/battle-preparation.tsx @@ -32,7 +32,7 @@ export function battlePlanCardOptions(game: GameView, slot: BattleCardSlot, lead assignments: game.leaderSkills?.assignments.filter(a => a.controller === game.me) ?? [], selectedLeader: leader, card: w, }); - return card.id !== 'ecaz-reinforcements' && + return (card.id !== 'ecaz-reinforcements' || (!!game.battle?.reinforcements && !game.battle.reinforcements.blocked)) && (card.id !== 'ecaz-harass-withdraw' || (!!game.battle?.harassWithdraw && !game.battle.harassWithdraw.blocked)) && battleCardSlotEligible(slot, card, { planetologistWeapon }) && (!fixed || fixedBattleInspectionMatches(slot, fixed.value, card.id, card)) && diff --git a/components/game-table.tsx b/components/game-table.tsx index 5996a51c..711b0172 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -494,6 +494,15 @@ export function GameTable({ const selectedDefenseId = battlePlanCardValue(g, 'defense', defense); const selectedBattleWeapon = me.hand?.find(c => c.id === selectedWeaponId); const selectedBattleDefense = me.hand?.find(c => c.id === selectedDefenseId); + const reinforcementsSelected = selectedWeaponId === 'ecaz-reinforcements' || + selectedDefenseId === 'ecaz-reinforcements'; + const reinforcementsPlanReason = !reinforcementsSelected ? null : + !g.battle?.reinforcements ? 'Reinforcements is unavailable in this battle.' : + g.battle.reinforcements.blocked ?? + ((selectedBattleWeapon?.id === 'ecaz-harass-withdraw' || + selectedBattleDefense?.id === 'ecaz-harass-withdraw' || + (!!selectedBattleWeapon && isStoneBurner(selectedBattleWeapon))) + ? 'Reinforcements cannot share this plan with Harass & Withdraw or Stone Burner.' : null); const selectedBattleKwisatz = kwisatz && !!me.kwisatz?.active && !me.kwisatz.dead && !g.battle?.kwisatzBlocked && (!me.kwisatz.usedAt || me.kwisatz.usedAt === g.battle?.territory); @@ -4819,6 +4828,17 @@ export function GameTable({ ))} + {g.battle.reinforcements && ( +

+ Reinforcements may occupy either card slot but is neither a weapon nor a defense. + It adds 2 to battle strength without changing the physical dial or spice support. + The cost is three reserve forces ({g.battle.reinforcements.normal} normal,{' '} + {g.battle.reinforcements.elite} elite) sent to the Tanks on any revealed outcome; + the card is discarded even after a traitor call or explosion. + {g.battle.reinforcements.blocked && ` ${g.battle.reinforcements.blocked}`} +

+ )} + {reinforcementsPlanReason &&

{reinforcementsPlanReason}

} {!battlePairValid &&

Choose two compatible battle cards. A single physical card cannot fill both slots.

} {selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon) && (

@@ -4902,7 +4922,7 @@ export function GameTable({ weapon, defense, }), - !!stonePlanReason || !!harassControl.blocked || !!smugglerPlanReason || !battlePairValid, + !!stonePlanReason || !!harassControl.blocked || !!smugglerPlanReason || !!reinforcementsPlanReason || !battlePairValid, )} ) : ( diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 1efd660f..ca78ee8f 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -70,6 +70,16 @@ bulk actions and backup/operations tools remain unfinished. The bounded [native Richese Nexus Cunning shipment](NEXUS_RICHESE_RULES.md#bounded-native-cunning-prototype-27-september-2026) now uses two distinct physical No-Field tokens in one priced shipment in genuine Richese/CHOAM paired Nexus games. One token immediately reveals a reserve-capped public force group; the other remains concealed. Owner-only controls, four legal AI profiles, Karama/Guild responses, signed saved receipts and concurrent in-memory SQLite settlement are connected in Basic/Advanced. A read-only local browser fixture displayed the owner selector and no rival private panel. Same-shipment nonrepeat is a documented product interpretation; faceup-component artwork, combined modules, Betrayal and full Nexus acceptance remain gated. This code is local and not in the last verified production image. +The independent [Reinforcements Battle Plan prototype](REINFORCEMENTS_RUNTIME.md) +connects its physical card in either slot for Basic/Advanced classic-faction +games without other optional modules. Three own reserve counters enter the +Tanks, +2 affects normal battle score only, and the card is discarded on all +revealed outcomes. Owner-only availability, controls, legal AI, private +inspection and saved continuation are connected. Traitor/explosion payment, +normal-first elite selection and battle-loss accounting remain explicitly +provisional interpretations, not a publisher or user ruling; full variant and +public mode gates remain closed. This source is not in the live image. + 5,408 offline tests, 52 HTTP tests, build, twenty-two administrator HTTP groups and desktop/mobile browser acceptance passed. Deployment delivery was unconfirmed at that checkpoint; the current production revision is recorded @@ -213,8 +223,9 @@ four AI profiles. New battles also have [revealed physical allocation](HARASS_AL ordinary/elite mixtures and multi-sector returns. Richese card combinations and additional optional modules stay gated. Withdrawal before explosion and card-specific discard precedence are explicitly labeled inferences. The Stone -Burner timing question is pending; no user answer is assumed. Reinforcements -remains unfinished. This does not open variant, mode or publication gates. +Burner timing question is pending; no user answer is assumed. The separate +Reinforcements prototype has its own provisional timing and cost boundary. +Neither prototype opens variant, mode or publication gates. The preceding [early Sapho aggressor](SAPHO_AGGRESSOR.md) connects the printed battle-priority effect during the existing shared pre-plan opportunity. @@ -265,7 +276,7 @@ These links define bounded working behavior, not complete module certification. | Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Orgiz transfers, Jacurutu income, Testing Station and Shrine. Orgiz's per-deposit interpretation, contested/shared benefits and mixed physical allocation remain provisional or pending. The Orgiz path in current source is not yet deployed. | | Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Secret Ally trade and bounded after-victory inspection](NEXUS_CHOAM_SECRET_ALLY.md), [Moritani Secret Ally losing-card retention](NEXUS_MORITANI_RULES.md#secret-ally-losing-card-retention-prototype), [Ecaz private yes/no traitor inquiry](NEXUS_ECAZ_RULES.md#bounded-secret-ally-traitor-inquiry-runtime) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected. Uniform CHOAM winner/Moritani loser windows and Ecaz inquiry scope/audience are explicit product inferences, not publisher rulings; Emperor purchase remains pending. A source audit or helper alone is not a completed effect. | | Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. | -| Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. [Recruits](RECRUITS_RUNTIME.md) connects clean Revival play; [Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md) connects the bounded battle return in the same independent three-card preview. Richese card contracts and the checklist identify other connected effects and explicit gaps. | +| Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. [Recruits](RECRUITS_RUNTIME.md) connects clean Revival play; [Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md) and [Reinforcements](REINFORCEMENTS_RUNTIME.md) connect bounded battle effects in the same independent three-card preview. Richese card contracts and the checklist identify other connected effects and explicit gaps. | | Multiplayer | [Public and private discussion](TABLE_DISCUSSION.md), saved rooms/seats, [recovery](../README.md#saved-seats-and-reconnecting), uncertain-request retry, [own-seat AI](AUTOPILOT.md), [named-player AI permission](SEAT_AI_PERMISSION.md) and [voluntary seat handover](SEAT_HANDOVER.md) have connected controls and recovery evidence. | ## Remaining readiness diff --git a/docs/ECAZ_TREACHERY_RULES.md b/docs/ECAZ_TREACHERY_RULES.md index b4170f3f..c717e7c6 100644 --- a/docs/ECAZ_TREACHERY_RULES.md +++ b/docs/ECAZ_TREACHERY_RULES.md @@ -117,3 +117,19 @@ toward the Advanced Atreides seven-loss threshold, or the relative order when the same plan also plays Harass & Withdraw. Those combinations remain explicit boundaries rather than inferred rules. No disconnected quote is recorded as a completed card effect. + +## 27 September bounded Reinforcements prototype + +The earlier cost review remains an unresolved source question, not a user +ruling. [The connected runtime](REINFORCEMENTS_RUNTIME.md) uses the printed +three-reserve transfer and +2 modifier in classic-faction Basic/Advanced games +with the independent three-card variant and no other optional modules. The +normal score is separate from physical dial/support/casualties; three ordinary +reserves are used first, with elite reserves supplying any shortfall. The +transfer counts as battle losses and applies even to a sole successful Traitor +caller, an opposing/mutual Traitor outcome or an explosion, before board +casualties. Those choices are **provisional product interpretations**, not +publisher adjudications or an answer to the pending user question. The card +cannot share a plan with Harass & Withdraw or Stone Burner until those +combinations are resolved. The earlier “remains disabled” statement describes +the 14 September snapshot rather than this bounded current prototype. diff --git a/docs/HARASS_WITHDRAW_RUNTIME.md b/docs/HARASS_WITHDRAW_RUNTIME.md index 60ff2848..c54e9936 100644 --- a/docs/HARASS_WITHDRAW_RUNTIME.md +++ b/docs/HARASS_WITHDRAW_RUNTIME.md @@ -78,8 +78,11 @@ This is a legal strategy path, not strength calibration. Richese remains eligible. This is a development boundary, not a printed ban. - Co-present reciprocal Ecaz allied armies on either side reject before sealing; other faction integration gates remain in force. This prototype does not settle Ecaz's undialed allied support allocation. -- Reinforcements remains unavailable pending its separate cost and outcome - accounting. Neither slot recognition nor card text proves its effect complete. +- [Bounded Reinforcements](REINFORCEMENTS_RUNTIME.md) can appear in the + opposing Battle Plan in classic-faction Basic/Advanced games. The two cards + cannot share one player's plan; their relative same-plan timing remains + unresolved. Both prototypes retain their separate provisional cost and + outcome interpretations. ## Verification and preservation diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 0854f393..4d037788 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,34 @@ # Dune implementation status +## 27 September 2026 — Bounded Reinforcements Battle Plan + +[The independent three-card runtime](REINFORCEMENTS_RUNTIME.md) now permits +the physical Reinforcements card in either Battle Plan slot for Basic/Advanced +classic-faction games without other optional modules. A normal battle score +gains two without changing the dial, support or on-board casualties; exactly +three own ordinary-first reserve counters transfer to typed Tanks and count as +battle losses at settlement, even for traitor/explosion outcomes. Mandatory +discard, private category-None inspections, owner-only controls, four minimal +legal AI profiles and JSON/SQLite saved continuation are connected. The +normal-first choice, all-outcome payment and accounting are provisional +product interpretations pending the recorded user/source ruling, not complete +variant certification. Same-plan Harass/Stone Burner and optional modules +remain gated; an opposing Harass plan is supported. Independent rules and +privacy reviews found and prompted repair of a revealed-plan deadlock against +opposing Harass. + +Fourteen focused Reinforcements cases and 27 focused Harass cases passed, +including Basic/Advanced physical custody, rival privacy, rejected-action +immutability and concurrent in-memory SQLite CAS/restart. Read-only +intercepted local browser views displayed the owner card in both physical +selectors, cost guidance and no duplicate card after selection; the rival +had no private card or cost offer. No room write occurred. Project-wide +`npm run check` stops at the unrelated in-progress Fremen Nexus draft +`Player.fremenNexusMovementBlockedTurn` diagnostic; isolated project +TypeScript excluding only that draft and scoped lint passed. The final frozen +offline suite passed 5,595/5,595 cases across 704 files; the app production +build passed. Production still runs the earlier verified `c9a3625` image. + ## 27 September 2026 — Bounded native Richese Nexus Cunning pair The [Richese source contract](NEXUS_RICHESE_RULES.md#bounded-native-cunning-prototype-27-september-2026) now has one usable two-No-Field shipment in genuine Richese/CHOAM paired Nexus games without other optional modules. Two distinct nonrepeating physical tokens ship at the one-marker price; one immediately reveals into reserve-capped public normal forces and the other remains the sole concealed marker. The native Karama cancellation leaves resources, tokens and shipment allowance untouched while spending the card; Advanced Guild special prevention spends the attempted allowance without placing tokens or charging spice. Owner-only controls, four minimal legal bot paths, public value/force log and private concealed custody are connected. A read-only local browser fixture displayed the owner pair selector; the rival view had no private panel. Same-shipment `lastShipped` handling is a product interpretation, not a published FAQ answer. diff --git a/docs/REINFORCEMENTS_RUNTIME.md b/docs/REINFORCEMENTS_RUNTIME.md new file mode 100644 index 00000000..873e04f8 --- /dev/null +++ b/docs/REINFORCEMENTS_RUNTIME.md @@ -0,0 +1,11 @@ +# Reinforcements battle prototype + +The separate Ecaz Treachery Cards variant shuffles one physical Reinforcements card independently of choosing the Ecaz faction. In a Basic or Advanced game with only classic factions and no other optional modules, its holder may commit it in either Battle Plan card slot. It is neither a weapon nor a defense: Prescience of a category receives **None**, while the card's physical slot remains occupied and hidden until reveal. A named-card inspection still fixes the exact card. Harass & Withdraw and Stone Burner cannot share this bounded plan; Homeworld and other optional-module combinations remain gated. + +The holder must have three own reserve counters when sealing the plan. The owner-only battle offer displays availability and the exact provisional cost. The current prototype deterministically takes ordinary reserves first, then elite reserves if needed, transferring precisely three physical counters to the corresponding Tanks subpools when the battle settles. There is no spice payment or on-board arrival. The +2 changes the calculated normal-outcome battle score, **not** the committed physical dial, support payment, casualty count or leader survival. The card is discarded after any revealed result, including an own, opposing or mutual Traitor call and Lasgun/Shield explosion. The cost is likewise paid on each of those outcomes. A successful Traitor call still supersedes the normal score; the modifier never overrides it. Public history records the transfer; the sealed card and owner reserve mix stay private until the reveal. JSON-restored decisions and transactional room updates cannot charge the same cost twice. + +**Provisional interpretations, not publisher rulings:** The card says “increase the dialed number by 2” and transfer three reserves, but no retrieved combined ruling answers whether a sole successful Traitor caller instead keeps their reserves, how the transfer orders against an explosion, whether +2 adds Advanced support or physical losses, which reserve types the holder may select, or whether the three spent reserves count as battle losses for Atreides. For this bounded prototype, payment is at battle settlement before on-board casualties on every outcome; the engine counts the three as battle losses, chooses normal-first rather than offering a type selection, and keeps the score bonus separate from physical dial. [The source review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) records the competing reading and pending user question. These choices are visible so they can be revised without changing prior hands or inventing hidden information. + +The owner receives a card-slot explanation and disabled reason when fewer than three reserve counters remain or the bounded mode is unsupported. All four AI profiles generate only legal candidates and can select either slot, including an Advanced plan. The current scoring is a minimal legal participation heuristic, not difficulty calibration. The prototype does not certify the whole three-card variant, other faction combinations, optional modules, historical-save compatibility or public mode starts. + +Focused rule, engine, controls and in-memory SQLite tests cover both slots, Basic/Advanced payment, elite subpool conservation, traitor/explosion disposal, private sealed views, JSON restoration, rejected-action immutability and competing final room commits. The test results and actual browser/UI evidence for the current checkpoint are recorded in [Implementation status](IMPLEMENTATION_STATUS.md); test coverage is not a publisher ruling. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 26e601a4..ca4ae26d 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -63,7 +63,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t | Boundary | Still unresolved | Existing record | | --- | --- | --- | | Bureaucrat / Emperor gifts | Whether an allied gift is a qualifying payment and whether its threshold may be revealed. Existing gift amounts are private to payer/recipient; the card and retrieved primary sources do not explicitly settle this combination. The user question is pending; no answer or exact-amount disclosure is assumed. | [Gift classification review](BUREAUCRAT_PAYMENTS.md#21-september-gift-classification-review) | -| Reinforcements cost | A user question asks whether to keep the card disabled, always transfer three reserve forces on every outcome, or waive only a sole traitor-call winner's cost. No answer is assumed. Advanced Atreides loss accounting and same-plan Harass ordering are separate unresolved combinations. Eligibility must not depend on concealed matching Traitors. | [Fresh cost review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) | +| Reinforcements cost | The user question still asks whether to keep the card disabled, always transfer three reserve forces on every outcome, or waive only a sole traitor-call winner's cost; no answer is assumed. The bounded classic-faction prototype provisionally charges on all revealed outcomes, chooses normal reserves before elite and counts the transfer as battle losses. Those are product interpretations, not a ruling. Same-plan Harass/Stone Burner remain gated; opposing Harass is supported. Eligibility never depends on concealed matching Traitors. | [Source cost review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) · [Bounded runtime](REINFORCEMENTS_RUNTIME.md) | | Harass / Stone Burner | A user question has been sent: keep the combination gated or compare forces remaining after withdrawal. No answer is assumed. The Richese card-family configuration is guarded publicly; hidden Stone Burner custody never controls eligibility. Ambiguous physical allocations and other optional modules remain unfinished. | [Current boundaries](HARASS_WITHDRAW_RUNTIME.md#current-boundaries) | | Recruits late accounting and rates | A user question has been sent for two free plus one paid Atreides return before Recruits: keep the disputed case gated, or one/two further free returns without refund. No answer is assumed. Late-changing free rates, pending repricing and same-turn recovered-card replay also stay gated. The connected clean-case preview retains the independent seven-force cap after faction-advantage cancellation as an explicit inference, not a direct combined FAQ. | [Source audit](RECRUITS_RULES.md), [runtime scope](RECRUITS_RUNTIME.md) | | Sandmaster / native HMS relocation | No official source located establishes that passengers remaining inside the HMS enter outside territories for this skill. Extra collection eligibility is pending a user ruling; native sector collection and ordinary force entry/exit remain available. | [Source comparison and question](SANDMASTER_MOVEMENT.md#21-september-native-hms-relocation-eligibility) | diff --git a/game/battle-resolution-quote.ts b/game/battle-resolution-quote.ts index 521cbb3f..7472e3da 100644 --- a/game/battle-resolution-quote.ts +++ b/game/battle-resolution-quote.ts @@ -33,6 +33,7 @@ import { HARASS_WITHDRAW_CARD, isHarassWithdraw, quoteHarassWithdraw, type Haras import { auditCount } from './choam-auditor'; import { sukGraduateSkill, type SukGraduateSkill } from './suk-graduate'; import { validateSpiceBankerSpend } from './spice-banker'; +import { isReinforcements, quoteReinforcements, REINFORCEMENTS_CARD } from './reinforcements'; import { rihaniVictorySkill, type RihaniSkill } from './rihani-decipherer'; import { copiedDiplomatDefense, quoteDiplomatDefense, type DiplomatDefenseQuote } from './diplomat-defense'; import { @@ -75,6 +76,8 @@ export type ResolutionParticipant = { noFieldAtTerritory?: boolean; }; export type ResolutionCombatant = ResolutionParticipant & { + /** Own physical reserve counters available before battle casualties. */ + reinforcementsReserves?: { normal: number; elite: number }; harassWithdraw?: HarassWithdrawContext; harassSelection?: HarassWithdrawSelection; diplomatDefense?: Pick & { card: string }; @@ -133,6 +136,8 @@ export type BattleSupportPayment = { freeByTraitor: boolean; }; export type BattleResolutionQuote = { + /** Reserve-to-Tanks costs apply to every revealed outcome, including traitors and explosions. */ + reinforcements?: { player: string; card: typeof REINFORCEMENTS_CARD; normal: number; elite: number }[]; harassWithdraw?: (HarassWithdrawQuote & { player: string; card: typeof HARASS_WITHDRAW_CARD })[]; result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion'; winner: string | null; @@ -372,10 +377,30 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote { }; const effectiveAd = effectiveDefense(a, d, ad), effectiveDd = effectiveDefense(d, a, dd); const withdrawals = new Map(); + const reinforcements: NonNullable = []; for (const side of [a, d]) { const slots = [card(side, side.plan.weapon), card(side, side.plan.defense)]; - requireQuote(!slots.some(c => c?.id === 'ecaz-reinforcements'), - 'Reinforcements battle effects are still being implemented.'); + const selectedIds = [side.plan.weapon, side.plan.defense, side.plan.leader, side.lateDefense]; + requireQuote(selectedIds.every((selectedId) => + selectedId !== REINFORCEMENTS_CARD || slots.some(c => c?.id === selectedId && isReinforcements(c))), + 'Reinforcements must be a canonical physical card in exactly one battle-card slot.'); + const selected = slots.filter(isReinforcements); + requireQuote(selected.length <= 1 && (selected.length === 0) === (side.reinforcementsReserves === undefined), + 'Reinforcements requires its own reserve counts exactly when selected.'); + if (selected.length) { + requireQuote(validBattleSlotPair(slots[0], slots[1]) && + !(side === d && reinforcements.length), + 'Reinforcements needs a unique physical card and valid weapon and defense slot pair.'); + requireQuote(!input.homeworld && !(side.leaderSkills?.length || (side === a ? d : a).leaderSkills?.length) && + !slots.some(isHarassWithdraw) && + !isStoneBurner(aw) && !isStoneBurner(dw), + 'Reinforcements cannot combine with Homeworld, leader skills, own Harass & Withdraw or Stone Burner.'); + const reserves = side.reinforcementsReserves!; + requireQuote(!!reserves && typeof reserves === 'object' && !Array.isArray(reserves), + 'Reinforcements needs own typed reserves.'); + const cost = quoteReinforcements(reserves.normal, reserves.elite); + reinforcements.push({ player: side.id, card: REINFORCEMENTS_CARD, normal: cost.normal, elite: cost.elite }); + } const harass = slots.some(isHarassWithdraw); requireQuote((!side.harassWithdraw && side.harassSelection === undefined) || harass, 'The withdrawal context requires the physical Harass & Withdraw card.'); if (harass) { @@ -608,6 +633,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote { scores = { attacker: a.plan.dial - attackerPenalty + + (reinforcements[0]?.player === a.id ? 2 : 0) + (a.id === homeworld?.native ? homeworld.strength : 0) + (deaths.attacker || effects.stunned ? 0 @@ -616,6 +642,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote { (a.plan.kwisatz ? 2 : 0)), defender: d.plan.dial - defenderPenalty + + (reinforcements[0]?.player === d.id ? 2 : 0) + (d.id === homeworld?.native ? homeworld.strength : 0) + (deaths.defender || effects.stunned ? 0 @@ -755,7 +782,8 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote { // The explicit after-use instruction also applies to a successful own traitor // call, using the same specificity inference as Planetologist's discard. const mandatorySkillDiscard = (side: ResolutionCombatant, selected: string) => - mandatoryPlanetologistDiscard(side, selected) || side.diplomatDefense?.card === selected || isHarassWithdraw(card(side, selected)); + mandatoryPlanetologistDiscard(side, selected) || side.diplomatDefense?.card === selected || + isHarassWithdraw(card(side, selected)) || isReinforcements(card(side, selected)); const moritani = input.participants.find((p) => p.faction === 'moritani'); if (winner && (moritani || input.nexusMoritani)) { const loser = winner === a ? d : a, @@ -837,6 +865,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote { : []; return { ...(harassWithdraw.length ? { harassWithdraw } : {}), + ...(reinforcements.length ? { reinforcements } : {}), result, winner: winner?.id ?? null, attackerTraitor: ac, diff --git a/game/bots.ts b/game/bots.ts index 92292897..8f917158 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -699,7 +699,8 @@ function plans(g: GameView): Action[] { const bankerPrototypeAvailable = spiceBankerModeSupported(g); const specialForLeader = (card: Card | undefined, leader: string | null) => canUsePlanetologistBattleSpecial({assignments:battleSkills, selectedLeader:leader, card}); - const prototypeCardAllowed = (card: Card) => card.id !== 'ecaz-reinforcements' && + const prototypeCardAllowed = (card: Card) => + (card.id !== 'ecaz-reinforcements' || (!!b.reinforcements && !b.reinforcements.blocked)) && (card.id !== 'ecaz-harass-withdraw' || (!!b.harassWithdraw && !b.harassWithdraw.blocked)); const weapons = [ null, @@ -795,6 +796,9 @@ function plans(g: GameView): Action[] { isStoneBurner(w) || (inspected && isStoneBurner(enemyWeapon)); if ((!leader && (weapon || defense)) || !validBattleSlotPair(w, d, specialForLeader(w,leader))) continue; + const reinforcements = weapon === 'ecaz-reinforcements' || defense === 'ecaz-reinforcements'; + if (reinforcements && (weapon === 'ecaz-harass-withdraw' || + defense === 'ecaz-harass-withdraw' || isStoneBurner(w))) continue; if (voice) { const used = playedVoiceMatch( @@ -967,6 +971,8 @@ function plans(g: GameView): Action[] { ? battleWeaponsExplode(w, d, enemyWeapon, enemyDefense) : (isShield(revealedDefense) && w?.kind === 'lasgun') || (revealedWeapon?.kind === 'lasgun' && isShield(d)); + const reinforcementBonus = reinforcements && !stoneBattle && + !selfExplosion && !explosionRisk ? 2 : 0; const undialed = stoneBattle ? Math.min( ...casualtyOptions( @@ -1006,7 +1012,7 @@ function plans(g: GameView): Action[] { (effectiveDefense ? 2 : 0) : level === 0 ? variation(g, `${leader}-${weapon}-${defense}-${dial}`) * 100 - : (ownSurvivingStrength + + : (ownSurvivingStrength + reinforcementBonus + (!effects.attackerDead && !effects.stunned ? bankerSpice : 0)) * 2 + diff --git a/game/engine.ts b/game/engine.ts index fa009571..b236a647 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -541,6 +541,7 @@ import { type SpiceCard, } from './cards'; import { ecazTreacheryCards, ecazTreacheryDefinition } from './ecaz-cards'; +import { isReinforcements, quoteReinforcements, ReinforcementsError } from './reinforcements'; import { RECRUITS_CARD_ID, type RecruitsPreview, @@ -8972,8 +8973,17 @@ function ecazTreacheryIntegrity(g: Game) { 'The Ecaz Treachery Cards inventory contains an unknown physical card.', ); for (const [player, plan] of Object.entries(g.battle?.plans ?? {})) { - requireRule(![plan.weapon, plan.defense].includes('ecaz-reinforcements'), - 'Reinforcements battle effects are still being implemented.'); + if ([plan.weapon, plan.defense].includes('ecaz-reinforcements')) { + const owner = getPlayer(g, player); + requireRule(plan.leader !== 'ecaz-reinforcements' && + g.battle?.lateDefense?.[player] !== 'ecaz-reinforcements' && + plan.weapon !== plan.defense && + owner.hand.some(isReinforcements) && + [plan.weapon, plan.defense].every(id => id === null || !!cardOf(owner, id)) && + validBattleSlotPair(cardOf(owner, plan.weapon), cardOf(owner, plan.defense)), + 'The sealed Reinforcements plan lost its physical card or legal slot.'); + currentReinforcementsCost(g, owner); + } requireRule(plan.leader !== HARASS_WITHDRAW_CARD && g.battle?.lateDefense?.[player] !== HARASS_WITHDRAW_CARD && !(plan.weapon === HARASS_WITHDRAW_CARD && plan.defense === HARASS_WITHDRAW_CARD), 'Harass & Withdraw occupies exactly one ordinary Battle Plan card slot.'); @@ -9018,6 +9028,37 @@ function recruitsModeSupported(g: Game) { !g.discoveryEnabled ); } +function reinforcementsModeSupported(g: Game) { + return !!g.ecazTreachery && recruitsModeSupported(g) && !g.sandtrout && + g.expansions.length === 0 && + g.players.every(player => faction(player.faction).expansion === 'base'); +} +function currentReinforcementsCost(g: Game, p: Player) { + requireRule(reinforcementsModeSupported(g), + 'Reinforcements currently requires the standalone Ecaz card variant with classic factions.'); + try { + return quoteReinforcements(p.reserves - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0); + } catch (error) { + if (error instanceof ReinforcementsError) throw new RuleError(error.message); + throw error; + } +} +function reinforcementsPreview(g: Game, p: Player) { + const battle = g.battle; + if (!battle || ![battle.attacker, battle.defender].includes(p.id) || + !p.hand.some(isReinforcements)) return null; + const normal = Math.min(3, Math.max(0, p.reserves - (p.elites?.reserves ?? 0))); + const elite = Math.min(3 - normal, Math.max(0, p.elites?.reserves ?? 0)); + let blocked: string | null = null; + if (!reinforcementsModeSupported(g)) + blocked = 'Reinforcements currently requires the standalone Ecaz card variant with classic factions.'; + else if (g.status !== 'playing' || g.phase !== 6 || battle.revealed || battle.plans[p.id]) + blocked = 'Choose Reinforcements before sealing your Battle Plan.'; + else if (normal + elite < 3) + blocked = 'Reinforcements needs three physical forces in your reserves.'; + return { blocked, normal, elite }; +} + function recruitsInteractionPending(g: Game) { return !!( g.response || @@ -14996,11 +15037,11 @@ function validatePlan( ); requireRule( !plan.weapon || (w && battleCardSlotEligible('weapon', w, { planetologistWeapon })), - 'Choose a weapon, worthless card, eligible Planetologist Special, or Harass & Withdraw.', + 'Choose a weapon, Worthless card, eligible Planetologist Special, Harass & Withdraw, or Reinforcements.', ); requireRule( !plan.defense || (d && battleCardSlotEligible('defense', d)), - 'Choose a defense, worthless card, or Harass & Withdraw.', + 'Choose a defense, Worthless card, Harass & Withdraw, or Reinforcements.', ); requireRule( !plan.weapon || plan.weapon !== plan.defense, @@ -15014,8 +15055,11 @@ function validatePlan( validBattleSlotPair(w, d, planetologistWeapon), 'Chemistry as a weapon needs another defense; Weirding Way as a defense needs another weapon.', ); - requireRule(![w, d].some(card => card?.id === 'ecaz-reinforcements'), - 'Reinforcements battle effects are still being implemented.'); + if ([w, d].some(isReinforcements)) { + requireRule(![w, d].some(card => isHarassWithdraw(card) || isStoneBurner(card)), + 'Reinforcements cannot share this bounded plan with Harass & Withdraw or Stone Burner.'); + currentReinforcementsCost(g, p); + } if ([w, d].some(isHarassWithdraw)) currentHarassWithdrawQuote(g, p, plan); if (isStoneBurner(w)) { const blocked = stonePlanBlock(g, p, dial, support); @@ -16479,6 +16523,10 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) { leaderSkills: battleLeaderSkills(g, p), occupiedStrongholds: leaderSkillStrongholdCount(g, p), forces: combatForces(g, p, b.territory, opponent), + ...([cardOf(p, plan.weapon), cardOf(p, plan.defense)].some(isReinforcements) + ? { reinforcementsReserves: { + normal: p.reserves - (p.elites?.reserves ?? 0), elite: p.elites?.reserves ?? 0, + } } : {}), ...([cardOf(p, plan.weapon), cardOf(p, plan.defense)].some(isHarassWithdraw) ? { harassWithdraw: harassWithdrawContext(g, p), ...(b.harassAllocation?.player === p.id && b.harassAllocation.stage === 'selected' @@ -16739,6 +16787,22 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) { : `${player.name} paid ${payment.amount} committed Spice Banker spice to the bank, separate from force support. The payment is spent win or lose; only a surviving skilled leader receives its strength bonus.`, { faction: player.faction, name: 'Spice Banker payment' }); } + for (const cost of quote.reinforcements ?? []) { + const player = getPlayer(g, cost.player); + const current = currentReinforcementsCost(g, player); + requireRule(cost.normal === current.normal && cost.elite === current.elite, + 'Reinforcements lost its original physical reserve cost.'); + player.reserves -= cost.normal + cost.elite; + player.tanks += cost.normal + cost.elite; + player.battleLosses += cost.normal + cost.elite; + if (cost.elite) { + requireRule(player.elites, 'Reinforcements lost its elite reserve custody.'); + player.elites.reserves -= cost.elite; + player.elites.tanks += cost.elite; + } + log(g, `${player.name} used Reinforcements: three reserve forces (${cost.elite} elite) went to the Tanks, and the dialed battle strength gained 2 without adding on-board forces or spice support.`, + { faction: player.faction, name: 'Reinforcements' }); + } if (quote.choamIncome) g.pendingChoamBattleIncome = { ...quote.choamIncome }; for (const [player, plan, canceled] of [[a, ap, dc], [d, dp, ac]] as const) if (canceled && [cardOf(player, plan.weapon), cardOf(player, plan.defense)].some(isHarassWithdraw)) @@ -26973,6 +27037,7 @@ export function viewGame(state: Game, id: string) { } : null, nexusInsights: projectedNexusInsights(g, id), harassWithdraw: harassWithdrawPreview(g, me), + reinforcements: reinforcementsPreview(g, me), ownCommitments: committedPlanElements(b, id), prescience: b.prescience ? { diff --git a/game/reference.ts b/game/reference.ts index db11956d..c9e41608 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4195,21 +4195,43 @@ export const RULE_TOPICS: RuleTopic[] = [ developmentStage: 'Prototyped', summary: 'During Revival, Recruits doubles every faction’s current free rate and raises the ordinary force limit to seven for this turn.', steps: [ - 'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; Recruits and bounded Harass & Withdraw controls are connected. Reinforcements remains unfinished.', + 'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; Recruits, bounded Reinforcements and Harass & Withdraw controls are connected.', 'The holder may play Recruits from its Revival panel. Discard the card once, then all players use the increased rates while this Revival phase remains open, including players who had already marked themselves ready. Completed returns still count toward their turn totals.', 'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.', 'Free-revival prevention still prevents free returns. A Fremen ally grant completed before Recruits doubles from three to six. A later grant, play after a paid ordinary return, unresolved transactions and a second use in the same turn remain guarded pending their rules decisions.', - 'The preview supports Basic or Advanced faction games without additional optional modules. Public activation, Reinforcements and complete combined-game acceptance remain unfinished.', + 'The preview supports Basic or Advanced classic-faction games without additional optional modules. Public activation and complete combined-game acceptance remain unfinished.', ], related: ['revival', 'ecaz-modules', 'expansion-faction-games', 'implementation-checklist'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Genuine independent three-card setup, physical Recruits play/discard, public turn effect, shared revival quotes, preserved use and guarded timing are connected. Harass has its own bounded battle prototype; Reinforcements remains missing.', evidence: ['game/ecaz-cards.ts', 'game/recruits.ts', 'game/revival.ts', 'game/engine.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Genuine independent three-card setup, physical Recruits play/discard, public turn effect, shared revival quotes, preserved use and guarded timing are connected. Harass and Reinforcements have separate bounded battle prototypes; complete variant interactions remain gated.', evidence: ['game/ecaz-cards.ts', 'game/recruits.ts', 'game/revival.ts', 'game/engine.ts'] }, { area: 'Player controls', status: 'Implemented', detail: 'The owned physical card has full inspection, a Revival action and explicit unavailable reasons. All seats see the effective public rates after play.', evidence: ['components/recruits.tsx', 'components/game-table.tsx', 'game/card-presentation.ts'] }, { area: 'AI', status: 'Partial', detail: 'All four profiles use the same legal owned offer for useful own or allied revival, then existing authoritative revival choices. Complete card strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] }, { area: 'Documentation', status: 'Implemented', detail: 'The card guide records examples, current prototype limits and the independent-cap inference; late-paid and late-rate questions remain explicit.', evidence: ['docs/RECRUITS_RULES.md', 'docs/RECRUITS_RUNTIME.md'] }, { area: 'Verification', status: 'Partial', detail: 'Focused rules, controls, AI and authenticated recovery checks cover the bounded preview. Sample and browser evidence is recorded separately; full variant acceptance remains open.', evidence: ['tests/recruits.test.ts', 'tests/recruits-engine.test.ts', 'tests/recruits-controls.test.tsx', 'tests/recruits-recovery.test.ts', 'tests/prototype-room.test.ts'] }, ], }, + { + id: 'card-reinforcements', + title: 'Reinforcements', + category: 'Cards', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Add two to battle strength while paying three own reserve counters into the Tanks when the physical card is revealed.', + steps: [ + 'In the independent Ecaz Treachery Cards variant, place your held Reinforcements card in either battle-card slot; it is neither a weapon nor a defense. A category Prescience answer is None, not the name of this card. One physical card cannot fill both slots.', + 'Have three own forces in reserves when sealing. The current Basic/Advanced classic-faction prototype spends ordinary reserves first and then elite reserves, moving exactly three to their corresponding Tanks subpools when the battle resolves. No extra forces arrive in the battle territory.', + 'The normal-outcome score gains 2 without adding to the physical dial, spice support or on-board casualties. The card and its reserve cost are consumed even on a Traitor victory, a mutual Traitor result or a Lasgun/Shield explosion; successful Traitor calls still decide the outcome directly.', + 'Payment on every outcome, deterministic normal-first selection and counting the reserve transfer as battle losses are documented provisional interpretations, not publisher rulings. Harass/Stone Burner combinations, other optional modules and expansion factions remain unavailable in this bounded prototype.', + ], + related: ['battle-cards', 'battle', 'card-recruits', 'card-harass-withdraw', 'implementation-checklist'], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Physical card custody, either battle slot, three typed reserve counters, normal score modifier and mandatory discard connect in classic-faction Basic/Advanced games only.', evidence: ['game/reinforcements.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Owner-only availability, exact cost guidance and both battle selectors support the card; private category answers remain None.', evidence: ['components/battle-preparation.tsx', 'components/game-table.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'Four profiles consider legal physical slot candidates and the normal score bonus; strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'The printed effect and unresolved Traitor, explosion, type-selection and accounting interpretations are distinguished from this temporary prototype.', evidence: ['docs/ECAZ_TREACHERY_RULES.md', 'docs/REINFORCEMENTS_RUNTIME.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Rule, engine, controls and saved concurrent recovery cases cover this bounded behavior; full variant and combined modes are not certified.', evidence: ['tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/reinforcements-controls.test.tsx', 'tests/reinforcements-recovery.test.ts'] }, + ], + }, { id: 'card-harass-withdraw', title: 'Harass & Withdraw', @@ -4245,7 +4267,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Development setup supports two through six distinct seats from the base factions and selected expansion sets. Expansion selection and optional modules are separate; public expansion starts remain disabled.', 'Ecaz places six starting forces in Imperial Basin, allocated among sectors 9, 10 and 11, and keeps fourteen in reserve. Finish Fremen placement first; Bene Gesserit then places its Advanced starting advisor. Moritani places its six forces in an unoccupied territory after all other setup is complete.', 'Ixians keep their private starting-card selection and physical cyborg/suboid forces. Tleilaxu receives three private Face Dancers. Advanced CHOAM receives its Auditor. Richese has a separate ten-card cache and three private No-Fields. Ecaz and Moritani receive their Ambassador and Terror inventories and share one Duke Vidal disc.', - 'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: the separate three-card variant has Recruits and bounded Harass & Withdraw development controls; Reinforcements remains unfinished.', + 'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: the separate three-card variant has Recruits, bounded Harass & Withdraw and bounded Reinforcements development controls for classic-faction games.', 'Advanced Ixians may explicitly decline Technology for one Richese special lot. Their once-per-round use remains available later. The special-lot exchange itself is unfinished pending replacement-card custody interpretation.', 'Fresh unsupported movement arrivals reject before opening prevention choices or reserving a new movement card. A saved CHOAM allow choice can return an uncommitted unsupported move for another choice, preserving its existing reserved Ornithopter. Simultaneous Ambassador/Terror order remains unresolved.', 'Ordinary shipment checks unfinished arrival combinations before saving the Guild interception choice. An already saved ordinary shipment may return unspent to its shipper when allowance encounters that arrival guard, so the shipper can choose another destination. Older No-Field, Smuggler, Nexus and Ambassador shipments are excluded from this recovery. Simultaneous arrival order remains unfinished.', diff --git a/game/reinforcements.ts b/game/reinforcements.ts new file mode 100644 index 00000000..aa53360b --- /dev/null +++ b/game/reinforcements.ts @@ -0,0 +1,31 @@ +import type { Card } from './cards'; +import { ecazTreacheryDefinition } from './ecaz-cards'; + +export const REINFORCEMENTS_CARD = 'ecaz-reinforcements' as const; + +export class ReinforcementsError extends Error { + constructor(message: string) { + super(message); + this.name = 'ReinforcementsError'; + } +} + +/** A matching id alone does not establish the physical variant card's identity. */ +export function isReinforcements(card: Card | undefined): boolean { + return !!card && card.id === REINFORCEMENTS_CARD && + Object.keys(card).sort().join(',') === 'effect,id,kind,name' && + ecazTreacheryDefinition(card)?.card.effect === 'reinforcements'; +} + +/** Spend exactly three own reserve counters, ordinary before elite; never dial them. */ +export function quoteReinforcements( + normalReserves: number, + eliteReserves: number, +): { normal: number; elite: number; bonus: 2 } { + if (![normalReserves, eliteReserves].every(n => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0)) + throw new ReinforcementsError('Reinforcements needs valid nonnegative reserve counts.'); + if (normalReserves < 3 && eliteReserves < 3 - normalReserves) + throw new ReinforcementsError('Reinforcements needs at least three own forces in reserves.'); + const normal = Math.min(3, normalReserves); + return { normal, elite: 3 - normal, bonus: 2 }; +} diff --git a/tests/ecaz-battle-controls.test.tsx b/tests/ecaz-battle-controls.test.tsx index 2e4e9d50..57ce3a83 100644 --- a/tests/ecaz-battle-controls.test.tsx +++ b/tests/ecaz-battle-controls.test.tsx @@ -77,12 +77,12 @@ void test('named card inspections still lock their exact physical card, includin } }); -void test('the table keeps a no-category slot selectable and offers Harass without unfinished Reinforcements', () => { +void test('the table offers the held no-category Harass card in its physical battle slot', () => { const view = fixture(); const html = renderToStaticMarkup(createElement(GameTable, { game: view, send: async () => {}, onExit() {}, busy: false })); const select = html.match(/]*id="battle-weapon"[^>]*>[\s\S]*?<\/select>/)?.[0]; assert.ok(select); - assert.doesNotMatch(select, /disabled|ecaz-reinforcements|ecaz-recruits|value="treachery-/); + assert.doesNotMatch(select, /disabled|ecaz-recruits|value="treachery-/); assert.match(select, /value="ecaz-harass-withdraw"/); assert.match(html, /Harass and Withdraw battle guidance/); }); diff --git a/tests/harass-withdraw.test.ts b/tests/harass-withdraw.test.ts index 2ed6d45c..bd8643b3 100644 --- a/tests/harass-withdraw.test.ts +++ b/tests/harass-withdraw.test.ts @@ -662,15 +662,7 @@ void test('Basic and Advanced Ixian half-dial withdrawal preserves all seven phy } }); -void test('Reinforcements and public Richese/optional-module contexts remain guarded', () => { - let g = harassWithdrawGame(); - takeHarassCard(g, 'a', 'ecaz-reinforcements'); - reject( - g, - 'a', - ownPlan(g, { defense: 'ecaz-reinforcements' }), - /Reinforcements.*still/, - ); +void test('public Richese and optional-module contexts remain guarded', () => { const richese = harassWithdrawGame({ factions: ['emperor', 'atreides', 'richese'], }); @@ -679,7 +671,7 @@ void test('Reinforcements and public Richese/optional-module contexts remain gua /Richese card family/, ); reject(richese, 'a', ownPlan(richese), /Richese card family/); - g = harassWithdrawGame({ factions: ['emperor', 'atreides', 'choam'] }); + const g = harassWithdrawGame({ factions: ['emperor', 'atreides', 'choam'] }); assert.equal(viewGame(g, 'a').battle!.harassWithdraw!.blocked, null); const forged = structuredClone(g); forged.nexusCards = { cards: null, phase: null }; diff --git a/tests/reinforcements-controls.test.tsx b/tests/reinforcements-controls.test.tsx new file mode 100644 index 00000000..2f8bc79f --- /dev/null +++ b/tests/reinforcements-controls.test.tsx @@ -0,0 +1,111 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { applyAction, viewGame } from '../game/engine'; +import type { Game, GameView } from '../game/engine'; +import { botActions } from '../game/bots'; +import { battlePlanCardOptions } from '../components/battle-preparation'; +import { harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); + return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true }; + }, +}); +// GameTable loads framework aliases and CSS modules; install test hooks before importing it. +const { GameTable } = await import('../components/game-table'); +aliases.deregister(); + +const cardId = 'ecaz-reinforcements'; + +function battle(advanced = false): Game { + const game = harassWithdrawGame({ advanced, factions: ['emperor', 'atreides', 'harkonnen'] }); + takeHarassCard(game, 'a', cardId); + return game; +} + +function selections(view: GameView) { + return (['weapon', 'defense'] as const).map(slot => + battlePlanCardOptions(view, slot, 'emperor-0', '', '').some(card => card.id === cardId)); +} + +function selector(html: string, slot: 'weapon' | 'defense') { + const select = html.match(new RegExp(`]*id="battle-${slot}"[^>]*>[\\s\\S]*?<\\/select>`))?.[0]; + assert.ok(select, `Missing ${slot} selection`); + return select; +} + +void test('a held Reinforcements card with an owner offer is selectable in either physical slot, but never appears in another player’s view', () => { + const game = battle(); + const owner = viewGame(game, 'a'); + assert.deepEqual(owner.battle?.reinforcements, { blocked: null, normal: 3, elite: 0 }); + assert.deepEqual(selections(owner), [true, true]); + const html = renderToStaticMarkup(createElement(GameTable, { game: owner, send: async () => {}, onExit() {}, busy: false })); + for (const slot of ['weapon', 'defense'] as const) + assert.match(selector(html, slot), /value="ecaz-reinforcements"/); + + const opponent = viewGame(game, 'd'); + assert.equal(opponent.battle?.reinforcements, null); + assert.ok(!opponent.players.find(player => player.id === 'a')?.hand?.some(card => card.id === cardId)); + assert.deepEqual(selections(opponent), [false, false]); + const opponentHtml = renderToStaticMarkup(createElement(GameTable, { game: opponent, send: async () => {}, onExit() {}, busy: false })); + assert.doesNotMatch(opponentHtml, /value="ecaz-reinforcements"/); +}); + +void test('a no-category inspection permits a held Reinforcements card but a second slot cannot reuse it', () => { + const view = viewGame(battle(), 'a'); + view.battle!.ownCommitments = [{ source: 'native', beneficiary: 'd', target: 'a', field: 'weapon', value: null }]; + assert.ok(battlePlanCardOptions(view, 'weapon', 'emperor-0', '', '').some(card => card.id === cardId)); + assert.ok(!battlePlanCardOptions(view, 'weapon', 'emperor-0', '', cardId).some(card => card.id === cardId)); + view.battle!.ownCommitments[0].value = 'ecaz-harass-withdraw'; + assert.ok(!battlePlanCardOptions(view, 'weapon', 'emperor-0', '', '').some(card => card.id === cardId)); +}); + +void test('blocked, absent, or unheld owner offers cannot make Reinforcements selectable', () => { + const view = viewGame(battle(), 'a'); + assert.ok(view.battle?.reinforcements); + const offer = view.battle.reinforcements; + for (const blockedOffer of [{ ...offer, blocked: 'Unsupported battle combination.' }, null]) { + view.battle.reinforcements = blockedOffer; + assert.deepEqual(selections(view), [false, false]); + const html = renderToStaticMarkup(createElement(GameTable, { game: view, send: async () => {}, onExit() {}, busy: false })); + for (const slot of ['weapon', 'defense'] as const) + assert.doesNotMatch(selector(html, slot), /value="ecaz-reinforcements"/); + } + view.battle.reinforcements = offer; + view.players.find(player => player.id === 'a')!.hand = view.players.find(player => player.id === 'a')!.hand!.filter(card => card.id !== cardId); + assert.deepEqual(selections(view), [false, false]); +}); + +void test('bot candidates using Reinforcements in both slots seal as legal Basic and Advanced plans; absent or blocked offers retain legal alternatives', () => { + for (const advanced of [false, true]) { + const game = battle(advanced); + const view = viewGame(game, 'a'); + view.players.find(player => player.id === 'a')!.bot = 'Medium'; + const actions = botActions(view).filter(action => action.type === 'battlePlan'); + for (const slot of ['weapon', 'defense'] as const) { + const other = slot === 'weapon' ? 'defense' : 'weapon'; + const plan = actions.find(action => action[slot] === cardId && !action[other]); + assert.ok(plan, `Missing ${advanced ? 'Advanced' : 'Basic'} ${slot} Reinforcements candidate`); + const sealed = applyAction(game, 'a', plan); + assert.equal(sealed.battle?.plans.a?.[slot], cardId); + } + for (const offer of [null, { ...view.battle!.reinforcements!, blocked: 'Unsupported battle combination.' }]) { + view.battle!.reinforcements = offer; + const fallback = botActions(view); + const ordinaryPlan = fallback.find(action => action.type === 'battlePlan' && action.weapon !== cardId && action.defense !== cardId); + assert.ok(ordinaryPlan); + assert.ok(fallback.every(action => action.weapon !== cardId && action.defense !== cardId)); + assert.ok(applyAction(game, 'a', ordinaryPlan).battle?.plans.a); + } + } +}); diff --git a/tests/reinforcements-engine.test.ts b/tests/reinforcements-engine.test.ts new file mode 100644 index 00000000..dd040738 --- /dev/null +++ b/tests/reinforcements-engine.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { harassCustody, harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw'; + +const cardId = 'ecaz-reinforcements'; +function fixture(advanced: boolean): Game { + const g = harassWithdrawGame({ advanced, factions: ['emperor', 'atreides', 'harkonnen'] }); + takeHarassCard(g, 'a', cardId); + harassCustody(g); + return g; +} +function seal(g: Game, slot: 'weapon' | 'defense', own: Partial = {}, other: Partial = {}) { + g = applyAction(g, 'a', { + type: 'battlePlan', dial: 1, support: g.advanced ? 1 : 0, + leader: 'emperor-0', weapon: slot === 'weapon' ? cardId : null, + defense: slot === 'defense' ? cardId : null, ...own, + }); + return applyAction(g, 'd', { + type: 'battlePlan', dial: 1, support: g.advanced ? 1 : 0, + leader: 'atreides-0', weapon: null, defense: null, ...other, + }); +} +function resolve(g: Game, callA = false, callD = false) { + g = applyAction(g, 'a', { type: 'traitorCall', call: callA }); + const serialized = JSON.parse(JSON.stringify(g)) as Game; + for (const p of g.players) + assert.deepEqual(viewGame(serialized, p.id), viewGame(g, p.id)); + const before = JSON.stringify(g); + const done = applyAction(g, 'd', { type: 'traitorCall', call: callD }); + assert.equal(JSON.stringify(g), before); + harassCustody(done); + return done; +} +function reject(g: Game, id: string, action: Action, reason: RegExp) { + const before = JSON.stringify(g); + assert.throws(() => applyAction(g, id, action), reason); + assert.equal(JSON.stringify(g), before); +} + +void test('Reinforcements consumes exactly three own normal reserves in either card slot, Basic and Advanced', () => { + for (const advanced of [false, true]) for (const slot of ['weapon', 'defense'] as const) { + const start = fixture(advanced); + const owner = viewGame(start, 'a'); + assert.deepEqual(owner.battle?.reinforcements, { blocked: null, normal: 3, elite: 0 }); + assert.equal(viewGame(start, 'd').battle?.reinforcements, null); + assert.equal(viewGame(start, 't').battle?.reinforcements, null); + const privatePlan = applyAction(start, 'a', { + type: 'battlePlan', dial: 1, support: advanced ? 1 : 0, leader: 'emperor-0', + weapon: slot === 'weapon' ? cardId : null, + defense: slot === 'defense' ? cardId : null, + }); + assert.deepEqual(viewGame(privatePlan, 'd').battle?.plans, {}); + assert.deepEqual(viewGame(privatePlan, 't').battle?.plans, {}); + const sealed = applyAction(privatePlan, 'd', { + type: 'battlePlan', dial: 1, support: advanced ? 1 : 0, + leader: 'atreides-0', weapon: null, defense: null, + }); + assert.equal(viewGame(sealed, 'a').battle?.reinforcements?.blocked, + 'Choose Reinforcements before sealing your Battle Plan.'); + const done = resolve(sealed); + assert.equal(done.battle, null); + assert.equal(done.players[0].reserves, 12); + assert.equal(done.players[0].tanks >= 3, true); + assert.equal(done.players[0].battleLosses >= 3, true); + assert.equal(done.discard.filter(c => c.id === cardId).length, 1); + assert.equal(done.log.filter(e => e.text.includes('used Reinforcements: three reserve forces')).length, 1); + assert.deepEqual(viewGame(JSON.parse(JSON.stringify(done)) as Game, 'a'), viewGame(done, 'a')); + } +}); + +void test('opposing physical Harass plan resolves with Reinforcements instead of trapping both revealed plans', () => { + for (const advanced of [false, true]) { + const g = fixture(advanced); + takeHarassCard(g, 'd', 'ecaz-harass-withdraw'); + const sealed = seal(g, 'weapon', {}, { defense: 'ecaz-harass-withdraw' }); + const done = resolve(sealed); + assert.equal(done.battle, null); + assert.equal(done.players[0].reserves, 12); + assert.equal(done.discard.filter(c => c.id === cardId).length, 1); + assert.equal(done.discard.filter(c => c.id === 'ecaz-harass-withdraw').length, 1); + assert.equal(done.log.filter(e => e.text.includes('used Reinforcements: three reserve forces')).length, 1); + assert.equal(done.log.filter(e => e.text.includes('used Harass & Withdraw')).length, 1); + } +}); + +void test('Reinforcements normal-first cost preserves elite subpool and is paid on a traitor outcome', () => { + let g = fixture(true); + const p = g.players[0]; + p.reserves = 3; + p.tanks = 12; + p.elites!.reserves = 2; + p.elites!.tanks = 3; + harassCustody(g); + assert.deepEqual(viewGame(g, 'a').battle?.reinforcements, { blocked: null, normal: 1, elite: 2 }); + // Give Atreides the Emperor's selected leader as a real traitor, preserving the deck's identities. + const target = 'emperor-0'; + const old = g.players[1].traitors[0]; + const pools = [g.traitorReserve!, ...g.players.map(player => player.traitors)]; + const source = pools.find(pool => pool.includes(target))!; + source[source.indexOf(target)] = old; + g.players[1].traitors[0] = target; + g = seal(g, 'defense'); + const done = resolve(g, false, true); + assert.equal(done.lastBattleContext?.result, 'traitor'); + assert.equal(done.players[0].reserves, 0); + assert.equal(done.players[0].elites!.reserves, 0); + assert.equal(done.players[0].elites!.tanks, 5); + assert.equal(done.discard.filter(c => c.id === cardId).length, 1); + assert.equal(done.log.filter(e => e.text.includes('used Reinforcements: three reserve forces (2 elite)')).length, 1); +}); + +void test('insufficient reserves, unsupported module, and a paired special reject without mutating the original game', () => { + const g = fixture(true); + const p = g.players[0]; + p.reserves = 2; + p.tanks = 13; + p.elites!.reserves = 2; + p.elites!.tanks = 3; + harassCustody(g); + assert.match(viewGame(g, 'a').battle!.reinforcements!.blocked!, /three physical forces/); + reject(g, 'a', { type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId }, /three own forces/); + p.reserves = 15; + p.tanks = 0; + p.elites!.reserves = 5; + p.elites!.tanks = 0; + reject(g, 'a', { type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId, + defense: 'ecaz-harass-withdraw' }, /cannot share/); + g.sandtrout = {} as Game['sandtrout']; + reject(g, 'a', { type: 'battlePlan', dial: 1, leader: 'emperor-0', weapon: cardId }, /standalone Ecaz card variant/); +}); diff --git a/tests/reinforcements-recovery.test.ts b/tests/reinforcements-recovery.test.ts new file mode 100644 index 00000000..9d64a4cc --- /dev/null +++ b/tests/reinforcements-recovery.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { DatabaseSync } from 'node:sqlite'; +import { viewGame, type Action, type Game } from '../game/engine'; +import type { RoomsClock } from '../db/rooms'; +import { unitStore } from './fixture-nexus-room-store'; +import { harassCustody, harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw'; + +const clock: RoomsClock = { now: () => 83001, sleep: async () => {} }; +const rows = (sqlite: DatabaseSync) => ({ + rooms: sqlite.prepare('SELECT * FROM rooms ORDER BY code').all(), + seats: sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(), +}); + +void test('a privately sealed Reinforcements plan and its one-time physical cost survive D1-style restart and competing final writes', async t => { + for (const advanced of [false, true]) { + const store = unitStore(); + t.after(() => store.sqlite.close()); + const made = await store.rooms.createRoom('Reinforcements recovery', 'emperor', advanced, []); + const code = made.view.code; + const tokens = [made.token, + (await store.rooms.joinRoom(code, 'Atreides', 'atreides')).token!, + (await store.rooms.joinRoom(code, 'Harkonnen', 'harkonnen')).token!]; + const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token))); + const ids = auths.map(auth => auth.playerId) as [string, string, string]; + let game = harassWithdrawGame({ advanced, ids, factions: ['emperor', 'atreides', 'harkonnen'] }); + game.code = code; + game.version = (await store.rooms.readRoom(code)).version; + takeHarassCard(game, ids[0], 'ecaz-reinforcements'); + harassCustody(game); + store.sqlite.prepare('UPDATE rooms SET state=?, version=? WHERE code=?') + .run(JSON.stringify(game), game.version, code); + store.writes.length = 0; + const act = async (index: number, action: Action) => { + const rooms = store.restart(); + const before = await rooms.readRoom(code); + await rooms.act(code, await rooms.authenticate(code, tokens[index]), before.version, action, clock); + return rooms.readRoom(code); + }; + game = await act(0, { type: 'battlePlan', dial: 1, support: advanced ? 1 : 0, + leader: 'emperor-0', defense: 'ecaz-reinforcements' }); + const preReveal = rows(store.sqlite); + for (const [index, token] of tokens.entries()) { + const rooms = store.restart(); + const seat = await rooms.readSeatView(code, await rooms.authenticate(code, token)); + assert.deepEqual(seat, viewGame(game, ids[index])); + assert.deepEqual(seat, viewGame(JSON.parse(JSON.stringify(game)) as Game, ids[index])); + if (index !== 0) { + assert.deepEqual(seat.battle?.plans, {}); + assert.equal(seat.battle?.reinforcements, null); + assert.equal(JSON.stringify(seat).includes('ecaz-reinforcements'), false); + } + } + assert.deepEqual(rows(store.sqlite), preReveal); + game = await act(1, { type: 'battlePlan', dial: 1, support: advanced ? 1 : 0, + leader: 'atreides-0' }); + game = await act(0, { type: 'traitorCall', call: false }); + const before = rows(store.sqlite); + const version = game.version; + const rooms = store.restart(); + const auth = await rooms.authenticate(code, tokens[1]); + const startWrite = store.writes.length; + let entered = 0; + let release!: () => void; + const both = new Promise(resolve => { release = resolve; }); + store.hooks.beforeWrite = async () => { + if (++entered === 2) release(); + await both; + }; + const attempts = await Promise.allSettled([0, 1].map(() => + rooms.act(code, auth, version, { type: 'traitorCall', call: false }, clock))); + store.hooks.beforeWrite = undefined; + assert.equal(attempts.filter(result => result.status === 'fulfilled').length, 1); + assert.deepEqual(store.writes.slice(startWrite).map(write => write.changes).sort((a, b) => a - b), [0, 1]); + const resolved = await store.restart().readRoom(code); + harassCustody(resolved); + assert.equal(resolved.version, version + 1); + assert.equal(resolved.battle, null); + assert.equal(resolved.players[0].reserves, 12); + assert.equal(resolved.discard.filter(card => card.id === 'ecaz-reinforcements').length, 1); + assert.equal(resolved.log.filter(entry => entry.text.includes('used Reinforcements: three reserve forces')).length, 1); + assert.notDeepEqual(rows(store.sqlite).rooms, before.rooms); + for (const [index, token] of tokens.entries()) { + const seat = await store.restart().readSeatView(code, await store.restart().authenticate(code, token)); + assert.deepEqual(seat, viewGame(JSON.parse(JSON.stringify(resolved)) as Game, ids[index])); + } + } +}); diff --git a/tests/reinforcements.test.ts b/tests/reinforcements.test.ts new file mode 100644 index 00000000..500204e0 --- /dev/null +++ b/tests/reinforcements.test.ts @@ -0,0 +1,145 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { baseDeck, leaders, type Card } from '../game/cards'; +import { richeseCards } from '../game/richese-cards'; +import { ecazTreacheryCards } from '../game/ecaz-cards'; +import { BattleResolutionQuoteError, quoteBattleResolution, type BattleResolutionInput } from '../game/battle-resolution-quote'; +import { isReinforcements, quoteReinforcements, ReinforcementsError, REINFORCEMENTS_CARD } from '../game/reinforcements'; + +const physicalCards = [...baseDeck(), ...richeseCards(), ...ecazTreacheryCards()]; +const reinforcement = ecazTreacheryCards().find(c => c.id === REINFORCEMENTS_CARD)!; + +function fixture(slot: 'weapon' | 'defense' = 'weapon'): BattleResolutionInput { + const attackerLeader = leaders('emperor')[0]; + const defenderLeader = leaders('guild')[0]; + return { + advanced: true, turn: 2, territory: 'arrakeen', + attacker: { + id: 'a', faction: 'emperor', hand: [reinforcement], spice: 10, + leader: attackerLeader, + plan: { dial: 2, support: 2, leader: attackerLeader.id, weapon: null, defense: null, [slot]: reinforcement.id }, + forces: { normal: 5, elite: 0, eliteStrength: 2, freeSupport: false }, + reinforcementsReserves: { normal: 1, elite: 2 }, + }, + defender: { + id: 'd', faction: 'guild', hand: [], spice: 10, + leader: defenderLeader, + plan: { dial: 2, support: 2, leader: defenderLeader.id, weapon: null, defense: null }, + forces: { normal: 5, elite: 0, eliteStrength: 2, freeSupport: false }, + }, + voters: [ + { id: 'a', beneficiary: 'a', called: false, traitors: [] }, + { id: 'd', beneficiary: 'd', called: false, traitors: [] }, + ], + participants: [{ id: 'a', faction: 'emperor' }, { id: 'd', faction: 'guild' }], + physicalCards, pendingAuditorPresent: false, pendingRetentionPresent: false, + }; +} +function putCard(g: BattleResolutionInput, side: 'attacker' | 'defender', slot: 'weapon' | 'defense', kind: Card['kind'] | 'stoneBurner') { + const card = physicalCards.find(c => (c.kind === kind || c.effect === kind) && !g.attacker.hand.some(h => h.id === c.id) && !g.defender.hand.some(h => h.id === c.id))!; + assert.ok(card); + g[side].hand = [...g[side].hand, card]; + g[side].plan[slot] = card.id; +} + +void test('canonical identity and reserve cost boundaries preserve the physical counter types', () => { + assert.equal(isReinforcements(reinforcement), true); + assert.equal(isReinforcements({ ...reinforcement, name: 'Reinforcement' }), false); + assert.equal(isReinforcements({ ...reinforcement, effect: 'recruits' }), false); + assert.equal(isReinforcements({ ...reinforcement, extra: true } as Card), false); + assert.deepEqual(quoteReinforcements(3, 0), { normal: 3, elite: 0, bonus: 2 }); + assert.deepEqual(quoteReinforcements(1, 2), { normal: 1, elite: 2, bonus: 2 }); + assert.deepEqual(quoteReinforcements(0, 3), { normal: 0, elite: 3, bonus: 2 }); + assert.deepEqual(quoteReinforcements(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER), { normal: 3, elite: 0, bonus: 2 }); + for (const reserves of [[2, 0], [1, 1], [-1, 4], [1.5, 3], [NaN, 3], [Infinity, 3], [Number.MAX_SAFE_INTEGER + 1, 0]]) + assert.throws(() => quoteReinforcements(reserves[0], reserves[1]), ReinforcementsError); +}); + +void test('either slot adds two score but not dial, support, payment or casualties; card always discards', () => { + for (const slot of ['weapon', 'defense'] as const) { + const g = fixture(slot); + const before = structuredClone(g); + const q = quoteBattleResolution(g); + assert.deepEqual(q.reinforcements, [{ player: 'a', card: REINFORCEMENTS_CARD, normal: 1, elite: 2 }]); + assert.equal(q.result, 'normal'); + assert.equal(q.winner, 'a'); + assert.equal(q.scores!.attacker, 2 + g.attacker.leader!.strength + 2); + assert.equal(q.scores!.defender, 2 + g.defender.leader!.strength); + assert.equal(q.casualties!.dial, 2); + assert.equal(q.casualties!.support, 2); + assert.deepEqual(q.casualties!.forces, g.attacker.forces); + assert.deepEqual(q.casualties!.options, [{ normal: 2, elite: 0, paidNormal: 2, paidElite: 0 }]); + assert.equal(q.payments.find(p => p.player === 'a')!.cost, 2); + assert.deepEqual(q.discarded, [{ player: 'a', card: REINFORCEMENTS_CARD }]); + assert.deepEqual(q.winnerCards, []); + assert.deepEqual(g, before); + } + const ordinary = fixture(); + ordinary.attacker.hand = []; + ordinary.attacker.plan.weapon = null; + delete ordinary.attacker.reinforcementsReserves; + assert.equal(quoteBattleResolution(ordinary).reinforcements, undefined); +}); + +void test('traitor, mutual traitors and explosion all pay the reserve cost and discard independently of winner', () => { + for (const outcome of ['ownTraitor', 'opponentTraitor', 'mutualTraitors', 'explosion'] as const) { + const g = fixture(); + if (outcome === 'explosion') { + putCard(g, 'attacker', 'defense', 'shield'); + putCard(g, 'defender', 'weapon', 'lasgun'); + } else { + if (outcome !== 'opponentTraitor') { + g.voters[0].called = true; + g.voters[0].traitors = [g.defender.plan.leader!]; + } + if (outcome !== 'ownTraitor') { + g.voters[1].called = true; + g.voters[1].traitors = [g.attacker.plan.leader!]; + } + } + const q = quoteBattleResolution(g); + assert.equal(q.result, outcome === 'ownTraitor' || outcome === 'opponentTraitor' ? 'traitor' : outcome); + assert.deepEqual(q.reinforcements, [{ player: 'a', card: REINFORCEMENTS_CARD, normal: 1, elite: 2 }]); + assert.equal(q.discarded.filter(c => c.card === REINFORCEMENTS_CARD).length, 1); + assert.equal(q.winnerCards.includes(REINFORCEMENTS_CARD), false); + assert.equal(q.scores, null); + assert.equal(q.casualties, null); + } +}); + +void test('the losing card cannot enter a Moritani Nexus retention window', () => { + const g = fixture(); + g.nexusMoritani = true; + g.attacker.plan.dial = 0; + g.attacker.plan.support = 0; + g.defender.plan.dial = 4; + g.defender.plan.support = 4; + const q = quoteBattleResolution(g); + assert.equal(q.winner, 'd'); + assert.deepEqual(q.reinforcements, [{ player: 'a', card: REINFORCEMENTS_CARD, normal: 1, elite: 2 }]); + assert.equal(q.retention, null); + assert.deepEqual(q.discarded, [{ player: 'a', card: REINFORCEMENTS_CARD }]); +}); + +void test('selected card requires own funded typed reserves and rejects malformed cards, pairs and unsupported combinations', () => { + const missing = fixture(); + delete missing.attacker.reinforcementsReserves; + assert.throws(() => quoteBattleResolution(missing), BattleResolutionQuoteError); + for (const reserves of [{ normal: 2, elite: 0 }, { normal: -1, elite: 4 }, { normal: 1.5, elite: 2 }, { normal: 0, elite: NaN }]) { + const g = fixture(); + g.attacker.reinforcementsReserves = reserves; + assert.throws(() => quoteBattleResolution(g), BattleResolutionQuoteError); + } + const surplus = fixture(); + surplus.defender.reinforcementsReserves = { normal: 3, elite: 0 }; + assert.throws(() => quoteBattleResolution(surplus), BattleResolutionQuoteError); + const fake = fixture(); + fake.attacker.hand = [{ ...reinforcement, name: 'Other' }]; + assert.throws(() => quoteBattleResolution(fake), BattleResolutionQuoteError); + const duplicate = fixture(); + duplicate.attacker.plan.defense = reinforcement.id; + assert.throws(() => quoteBattleResolution(duplicate), BattleResolutionQuoteError); + const stone = fixture(); + putCard(stone, 'defender', 'weapon', 'stoneBurner'); + assert.throws(() => quoteBattleResolution(stone), BattleResolutionQuoteError); +});