From 0406fc18691956a767e1cda7e20f523cfb73987d Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:29:19 +1100 Subject: [PATCH] Connect Semuta to Moritani initial battle discards --- docs/CURRENT_STATUS.md | 11 ++ docs/MORITANI_ALLY_RETENTION_RULES.md | 34 +++++- docs/RULE_DECISIONS.md | 10 ++ docs/SEMUTA_DISCARD_CONTINUATIONS.md | 20 ++++ game/battle-resolution-quote.ts | 20 ++-- game/engine.ts | 8 +- game/reference.ts | 2 +- tests/nexus-moritani-retention.test.ts | 3 + tests/semuta-moritani-initial-discard.test.ts | 79 +++++++++++++ tests/semuta-moritani-initial-quote.test.ts | 111 ++++++++++++++++++ 10 files changed, 283 insertions(+), 15 deletions(-) create mode 100644 tests/semuta-moritani-initial-discard.test.ts create mode 100644 tests/semuta-moritani-initial-quote.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 6c1fcdc7..15261bc3 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,17 @@ open mode gates. ## Current checkpoint and work +**Semuta before Basic Moritani ally retention — 28 October. Prototyped / +Partial.** In the bare Basic Semuta preview, mandatory loser cards form an +initial battle discard batch before the original allied-retention choice; +Semuta may claim or pass before winner cleanup resumes. The later unkept cards +form a separate opportunity. This ordering is provisional; older saves and +other profiles retain their existing reservations. Eleven related offline +files pass 125 cases, including controlled physical-card battle claim/pass, +later retention and mandatory-card/Traitor distinctions. Wider combinations +and deployed acceptance remain open. +[Scope](MORITANI_ALLY_RETENTION_RULES.md#initial-mandatory-discard-before-native-retention--28-october-2026). + **Mixed Homeworld No-Field transfers — 28 October. Prototyped / Partial.** Richese may provisionally ship its existing concealed marker together with its own selected physical forces from that same foreign Homeworld to another eligible foreign World. diff --git a/docs/MORITANI_ALLY_RETENTION_RULES.md b/docs/MORITANI_ALLY_RETENTION_RULES.md index fa88ad2a..49c97e5d 100644 --- a/docs/MORITANI_ALLY_RETENTION_RULES.md +++ b/docs/MORITANI_ALLY_RETENTION_RULES.md @@ -2,6 +2,33 @@ Bounded source and implementation audit, 2026-09-06. No runtime edits or live-room actions were performed. +## Initial mandatory discard before native retention — 28 October 2026 + +**Prototyped / Partial.** In the supported bare Basic Semuta profile with a +native Moritani ally, cards that the losing ally could not keep as a winner +are discarded with the original mandatory battle batch; the ally's eligible +played cards remain reserved for its later retention choice. Cheap Hero is +always ineligible. The ordinary predicate still permits an unused Poison Tooth +and applies the existing Traitor exception to otherwise mandatory cards. + +The initial batch receives one neutral Semuta opportunity. Its resolved battle +and remaining winner cleanup continue once; the losing ally's original +retention pool stays fixed even if Semuta transfers a freshly discarded card. +The later choice, Karama response and disposal of unkept eligible cards use the +existing native continuation. A Semuta holder cannot spend a reserved battle +card. Older saved retention records and other profiles keep their recorded +all-played reservation. + +The publisher permits the ally to keep one eligible played card and Semuta to +take a card another player has just discarded. It does not establish the timing +between these effects. Sending unretainable cards to an initial batch and the +remaining unkept cards to a later batch is a provisional software ordering; it +creates separate Semuta opportunities and is not a publisher ruling that the +loser's discards are simultaneous or separate. The existing all-card +reservation remains a different serialization, documented below. Controlled +actual-battle fixtures exercise this boundary; wider combinations, complete +rules and deployed acceptance remain open. + ## Distinct Advanced special Karama first version — 8 October 2026 This is **not** the ally-retention ability below. Authorized supplied PDF @@ -116,9 +143,10 @@ require one private selection. Kept and unrelated hand cards remain unavailable. Compulsory/optional winner-card batches may also pause before the original choose-stage retention. Existing aftermath validation and reciprocal alliance checks preserve that native opportunity. A loser acquiring the winner's card -through Semuta cannot add it to the original played/eligible pool. All losing -played cards are reserved in this envelope, so no losing `battleResolved` -batch precedes retention; that distinct pending-retention path stays gated. +through Semuta cannot add it to the original played/eligible pool. At this +earlier checkpoint all losing played cards were reserved, so no losing +`battleResolved` batch preceded retention. The later narrowly scoped initial +mandatory-discard prototype is described above. Advanced native Moritani and Nexus/Skills compositions are not opened. An enemy or the defeated ally spending a printed Karama to cancel the proposed diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 16eba167..0332cbac 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,15 @@ # Rule decision index +## Semuta before Moritani allied retention — 28 October 2026 + +The printed powers do not specify their combined discard ordering. In the +bare Basic Semuta preview, the defeated ally's mandatory cards enter an +initial Semuta batch before winner cleanup; the later unkept retainable cards +form a separate batch after the original retention choice and response. This +is a provisional serialization, not a publisher ruling. Existing saved +retention records and other profiles keep their previous reservations. +[Scope](MORITANI_ALLY_RETENTION_RULES.md#initial-mandatory-discard-before-native-retention--28-october-2026). + ## Richese marker with same-source Homeworld forces — 28 October 2026 The supplied Advanced revision permits Homeworld-to-Homeworld shipment diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index c87dcc5c..ca8fa73e 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -2,6 +2,26 @@ Read-only engine audit, 2026-09-07. Read the complete `docs/SEMUTA_DRUG_ENGINE_AUDIT.md`, then inspected every current `discard(...)` call, direct Treachery-discard mutation and `draw(g)` caller in `game/engine.ts`, plus the Truthtrance discard callback and pure Box/Distrans results. Names below are integration anchors; approximate current lines are navigation aids only. This report adopts no additional source ruling and changes no project file. +## Native Moritani retention and initial battle discards — 28 October 2026 + +In the supported bare Basic Semuta profile, a defeated ally of native Moritani +discards played cards that cannot be retained with the original mandatory +`battleResolved` batch. The eligible played cards remain reserved for the +later Moritani choice. A neutral Semuta reaction can suspend the original +resolved-battle continuation at the initial batch; claiming or passing resumes +the original winner cleanup and the same retention choice without repeating +battle results. A later discard of the unkept eligible cards has its own fresh +Semuta opportunity. A recovered card never enters the original retention pool. + +This split into two distinct opportunities is a provisional software ordering: +the source does not establish the priority or whether those discards must be +grouped. The ordinary winner-retention predicate, including the Cheap Hero and +Traitor cases, determines which cards are ineligible; it is not inferred from +their generic card types alone. Older saved retention records and non-preview +games retain their original reservations. Controlled actual-battle fixtures +exercise the new continuation; wider module combinations and final acceptance +remain open. [Retention scope](MORITANI_ALLY_RETENTION_RULES.md#initial-mandatory-discard-before-native-retention--28-october-2026). + ## Shrine physical-role stages — 16 October 2026 Fresh private `semuta --discoveries` setup retains the original Discovery diff --git a/game/battle-resolution-quote.ts b/game/battle-resolution-quote.ts index de12819f..b45c04ad 100644 --- a/game/battle-resolution-quote.ts +++ b/game/battle-resolution-quote.ts @@ -156,6 +156,9 @@ export type BattleResolutionInput = { physicalCards: readonly { id: string }[]; pendingAuditorPresent: boolean; pendingRetentionPresent: boolean; + /** Selected Basic Semuta serialization: dispose mandatory loser cards before + * reserving the eligible native Moritani ally-retention choices. */ + nativeMoritaniEarlyMandatoryDiscard?: boolean; /** Open the same loser window regardless of private Moritani Nexus custody. */ nexusMoritani?: boolean; }; @@ -966,16 +969,17 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote { const loser = winner === a ? d : a, loserPlayed = loser === a ? played.attacker : played.defender; const retainablePlayed = loserPlayed.filter( - (selected) => !mandatorySkillDiscard(loser, selected), - ); - const eligible = retainablePlayed.filter( (selected) => - canRetainBattleCard( - card(loser, selected)!, - ac || dc, - !!loser.poisonTooth, - ), + !mandatorySkillDiscard(loser, selected) && + (!input.nativeMoritaniEarlyMandatoryDiscard || !moritani || + canRetainBattleCard( + card(loser, selected)!, + ac || dc, + !!loser.poisonTooth, + )), ); + const eligible = retainablePlayed.filter((selected) => + canRetainBattleCard(card(loser, selected)!, ac || dc, !!loser.poisonTooth)); if ( moritani && loser.id !== moritani.id && diff --git a/game/engine.ts b/game/engine.ts index 0c2ee1c4..cc47dcce 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -9188,13 +9188,13 @@ function treacheryDiscardIntegrity(g: Game) { 'The committed Kaitain discard no longer matches its payment and closing opportunity.'); } else throw new RuleError('Unknown saved discard continuation.'); if (pending.reaction && g.moritaniRetention && - (continuation.kind === 'winnerMandatoryDiscard' || + (continuation.kind === 'battleResolved' || continuation.kind === 'winnerMandatoryDiscard' || (continuation.kind === 'battleCleanup' && continuation.source === 'winner'))) { const retention = g.moritaniRetention; const owner = getPlayer(g, retention.owner), loser = getPlayer(g, retention.player); requireRule(!g.advanced && retention.source === undefined && retention.stage === 'choose' && owner.faction === 'moritani' && owner.ally === loser.id && loser.ally === owner.id, - 'The pending native allied retention no longer matches the preceding winner discard.'); + 'The pending native allied retention no longer matches the preceding battle discard.'); currentBattleAftermathQuote(g); } } @@ -9393,7 +9393,8 @@ function semutaOfferSupported( continuation.kind === 'battleResolved') && ((g.pendingWinnerDiscards && continuation.kind !== 'battleResolved') || (g.moritaniRetention && (g.advanced || g.moritaniRetention.source !== undefined || - g.moritaniRetention.stage !== 'choose' || continuation.kind === 'battleResolved')) || + g.moritaniRetention.stage !== 'choose' || + (continuation.kind === 'battleResolved' && !bareBasicSemutaProfile(g)))) || g.pendingIxSubstitution)) return false; if (continuation.kind === 'terrorDiscard' && (!childTerrorDiscard && (continuation.entry.resume !== 'none' || @@ -32487,6 +32488,7 @@ function currentBattleResolutionInput(g: Game, canceledVoter?: string): BattleRe physicalCards: physicalTreacheryCards(g), pendingAuditorPresent: !!g.pendingAuditor, pendingRetentionPresent: !!g.moritaniRetention, + nativeMoritaniEarlyMandatoryDiscard: !!g.semutaPreview && bareBasicSemutaProfile(g), nexusMoritani: !!g.nexusCards?.cards && !byFaction(g, 'moritani'), ...(b.stoneAllocation ? { stoneAllocations: b.stoneAllocation.selections } : {}), }; diff --git a/game/reference.ts b/game/reference.ts index 5ce8f69e..6118e98e 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1343,7 +1343,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Mandatory and winner battle discards also pause before a native Auditor opportunity. Claim or Continue preserves the original survival limit and opponent-used card identities, then eligibility and the full cancellation price use the current opposing hand. A card acquired from the other combatant can be eligible if the audited player did not use it. Payment or private inspection resumes once; only CHOAM receives the historical snapshot.', 'Fresh three-through-six-seat Basic/Advanced Richese games with CHOAM and optional Ix may retain original unused Tech Tokens under the existing native roster prerequisites. Mandatory and winner discards now offer Semuta before the original winner takes one loser-owned token. Claim or Continue leaves every token and income ledger in place until original cleanup finishes; the winner’s selected award still precedes Face Dance. Advanced may additionally retain original unused Stronghold Cards at two through six seats; combining Tech requires three through six. The six separate public module cards remain unowned until the actual final Mentat assignment, using the board after effects such as Gamont. Neither Semuta claim nor its original closing CHOAM continuation awards a Stronghold Card early. Original Leader Skills require the explicit entry described below; other overlays and the separate Basic Moritani entry remain excluded.', 'A mandatory loser-card discard may also offer Semuta while the original winner’s compulsory used card remains reserved. For native Ixians, claim or Continue settles the original casualties, then restores the one-for-one Suboid/Cyborg substitution and its Karama response. The winning used Poison Tooth is not discarded until substitution or decline finishes; only then is that physical Tooth a separate fresh target before optional winner cleanup.', - 'In the Basic native Moritani entry, winner compulsory/optional card discards may pause before the losing ally’s original retention choice. After that ally chooses one eligible played card or declines, native Karama finishes first; the completed unkept-card batch then offers Semuta. A kept or unrelated hand card is never a target. Multiple fresh cards require commitment and one private selection; taking a winner’s card does not add it to the original retention pool. Advanced Moritani, Nexus retention and unresolved casualty parents remain separate.', + 'In the Basic native Moritani entry, mandatory loser cards such as Cheap Hero are discarded first and may offer Semuta before winner cleanup and the losing ally’s original retention choice. After the ally chooses one eligible played card or declines, native Karama finishes first; the completed unkept-card batch offers Semuta separately. This split ordering is provisional. A kept or unrelated hand card is never a target. Multiple cards in the same fresh batch require commitment and one private selection; taking a discarded card does not add it to the retention pool. Advanced Moritani, Nexus retention and unresolved casualty parents remain separate.', 'A printed Karama canceling native Harkonnen capture, CHOAM Auditor or quoted battle income, Ixian casualty substitution, or Basic Moritani allied retention now offers that exact public cost before the original cancellation finishes. Claim or Continue restores the saved response and cancels once. Acquiring the cost card cannot restore a captive, inspect a hand, pay the canceled income, exchange casualties or keep the proposed allied card. Later native card cleanup and markets still finish; Worthless conversion, other cancellations and module combinations remain separate.', 'A printed-role Karama stopping Richese’s compulsory cache auction or allied card gift also offers its exact used cost before prevention completes. Claim or Continue resumes that original prevention once. The cache keeps its cards and frozen ordinary count, including any completed Black Market reduction; a prevented gift stays with Richese and its original parent resumes. Recovering Karama does not repeat the attempt, transfer the gift or replay a paid purchase. Existing gift retry protection remains.', 'When Semuta itself was proposed as the now-prevented allied gift, its donor may spend it to claim that counter’s used cost. Prevention first retains Semuta and restores the original parent; the same committed response then spends Semuta and takes the cost, without a second prevention or transfer to the ally. Passing keeps Semuta. Other commitments remain enforced.', diff --git a/tests/nexus-moritani-retention.test.ts b/tests/nexus-moritani-retention.test.ts index 625c5d2c..0bce9eb8 100644 --- a/tests/nexus-moritani-retention.test.ts +++ b/tests/nexus-moritani-retention.test.ts @@ -32,6 +32,9 @@ function battle(holdCard: boolean, advanced = false, playedCard = true, cards.hands[winnerId] = 'choam'; } Object.assign(game, { active: winnerId, order: ids, phase: 6, storm: 18 }); + // This deliberately staged battle skips the preceding Charity phase, so its + // Advanced-only ordering receipt cannot remain active in the battle fixture. + delete game.charityOrder; for (const player of game.players) { player.forces = player.id === observerId ? {} : { 'arrakeen:10': 10 }; player.reserves = player.id === observerId ? 20 : 10; diff --git a/tests/semuta-moritani-initial-discard.test.ts b/tests/semuta-moritani-initial-discard.test.ts new file mode 100644 index 00000000..10829fc1 --- /dev/null +++ b/tests/semuta-moritani-initial-discard.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { + semutaMoritaniRetentionFixture, + resolveSemutaMoritaniBattle, + finishSemutaMoritaniWinner, + chooseSemutaMoritaniRetention, + passSemutaMoritaniDiscard, + semutaMoritaniPlayer as player, + semutaMoritaniInventory as inventory, + semutaMoritaniResources as resources, + semutaMoritaniReload as reload, +} from './fixture-semuta-moritani-retention'; + +function rejectWithoutMutation(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +for (const claim of [false, true]) { + void test(`Semuta ${claim ? 'claims' : 'passes'} a losing allied Hero before Moritani retention`, () => { + const fixture = semutaMoritaniRetentionFixture(); + // Controlled physical-card substitution: retain the same genuine Basic + // Richese/Moritani roster and battle; move one existing Hero to the loser. + const heroIndex = fixture.game.deck.findIndex(card => card.kind === 'hero'); + assert.notEqual(heroIndex, -1, 'A physical Cheap Hero must be available.'); + const [hero] = fixture.game.deck.splice(heroIndex, 1); + player(fixture.game, 'l').hand.push(hero); + fixture.loserLeader = hero.id; + fixture.loserStrength = 0; + assert.deepEqual(inventory(fixture.game), fixture.cards); + + const pending = resolveSemutaMoritaniBattle(fixture); + const original = structuredClone(resources(pending)); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + const receipt = pending.pendingTreacheryDiscard!; + assert.equal(receipt.batch.cause, 'battle:mandatory'); + assert.equal(receipt.continuation.kind, 'battleResolved'); + assert.deepEqual(receipt.batch.entries.map(entry => [entry.discardedBy, entry.card.id]), [['l', hero.id]]); + assert.equal(pending.discard.filter(card => card.id === hero.id).length, 1); + assert.equal(pending.moritaniRetention?.stage, 'choose'); + assert.deepEqual(pending.moritaniRetention!.played, [fixture.weapon, fixture.defense]); + assert.deepEqual(pending.moritaniRetention!.eligible, [fixture.weapon, fixture.defense]); + assert.equal(player(pending, 'l').hand.some(card => card.id === hero.id), false); + const event = receipt.batch.event; + for (const id of ['r', 'm', 'w', 'l']) + assert.deepEqual(viewGame(reload(pending), id).semutaReaction?.candidates, []); + rejectWithoutMutation(pending, 'l', { type: 'decision', keep: fixture.weapon }); + rejectWithoutMutation(pending, 'r', { type: 'semutaCommit', event: 'stale-event' }); + + let resumed = claim + ? applyAction(reload(pending), 'r', { type: 'semutaCommit', event }) + : passSemutaMoritaniDiscard(reload(pending)); + assert.equal(resumed.pendingTreacheryDiscard, null); + assert.equal(resumed.decision?.kind, 'battleCards'); + assert.deepEqual(resources(resumed), original); + assert.equal(player(resumed, 'r').hand.some(card => card.id === hero.id), claim); + assert.equal(resumed.discard.some(card => card.id === hero.id), !claim); + assert.equal(resumed.discard.filter(card => card.id === fixture.semuta).length, Number(claim)); + assert.deepEqual(resumed.moritaniRetention, pending.moritaniRetention); + + resumed = finishSemutaMoritaniWinner(resumed); + assert.equal(resumed.decision?.kind, 'moritaniRetention'); + rejectWithoutMutation(resumed, 'l', { type: 'decision', keep: hero.id }); + const later = chooseSemutaMoritaniRetention(resumed, fixture.weapon); + assert.equal(later.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(later.pendingTreacheryDiscard!.batch.cause, 'battle:moritani'); + assert.deepEqual(later.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id), [fixture.defense]); + const final = passSemutaMoritaniDiscard(later); + assert.equal(final.moritaniRetention, null); + assert.equal(final.pendingTreacheryDiscard, null); + assert.equal(player(final, 'l').hand.some(card => card.id === fixture.weapon), true); + assert.equal(final.discard.some(card => card.id === fixture.defense), true); + assert.deepEqual(resources(final), original); + assert.deepEqual(inventory(final), fixture.cards); + }); +} diff --git a/tests/semuta-moritani-initial-quote.test.ts b/tests/semuta-moritani-initial-quote.test.ts new file mode 100644 index 00000000..1a4c6b00 --- /dev/null +++ b/tests/semuta-moritani-initial-quote.test.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { baseDeck, ixBattleCards, leaders, type Card } from '../game/cards'; +import { richeseCards } from '../game/richese-cards'; +import { quoteBattleResolution, type BattleResolutionInput, type ResolutionCombatant } from '../game/battle-resolution-quote'; + +const cards = () => [...baseDeck(), ...ixBattleCards(), ...richeseCards()]; + +function alliedBattle(): BattleResolutionInput { + const side = (id: string, faction: 'emperor' | 'guild', dial: number): ResolutionCombatant => { + const leader = leaders(faction)[0]; + return { id, faction, spice: 10, hand: [], leader, + plan: { dial, support: 0, leader: leader.id, weapon: null, defense: null }, + forces: { normal: 5, elite: 0, eliteStrength: 2, freeSupport: false } }; + }; + const attacker = side('winner', 'emperor', 4); + const defender = side('loser', 'guild', 0); + defender.ally = 'moritani'; + return { advanced: false, turn: 2, territory: 'arrakeen', attacker, defender, + voters: [ + { id: 'winner', beneficiary: 'winner', called: false, traitors: [] }, + { id: 'loser', beneficiary: 'loser', called: false, traitors: [] }, + ], + participants: [ + { id: 'winner', faction: 'emperor' }, + { id: 'loser', faction: 'guild', ally: 'moritani' }, + { id: 'moritani', faction: 'moritani', ally: 'loser' }, + ], + physicalCards: cards(), pendingAuditorPresent: false, pendingRetentionPresent: false }; +} + +function play(g: BattleResolutionInput, slot: 'leader' | 'weapon' | 'defense', kind: Card['kind']) { + const card = cards().find(candidate => candidate.kind === kind && + !g.defender.hand.some(existing => existing.id === candidate.id)); + assert.ok(card, `Missing physical ${kind} card`); + g.defender.hand = [...g.defender.hand, card]; + g.defender.plan[slot] = card.id; + if (slot === 'leader') delete g.defender.leader; + return card.id; +} + +function callLosingLeader(g: BattleResolutionInput) { + const voter = g.voters.find(candidate => candidate.id === 'winner')!; + voter.called = true; + voter.traitors = [g.defender.plan.leader!]; +} + +void test('early native Moritani cleanup places the losing Hero in the first discard while preserving its other card', () => { + const g = alliedBattle(); + const hero = play(g, 'leader', 'hero'), weapon = play(g, 'weapon', 'projectile'); + g.nativeMoritaniEarlyMandatoryDiscard = true; + const before = structuredClone(g); + const q = quoteBattleResolution(g); + assert.equal(q.winner, 'winner'); + assert.deepEqual(q.retention?.played, [weapon]); + assert.deepEqual(q.retention?.eligible, [weapon]); + assert.deepEqual(q.discarded, [{ player: 'loser', card: hero }]); + assert.deepEqual(g, before); +}); + +void test('legacy/default Moritani cleanup retains its original whole played set until the later choice', () => { + const g = alliedBattle(); + const hero = play(g, 'leader', 'hero'), weapon = play(g, 'weapon', 'projectile'); + for (const option of [undefined, false]) { + g.nativeMoritaniEarlyMandatoryDiscard = option; + const before = structuredClone(g), q = quoteBattleResolution(g); + assert.equal(q.winner, 'winner'); + assert.deepEqual(new Set(q.retention?.played), new Set([hero, weapon])); + assert.deepEqual(q.retention?.eligible, [weapon]); + assert.equal(q.discarded.some(entry => entry.player === 'loser'), false); + assert.deepEqual(g, before); + } +}); + +for (const used of [false, true]) for (const traitor of [false, true]) { + void test(`early Moritani cleanup ${used ? 'used' : 'unused'} Poison Tooth with ${traitor ? 'a traitor call' : 'normal battle'}`, () => { + const g = alliedBattle(); + const tooth = play(g, 'weapon', 'poisonTooth'), defense = play(g, 'defense', 'shield'); + g.defender.poisonTooth = used; + g.nativeMoritaniEarlyMandatoryDiscard = true; + if (traitor) callLosingLeader(g); + const before = structuredClone(g), q = quoteBattleResolution(g); + assert.equal(q.winner, 'winner'); + const mandatory = used && !traitor; + assert.equal(q.retention!.played.includes(tooth), !mandatory); + assert.equal(q.retention!.eligible.includes(tooth), !mandatory); + assert.equal(q.retention!.played.includes(defense), true); + assert.equal(q.retention!.eligible.includes(defense), true); + assert.deepEqual(q.discarded.filter(entry => entry.player === 'loser'), + mandatory ? [{ player: 'loser', card: tooth }] : []); + assert.deepEqual(g, before); + }); +} + +for (const traitor of [false, true]) { + void test(`early Moritani cleanup Artillery ${traitor ? 'prevented by a traitor call' : 'used in battle'}`, () => { + const g = alliedBattle(); + const artillery = play(g, 'weapon', 'artillery'), defense = play(g, 'defense', 'shield'); + g.nativeMoritaniEarlyMandatoryDiscard = true; + if (traitor) callLosingLeader(g); + const before = structuredClone(g), q = quoteBattleResolution(g); + assert.equal(q.winner, 'winner'); + assert.equal(q.retention!.played.includes(artillery), traitor); + assert.equal(q.retention!.eligible.includes(artillery), traitor); + assert.equal(q.retention!.played.includes(defense), true); + assert.equal(q.retention!.eligible.includes(defense), true); + assert.deepEqual(q.discarded.filter(entry => entry.player === 'loser'), + traitor ? [] : [{ player: 'loser', card: artillery }]); + assert.deepEqual(g, before); + }); +}