diff --git a/docs/BATTLE_DISCARD_CONTINUATIONS.md b/docs/BATTLE_DISCARD_CONTINUATIONS.md index c76598a0..5466abf3 100644 --- a/docs/BATTLE_DISCARD_CONTINUATIONS.md +++ b/docs/BATTLE_DISCARD_CONTINUATIONS.md @@ -10,6 +10,13 @@ Normal Advanced/Ixian winner casualties retain the effective force strengths, fr The remaining order is winner casualties, applicable Ixian substitution, optional winner cards, Moritani cleanup, CHOAM support income, tech transfer, Harkonnen capture, Auditor, Face Dance and the next battle/phase. Winner and Moritani discards are distinct later batches. Empty batches create no event. Moritani's consumed retention receipt preserves played and kept identities without requiring discarded cards to remain in hand. Older cleanup decisions without a resolved receipt can establish one from their existing authenticated parent; a mismatching existing receipt is rejected. +11 October 2026 Semuta update: the explicit development preview admits mandatory +and winner-card batches with the native Advanced Harkonnen capture still pending. +Claim or all-seat decline retires the discard receipt before the original +casualties, cleanup and capture suffix. The capture offer samples no captive +until its original response finishes. Other pending reward exclusions are +unchanged; this does not certify complete Semuta or expansion combinations. + Sequence retirement precedes the remaining work. Ordinary public actions still drain these saved steps automatically. Test-only observation of unchanged private production dispatch captures real intermediate frames; this is not a public Semuta reaction window. ## Integrity, privacy and multiplayer diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 8a474f30..d37791f2 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,19 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Semuta / native Advanced Harkonnen capture — 11 October.** Mandatory and +winner-card battle discards now offer Semuta with the original capture pending. +Claim or all-seat decline preserves winner casualties and cleanup before the +same capture offer; no early sampling or second capture. Other pending reward +exclusions are unchanged. The regression fails before and passes after; +types/lint and 97 affected cases pass. Thirty-six isolated runtime scenarios +cover both producers, claim/pass, four legal AI profiles and capture +decline/retention/execution; execution earns exactly two extra spice against the +same decline suffix. Real GameTable at 390px claims Stunner, resumes capture and +retains one captive before Collection with no overflow. Temporary QA scaffolds +are removed; no saved store or deployed game was targeted. See +[the source/runtime boundary](SEMUTA_SOURCE_UPDATE_20260907.md). + **Semuta / ordinary battle Truthtrance — 11 October.** Ordinary definite Truthtrance answers now offer the consumed card during battle, including a bound unsubmitted-plan promise, a question about a submitted/revealed plan and a fact diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 26cfc2c0..01ee45dd 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,39 @@ # Dune implementation status +## 11 October 2026 — Semuta before native Advanced Harkonnen capture + +The resolved battle and winner-cleanup receipts already validate the original +pending capture's winner, loser and territory. Removing only `pendingCapture` +from the reaction exclusion admits both discard batches without changing the +capture handler, random selection or aftermath ordering. Other pending reward +exclusions remain intact. + +The real Advanced Richese/Harkonnen fixture now covers mandatory loser disposal, +winner disposal, claim/pass, unchanged resources and pending capture, retired +events, card conservation and one retained captive. It fails before the guard +change and passes after. The fixture also declines the existing Atreides +full-plan inspection before shared readiness; no preparation bypass was added. +Types/lint and 97 affected cases across eight files pass. + +Thirty-six isolated engine scenarios cover both producers with claim, all-seat +pass and all four legal AI profiles, followed by capture decline, retention or +execution. Each reaches Collection with zero or one captive as appropriate; +execution gains exactly two spice against the same decline suffix. An initial +probe wrongly attributed automatic Collection income to capture; its corrected +comparison uses the complete same-phase baseline. + +Real production GameTable controls at 390px claim the winner's Stunner, resume +Capture a leader, inspect the captive and retain exactly one before Collection, +without horizontal overflow. The QA adapter only switched seats and mounted the +real table against an isolated memory-only engine; it touched no saved store. +All temporary scripts, server and browser tab are removed/stopped. The first +compiler pass stopped on an unused temporary fixture helper; after scaffold +removal, the frozen implementation check passed. + +Source/runtime boundaries: [Semuta](SEMUTA_SOURCE_UPDATE_20260907.md), +[battle ordering](BATTLE_DISCARD_CONTINUATIONS.md). Full Semuta, expansion and +deployed acceptance remain open. + ## 11 October 2026 — Semuta with a bound battle-plan promise The Truthtrance receipt already carried a battle promise and the integrity diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 235f578e..d4c64ead 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -27,6 +27,12 @@ Native Richese/No-Field shipment questions and unsupported module combinations remain outside the admitted shipment-question scope. A claim may move the consumed card, never re-answer the question or rewrite its history. +Mandatory and winner battle discards may now pause before native Advanced +Harkonnen capture. The original pending capture remains bound to the winning +Harkonnen, loser and territory; winner casualties and optional card cleanup +still precede the capture offer. Claim or decline neither samples a captive +early nor repeats the capture. Other pending battle rewards remain gated. + Clean Once Around Sapho stages `saphoAuctionDiscard` after the open lot's finite bidder reorder, hand-slot release and public card disposal, but before settlement. The saved receipt binds the lot, prior funding, diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 874b4647..a5197cb2 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -234,9 +234,9 @@ policy**, not a newly discovered publisher timing rule. Full-hand Semuta uses the explicit user-approved atomic exchange; its completed hand count is unchanged. Pending gifts, competing responses, completed Ornithopter arrivals with Ecaz Ambassador or Moritani Terror, -pending battle rewards and other -typed producers retain their automatic continuation; Semuta cannot -claim those events yet. +pending battle rewards other than native Harkonnen capture and other typed +producers retain their automatic continuation; Semuta cannot claim those events +yet. The completed Box's selected search card stays private and its paid shuffle is not repeated when another seat takes the used Box. No source ruling is inferred @@ -245,6 +245,26 @@ separate [reaction privacy audit](SEMUTA_PRIVACY_REVIEW.md) explains why auto-closing only when nobody secretly holds the card would leak possession. The main game and public expansion gates remain closed. +**11 October 2026 — native Harkonnen capture suffix.** Mandatory and winner +battle discard batches now offer Semuta while the original Advanced Harkonnen +capture remains pending. Only the capture exclusion changed; the battle receipt +already validates that the winning Harkonnen, loser and territory match. Claim +or decline resumes original winner casualties and card cleanup before the same +capture offer. No captive is sampled until the original capture response ends. +Other pending battle rewards retain their separate exclusions. + +The permanent regression fails before the guard change and passes after, +covering both discard producers, unchanged capture/cleanup order, settled +resources, card custody, retired-event rejection and one retained captive. +An isolated engine smoke covers both producers with claim, all-seat pass and +four legal AI profiles, then capture decline, retention and execution (36 +scenarios). Execution adds exactly two spice compared with the same completed +decline path, without counting automatic Collection income as capture income. +Real GameTable controls at 390px claim the winner's Stunner, resume Capture a +leader, inspect the captive and retain exactly one before Collection; no +horizontal overflow. No saved store or deployed game was targeted. +Types/lint and the affected 97-case union across eight files pass. + **11 October 2026 — ordinary battle answer.** Ordinary definite Truthtrance answers now offer the consumed card during battle. This includes unsubmitted battle-plan promises, questions about submitted/revealed plans and fact questions. diff --git a/game/card-presentation.ts b/game/card-presentation.ts index 8967a7e7..98df59e2 100644 --- a/game/card-presentation.ts +++ b/game/card-presentation.ts @@ -40,7 +40,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null { if (definition?.card.effect === 'distrans') return 'Choose a recipient and card in the Distrans transfer panel.'; if (definition?.card.effect === 'semutaDrug') - return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, retired Ornithopter (before native Intrusion), Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation or Bene Gesserit Charity, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. The approved full-hand exchange spends Semuta without increasing the completed hand. Pending rewards, other producers and nested responses remain unfinished.'; + return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, retired Ornithopter (before native Intrusion), Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, Inflation or Bene Gesserit Charity, battle-card and Ix-deck Thumper/Amal discards are connected, as are private normal-auction and Black Market Ixian allied replacements. Battle discards may pause before native Advanced Harkonnen capture; capture resumes after cleanup. The approved full-hand exchange spends Semuta without increasing the completed hand. Other pending rewards, producers and nested responses remain unfinished.'; return definition && definition.card.effect !== 'karama' ? 'This Richese card’s effect is not implemented yet. You can inspect its rules.' : null; @@ -92,7 +92,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation { richese.card.effect === 'juiceOfSapho' ? 'Richese effect integration and verification are incomplete. Supported controls cover Once Around first or last while your bid remains open, bounded movement and battle-choice ordering, and aggressor during the shared pre-plan opportunity. Earlier bids stay committed. Later aggressor intervention and other phase or auction modes remain unfinished; expansion starts remain disabled.' : richese.card.effect === 'semutaDrug' - ? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, paid Box disposal, retired Ornithopter movement (including a following Bene Gesserit Intrusion), Distrans, ordinary and queued definite Truthtrance answers, four Sapho scopes, unready pre-leader Residual Poison, printed Karama cancelling CHOAM Charity/Inflation/Bene Gesserit Charity or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon and proactive La La La/Baliset/ordinary Trip to Gamont Worthless use, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages and private normal-auction or Black Market Ixian ally replacements. Commitment is owner-only; the approved full-hand exchange spends Semuta without increasing your completed hand. Other producers, nested responses and normal expansion starts remain unfinished.' + ? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, paid Box disposal, retired Ornithopter movement (including a following Bene Gesserit Intrusion), Distrans, ordinary and queued definite Truthtrance answers, four Sapho scopes, unready pre-leader Residual Poison, printed Karama cancelling CHOAM Charity/Inflation/Bene Gesserit Charity or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon and proactive La La La/Baliset/ordinary Trip to Gamont Worthless use, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages (including before native Advanced Harkonnen capture) and private normal-auction or Black Market Ixian ally replacements. Commitment is owner-only; the approved full-hand exchange spends Semuta without increasing your completed hand. Other pending rewards, producers, nested responses and normal expansion starts remain unfinished.' : richese.card.effect === 'mirrorWeapon' ? 'Mirror Weapon works in bounded CHOAM/Richese-deck classic-faction battles; copied Tooth and Stone choices resolve separately, and a winning physical Mirror may be kept even after copying Tooth or Artillery. This winner-retention treatment is a user-selected interpretation, not an official clarification. Combined modes and full Richese starts remain gated.' : richese.card.effect === 'stoneBurner' diff --git a/game/engine.ts b/game/engine.ts index 9d18e763..d4cfe588 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -6638,9 +6638,9 @@ function treacheryDiscardIntegrity(g: Game) { 'The committed Kaitain discard no longer matches its payment and closing opportunity.'); } else throw new RuleError('Unknown saved discard continuation.'); } -/** Bounded public single-card reaction after clean completed effects. Battle - * winner cleanup may resume only without other pending battle rewards; final - * flight arrival may resume native Intrusion, but other entry responses remain gated. */ +/** Bounded public single-card reaction after completed effects. Battle cleanup + * may preserve native Harkonnen capture; other pending rewards remain gated. + * Final flight arrival may resume native Intrusion, but other entry responses remain gated. */ function semutaOfferSupported( g: Game, continuation: NonNullable['continuation'], @@ -6686,7 +6686,7 @@ function semutaOfferSupported( if ((continuation.kind === 'battleCleanup' || continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'battleResolved') && - (g.pendingAuditor || g.pendingCapture || g.pendingTech || + (g.pendingAuditor || g.pendingTech || g.pendingFaceDance || g.pendingChoamBattleIncome || g.pendingWinnerDiscards || g.moritaniRetention || g.pendingIxSubstitution)) return false; diff --git a/game/reference.ts b/game/reference.ts index f37a6999..ed261a0e 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1171,6 +1171,9 @@ export const RULE_TOPICS: RuleTopic[] = [ : definition.card.effect === 'semutaDrug' ? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, definite ordinary Truthtrance answers including queued questions, supported shipment promises and battle answers before or after plan submission/reveal, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, a completed CHOAM card sale after its bank income, clean Kulon Worthless disposal before its movement bonus, proactive La La La disposal before blocking free revival, proactive Baliset before restricting movement, proactive ordinary Trip to Gamont before returning a force, accepted Jubba Cloak before storm protection, and three battle batches. A private normal-auction or Black Market Ixian ally replacement also pauses before drawing, without showing its face until a holder commits; original payment and seller income settle once. Cache purchases cannot use native Ixian allied replacement. Ordinary definite Truthtrance answers pause even when another question stays reserved or the answer bound a supported shipment or unsubmitted battle-plan promise. Submitted/revealed plans bind no new promise, but their answers still offer Semuta without changing the plan. Under the user-selected exchange ruling, Commit may replace Semuta even at your faction hand limit: acquisition and Semuta disposal settle atomically, without increasing your completed hand. Reserved incoming cards still need room. For multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, reactive Worthless uses except printed Jubba, elite or concealed Trip returns, inspected/partly ready Residual Poison battles, nested responses, pending rewards and public starts remain unfinished.' : 'Reference component only: this Richese card’s game actions are not enabled.', + ...(definition.card.effect === 'semutaDrug' ? [ + 'Mandatory and winner battle discards also pause before native Advanced Harkonnen capture. Claim or Continue completes the original casualties and card cleanup before the capture offer; it does not sample a captive early or repeat capture. Other pending rewards remain outside this bounded path.', + ] : []), ], ...(definition.card.effect === 'semutaDrug' ? { @@ -1179,7 +1182,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation' as const, status: 'Partial' as const, detail: - 'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon and proactive La La La/Baliset/ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages. A private normal-auction or Black Market Ixian ally replacement pauses before its draw with a neutral, face-hidden offer; original payment and seller income settle once. Cache purchases retain their native allied-replacement prohibition. Ordinary definite Truthtrance answers pause whether or not another question remains reserved, and also when the answer bound a supported shipment or unsubmitted battle-plan promise. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. The user-approved full-hand exchange preserves final hand count and incoming reservations. Other Karama uses, other reactive Worthless uses, private discards, nested responses and pending rewards remain unfinished.', + 'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon and proactive La La La/Baliset/ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages, including before native Advanced Harkonnen capture. A private normal-auction or Black Market Ixian ally replacement pauses before its draw with a neutral, face-hidden offer; original payment and seller income settle once. Cache purchases retain their native allied-replacement prohibition. Ordinary definite Truthtrance answers pause whether or not another question remains reserved, and also when the answer bound a supported shipment or unsubmitted battle-plan promise. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. The user-approved full-hand exchange preserves final hand count and incoming reservations. Other Karama uses, other reactive Worthless uses, private discards, nested responses and other pending rewards remain unfinished.', }, { area: 'Player controls' as const, diff --git a/tests/semuta-engine.test.ts b/tests/semuta-engine.test.ts index c46353b1..e7a929ce 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -23,10 +23,10 @@ function inventory(g: Game) { assert.equal(new Set(ids).size, ids.length); return ids; } -function fixture(ix = false, ixianAlly = false, advanced = false, opponent: 'emperor' | 'beneGesserit' = 'emperor') { +function fixture(ix = false, ixianAlly = false, advanced = false, opponent: 'emperor' | 'beneGesserit' | 'harkonnen' = 'emperor') { let g = createGame('SEMUTAQ2', newPlayer('r', 'Richese', 'richese'), advanced, ix ? ['choam', 'ix'] : ['choam']); joinGame(g, newPlayer('a', 'Atreides', 'atreides')); - joinGame(g, newPlayer('e', ixianAlly ? 'Ixians' : opponent === 'beneGesserit' ? 'Bene Gesserit' : 'Emperor', + joinGame(g, newPlayer('e', ixianAlly ? 'Ixians' : opponent === 'beneGesserit' ? 'Bene Gesserit' : opponent === 'harkonnen' ? 'Harkonnen' : 'Emperor', ixianAlly ? 'ixians' : opponent)); for (const p of g.players) { p.bot = 'Medium'; p.ready = true; } g = initializeSemutaGameForAudit(g); @@ -1077,6 +1077,90 @@ void test('clean winner-selected battle cards allow one committed Semuta choice assert.deepEqual(settled(alternate), settled(declined)); }); +void test('Semuta settles mandatory and winner discards before the original Harkonnen capture', () => { + const { g, semuta, hajr } = fixture(false, false, true, 'harkonnen'); + player(g, 'a').hand = []; + g.deck.push(hajr); + const weapon = take(g, g.deck.find(c => c.kind === 'projectile')!.id); + const defense = take(g, g.deck.find(c => c.kind === 'snooper')!.id); + player(g, 'e').hand.push(weapon); + player(g, 'a').hand.push(defense); + for (const id of ['a', 'e']) { + player(g, id).forces = { 'arrakeen:10': 5 }; + player(g, id).reserves = 15; + for (const leader of player(g, id).leaders) leader.strength = 0; + } + Object.assign(g, { phase: 6, active: 'e', order: ['e', 'a', 'r'], + response: null, decision: null, phaseOpening: null, ready: [] }); + const original = inventory(g); + let state = applyAction(g, 'e', { type: 'chooseBattle', territory: 'arrakeen', target: 'a' }); + for (let step = 0; step < 40; step++) { + if (state.response) { + const actor = state.players.find(p => !state.response!.passed.includes(p.id))!; + state = applyAction(state, actor.id, { type: 'passResponse' }); + } else if (state.battle?.preparation) + state = applyAction(state, state.battle.preparation.owner, { type: 'declineBattlePower' }); + else if (state.decision?.kind === 'fullPlanOffer') + state = applyAction(state, state.decision.player, { type: 'decision', decline: true }); + else break; + } + for (const id of ['e', 'a']) + state = applyAction(state, id, { type: 'battlePreparationReady', event: state.battle!.preLeader!.event }); + state = applyAction(state, 'e', { type: 'battlePlan', dial: 2, support: 2, + leader: player(state, 'e').leaders[0].id, weapon: weapon.id }); + state = applyAction(state, 'a', { type: 'battlePlan', dial: 0, support: 0, + leader: player(state, 'a').leaders[0].id, defense: defense.id }); + for (const id of ['e', 'a']) state = applyAction(state, id, { type: 'traitorCall', call: false }); + assert.equal(state.pendingTreacheryDiscard?.continuation.kind, 'battleResolved'); + assert.equal(state.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.deepEqual(state.pendingCapture, { player: 'e', loser: 'a', territory: 'arrakeen' }); + const settled = (game: Game) => game.players.map(p => ({ + id: p.id, spice: p.spice, forces: p.forces, reserves: p.reserves, tanks: p.tanks, + leaders: p.leaders, + })); + const pass = (game: Game) => { + const event = game.pendingTreacheryDiscard!.batch.event; + for (const id of ['e', 'a', 'r']) game = applyAction(game, id, { type: 'semutaPass', event }); + return game; + }; + const mandatoryEvent = state.pendingTreacheryDiscard!.batch.event; + const mandatoryClaim = applyAction(reload(state), 'r', { type: 'semutaCommit', event: mandatoryEvent }); + const mandatoryPass = pass(reload(state)); + assert.equal(player(mandatoryClaim, 'r').hand.some(c => c.id === defense.id), true); + assert.deepEqual(settled(mandatoryClaim), settled(mandatoryPass)); + for (const resumed of [mandatoryClaim, mandatoryPass]) { + assert.equal(resumed.decision?.kind, 'battleCards'); + assert.deepEqual(resumed.pendingCapture, state.pendingCapture); + assert.throws(() => applyAction(resumed, 'r', { type: 'semutaCommit', event: mandatoryEvent })); + } + const winnerDiscard = applyAction(mandatoryPass, 'e', { type: 'decision', discard: [weapon.id] }); + assert.equal(winnerDiscard.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + const winnerEvent = winnerDiscard.pendingTreacheryDiscard!.batch.event; + const winnerClaim = applyAction(reload(winnerDiscard), 'r', { type: 'semutaCommit', event: winnerEvent }); + const winnerPass = pass(reload(winnerDiscard)); + assert.deepEqual(settled(winnerClaim), settled(winnerPass)); + assert.equal(player(winnerClaim, 'r').hand.some(c => c.id === weapon.id), true); + assert.equal(winnerClaim.discard.filter(c => c.id === semuta.id).length, 1); + assert.equal(winnerPass.discard.some(c => c.id === semuta.id), false); + for (let resumed of [winnerClaim, winnerPass]) { + assert.equal(resumed.decision?.kind, 'captureOffer'); + assert.deepEqual(resumed.pendingCapture, state.pendingCapture); + resumed = applyAction(resumed, 'e', { type: 'decision', accept: true }); + while (resumed.response?.kind === 'capture') { + const actor = resumed.players.find(p => !resumed.response!.passed.includes(p.id))!; + resumed = applyAction(resumed, actor.id, { type: 'passResponse' }); + } + assert.equal(resumed.decision?.kind, 'capturedLeader'); + const captive = player(resumed, 'a').leaders.find(l => l.capturedBy === 'e')!; + assert.ok(captive); + resumed = applyAction(resumed, 'e', { type: 'decision', mode: 'keep' }); + assert.equal(resumed.pendingCapture, null); + assert.equal(player(resumed, 'a').leaders.filter(l => l.capturedBy === 'e').length, 1); + assert.deepEqual(inventory(resumed), original); + assert.throws(() => applyAction(resumed, 'r', { type: 'semutaCommit', event: winnerEvent })); + } +}); + void test('a mandatory winner Hero discard can be claimed before optional battle cleanup', () => { const { g, semuta, hajr } = fixture(); player(g, 'a').hand = [];