diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 4b8da79a..d2910bd2 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -9,22 +9,21 @@ Expansion starts and full rules/publication acceptance remain gated.** The explicit [Semuta development preview](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime) pauses clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final definite Truthtrance, movement-order Sapho, -mandatory winner or later optional winner battle-card discards without -pending rewards. The two winner batches are separate: a claim on the -mandatory Hero can resume the winner's optional weapon choice once. -Every seat sees a neutral offer regardless of hidden Semuta possession. -A sole eligible card transfers on commitment; a simultaneous winner -batch first requires public commitment, then reveals eligible faces -only to the holder for one-card selection. Other cards remain in the -pile and the resolved battle resumes once. The flight queue, committed -arrival, private gift, answer history and Sapho order also resume -without replay. Minimal legal AI, exact JSON/SQLite continuation and -competing-room CAS are connected. Mixed arrivals, auction/battle -Sapho, queued or promise-bound Truthtrance, loser/mixed-owner battle -batches, pending battle rewards, other discard producers, full-hand -ordering, normal Richese starts and deployed acceptance remain -unfinished. Neutral passes and free-slot guard are product safety -choices, not publisher rulings. +initial mandatory battle, mandatory winner and optional winner +discards without competing rewards. A mutual-traitor battle can +discard two public cards with different former owners in one batch; +a holder must commit before seeing the selectable fresh faces and +may take only a card discarded by another player. Mandatory Hero +cleanup and later optional weapon disposal remain separate events. +Every seat sees a neutral offer regardless of hidden Semuta custody. +Casualties, battle results, flight arrival, private gifts, answer +history and Sapho order resume without replay. Minimal legal AI, +exact JSON/SQLite continuation and competing-room CAS are connected. +Mixed arrivals, auction/battle Sapho, queued or promise-bound +Truthtrance, pending battle rewards and other discard producers, +full-hand ordering, normal Richese starts and deployed acceptance +remain unfinished. Neutral passes and free-slot guard are product +safety choices, not publisher rulings. One [natural Advanced Moritani assassination](MORITANI_ASSASSINATE_LEADERS.md#29-september-2026--natural-revealed-card-game) now proceeds from genuine battle loss to a dead-target Traitor reveal, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index f491a60e..6dcfc252 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,45 @@ # Dune implementation status +## 30 September 2026 — Semuta at first mandatory battle discard + +The explicit Richese Semuta preview now pauses a clean public +`battleResolved` discard batch after its combat result and physical +used-card disposal but before casualty choices or winner cleanup. +A normal battle's used loser defense is one fresh card; a mutual +Traitor battle can produce one simultaneous pair owned by both +combatants. Every seat sees the same neutral offer. The holder may +commit and recover only a card discarded by another player; two +eligible cards require owner-only inspection and exact one-card +selection. Other cards remain on the pile. The result, losses and +later mandatory/optional winner batches continue once, separately. + +Genuine setup exercises a normal battle's loser defense, all-seat +decline into winner choice, and a two-owner mutual-Traitor batch +with committed selection and physical census. An isolated migrated +SQLite room recovers both the initial and owner-committed mixed batch +without auto-advancing; authenticated selection changes one card +without rewriting seats or doubling casualties. Pending battle +rewards, other discard producers, full-hand ordering, normal Richese +starts and deployed acceptance remain gated. + +Independent rules review found no scoped issue. Privacy/recovery +review found that the initial `battleResolved` metadata did not bind +the exact original card/owner pair, permitting a malformed saved +batch to reassign a former owner or substitute an old discarded +card. Failing-before/passing-after cases now bind the original +mandatory entries in the resolved battle receipt and reject both +corruptions before projection or claim; frames predating that receipt +continue automatically rather than inventing an offer. + +Follow-up privacy/recovery review found no remaining scoped defect. +A direct production-engine run resolved a mutual-Traitor battle, +opened one two-owner public batch, committed Semuta, selected one +opponent card and left the other physically discarded without a +winner. Phone-width rules showed the three separate battle stages. +`npm run check` passed typecheck, lint and 5,811/5,811 offline +cases; `npm run build` and all 55 Vite development HTTP cases +passed. No deployed revision was exercised. + ## 30 September 2026 — Semuta between mandatory and optional winner cleanup The explicit Richese Semuta preview now pauses a clean public diff --git a/docs/RICHESE_COMPONENTS.md b/docs/RICHESE_COMPONENTS.md index 9dba1a7c..e0738c88 100644 --- a/docs/RICHESE_COMPONENTS.md +++ b/docs/RICHESE_COMPONENTS.md @@ -12,7 +12,7 @@ Audited 6 September 2026. The ten physical identities and their readable face in - Player controls show both original plans, exact undialed counts or honest possible totals, the mode choice and a complete internal guide. No victim/death confirmation is added. The card stays physically reserved until standard cleanup; stale events, duplicate requests and corrupted source custody fail before resources change. All four AI profiles use public force possibilities, legal low-dial plans and revealed leader/bounty information. They preserve leader value when Artillery suppresses bounty and leave traitor/explosion precedence intact. -- Semuta Drug has an explicit bounded development preview for clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final definite Truthtrance, movement-order Sapho and separate mandatory/optional winner battle-card batches. A multi-card winner batch exposes candidates only after holder commitment and transfers one physical card. Private gifts, unresolved promises, reactive arrivals, auction/battle Sapho, loser/mixed-owner battle batches and pending battle rewards remain outside scope. Other producers, full-hand ordering and normal Richese starts remain unfinished. Mirror Weapon and Juice of Sapho have separate bounded paths, not complete combined-mode certification. Other unfinished systems, mobile acceptance, difficulty calibration and complete Advanced/expansion games still prevent goal completion. No start gate was removed. +- Semuta Drug has an explicit bounded preview for clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final definite Truthtrance, movement-order Sapho and three distinct battle discard stages: initial mandatory combatants, mandatory winner and optional winner. A multi-card batch, including two-owner mutual-Traitor cleanup, exposes candidates only after holder commitment and transfers one other-player physical card. Private gifts, unresolved promises, reactive arrivals, auction/battle Sapho and pending battle rewards remain outside scope. Other producers, full-hand ordering and normal Richese starts are unfinished. Mirror Weapon and Juice of Sapho have separate bounded paths, not complete combined-mode certification. Other systems, mobile acceptance, difficulty calibration and complete Advanced/expansion games still prevent goal completion. No start gate was removed. The complete integrated validation checkpoint is recorded in [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md). @@ -113,10 +113,11 @@ explicitly opted-in Richese development profile. Neutral all-seat responses, physical one-card recovery, a provisional free-slot guard, minimal legal AI and saved continuation work after clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final -definite Truthtrance, movement-order Sapho and separate mandatory -and optional winner battle-card batches, including committed -owner-only multi-card selection. Queued questions, promise-bound -answers, reactive arrivals, auction/battle Sapho, loser or mixed-owner -battle batches and pending battle rewards remain unconnected. -Full-hand ordering, ten-card inventory, cache custody and public -expansion start gate are unchanged. +definite Truthtrance, movement-order Sapho and three separate battle +card stages: initial mandatory combatants, mandatory winner and +optional winner. Multi-owner batches require committed owner-only +selection of one other-player card. Queued questions, promises, +reactive arrivals, auction/battle Sapho, pending battle rewards and +other semantic discard families remain unconnected. Full-hand +ordering, ten-card inventory, cache custody and public expansion +start gate are unchanged. diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index a2b876f1..0fc2069b 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -35,16 +35,17 @@ sources. The final forces and move counter are held in the existing bound flight frame; claim or decline retires that frame and logs the arrival once. Mixed entry reactions remain unsupported for Semuta. -Mandatory winner cleanup now pauses at `winnerMandatoryDiscard` -after any earlier loser/battle batch has retired; a Hero already in -the public pile can be claimed before the exact optional winner -choice resumes. If the winner then selects other played cards, -their public disposal is a separate `battleCleanup` batch. Each -stage has a neutral all-seat offer; only a committed holder inspects -multiple eligible cards, selects one, and leaves the others in the -pile. No battle award or card cost repeats. Moritani loser retention -and pending Auditor, capture, tech, Face Dance or CHOAM rewards -remain automatic without this reaction. +Clean `battleResolved` now pauses the first public mandatory batch +after combat result and actual card custody are committed but before +casualty choices or winner follow-up. A mutual-Traitor outcome may +place two cards from different former owners in that one batch; +claimants can choose only a card from another player. Later mandatory +winning-card cleanup uses a separate `winnerMandatoryDiscard` batch, +and an optional winner selection uses a third `battleCleanup` batch. +Every supported stage has a neutral all-seat offer; committed holders +select one fresh card, leave the others in the pile and resume exactly +once. Pending Auditor, capture, tech, Face Dance or CHOAM rewards +remain automatic without Semuta. ## Main findings diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 9e669662..21624806 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -62,33 +62,32 @@ Do not ask again about normal targets, forced-discard owner bookkeeping, simulta ## 30 September 2026 — bounded clean-discard runtime The explicit `semuta` development profile starts a fresh Richese roster -without opening ordinary expansion starts. It interrupts **one public -ordinary-card discard, the used Box after a paid Nullentropy search, -either retirement of a played Ornithopter in a clean flight, the used -Distrans after a private transfer, a final definite Truthtrance, -movement-order Juice of Sapho, mandatory winning-card cleanup, or a -later winner-selected battle-card batch without pending rewards**, -provided the parent transaction is clean. Every seat sees a neutral -fresh-discard opportunity regardless -of hidden Semuta possession. An early-ended Ornithopter advances its -saved movement queue; a completed flight has committed its forces -and resumes its one arrival only without BG, Ecaz or Moritani entry -reactions. Distrans's gift stays private with its recipient. The -Truthtrance answer/history and Sapho first/last queue are bound. -Mandatory winning cards form one public fresh batch after the -previous loser cleanup, and any later optional winner selection is a -separate batch. Neither a Semuta claim nor all-seat decline replays -casualties or re-discards an already claimed card. +without opening ordinary expansion starts. It interrupts **clean public +ordinary-card, paid Box, retired Ornithopter, used Distrans, final +Truthtrance, movement-order Sapho and battle discard batches**, provided +the parent transaction has no competing response. Every seat sees a +neutral fresh-discard opportunity regardless of hidden Semuta custody. +An early-ended Ornithopter advances the saved queue; a completed +flight resumes one committed arrival only without BG, Ecaz or +Moritani entry reactions. Distrans's gift remains private with its +recipient; Truthtrance answer/history and Sapho first/last order are +already bound. + +The first battle mandatory batch may contain public cards belonging +to both combatants. Its accepted result and card custody precede +the neutral offer; casualty allocation and winner cleanup still +follow it once. Mandatory winning-card cleanup, if present, is a +separate later batch; optional winner-selected disposal is later +again. No Semuta claim replays combat or combines these events. Each seat can Continue; only the canonical holder with a free hand slot and a card discarded by another player can commit. A sole eligible fresh card transfers automatically in that same action. -Multiple eligible public winner cards instead open a committed, -owner-only inspection and exact one-card selection; the other fresh -cards stay in the pile and Semuta is discarded once. The saved -ordinary effect or Box parent resumes once. No undisclosed face is -projected before commitment. Other simultaneous discard producers -remain unfinished. +Multiple eligible public battle cards open a committed, owner-only +inspection and exact one-card selection; the other cards remain in +the pile and Semuta is discarded once. The saved ordinary effect or +Box parent resumes once. No undisclosed face is projected before +commitment. Other simultaneous discard producers remain unfinished. This neutral all-seat acknowledgement is a conservative **application policy**, not a newly discovered publisher timing rule. The explicit @@ -96,12 +95,12 @@ free-slot requirement is a provisional safety guard, not the printed resolution of full-hand take-before-discard. Auction, CHOAM market, pending gifts, competing responses, completed Ornithopter arrivals with BG intrusion, Ecaz Ambassador or Moritani Terror, queued or -promise-bound Truthtrance, auction/battle Sapho, loser or mixed-owner -mandatory battle batches and pending battle rewards 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 from this omission. The +promise-bound Truthtrance, auction/battle Sapho, pending battle +rewards 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 +from this omission. The 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. diff --git a/game/card-presentation.ts b/game/card-presentation.ts index 247fcfe3..94ac0928 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 completed ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho and winner battle-card discards are connected; other producers and full-hand sequencing remain unfinished.'; + return 'Use the neutral fresh-discard response in the Semuta development preview. Clean completed ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho and battle-card batches are connected; pending rewards, other producers and full-hand sequencing remain unfinished.'; return definition && definition.card.effect !== 'karama' ? 'This Richese card’s effect is not implemented yet. You can inspect its rules.' : null; diff --git a/game/engine.ts b/game/engine.ts index 0fb6df60..e36bec6b 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1783,6 +1783,10 @@ export type Game = { result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion' | 'legacy'; cardRoles?: Record>>; cardRolesSignature?: string; + mandatoryDiscard?: { + entries: { card: string; owner: string }[]; + signature: string; + }; winnerDiscards?: { cards: string[]; completed: boolean; signature: string }; sukRescue?: { signature: string; completed: boolean }; ixSubstitution?: { signature: string; completed: boolean }; @@ -4542,6 +4546,13 @@ function battleCardRolesSignature(context: NonNullable) { + return JSON.stringify({ + event: context.event, turn: context.turn, territory: context.territory, + combatants: context.combatants, winner: context.winner, result: context.result, + entries: context.mandatoryDiscard?.entries, + }); +} function battleCardRolesIntegrity(g: Game) { const context = g.lastBattleContext; if (!context?.cardRoles && !context?.cardRolesSignature) return; @@ -5547,6 +5558,20 @@ function treacheryDiscardIntegrity(g: Game) { ), 'The saved battle discard does not match its resolved combatants.', ); + if (c.kind === 'battleResolved' && context.mandatoryDiscard) { + const receipt = context.mandatoryDiscard; + requireRule( + Array.isArray(receipt.entries) && + receipt.entries.length === batch.entries.length && + receipt.entries.every(entry => + typeof entry.card === 'string' && typeof entry.owner === 'string') && + receipt.signature === battleMandatoryDiscardSignature(context) && + JSON.stringify(receipt.entries) === JSON.stringify(batch.entries.map(entry => ({ + card: entry.card.id, owner: entry.discardedBy, + }))), + 'The fresh mandatory battle cards no longer match their former owners.', + ); + } // Projection must validate the private Auditor owner as strictly as actions. auditorIntegrity(g); const loser = c.combatants.find((id) => id !== context.winner); @@ -5755,6 +5780,7 @@ function semutaOfferSupported( continuation.kind !== 'saphoMovementDiscard' && !(continuation.kind === 'battleCleanup' && continuation.source === 'winner') && continuation.kind !== 'winnerMandatoryDiscard' && + continuation.kind !== 'battleResolved' && !(continuation.kind === 'ornithopterDiscard' && (continuation.source === 'end' || (continuation.source === 'move' && @@ -5764,8 +5790,11 @@ function semutaOfferSupported( !(continuation.kind === 'truthtranceDiscard' && continuation.consumed.source === undefined && continuation.remaining === null && continuation.promise === null)) return false; + if (continuation.kind === 'battleResolved' && + !g.lastBattleContext?.mandatoryDiscard) return false; if ((continuation.kind === 'battleCleanup' || - continuation.kind === 'winnerMandatoryDiscard') && + continuation.kind === 'winnerMandatoryDiscard' || + continuation.kind === 'battleResolved') && (g.pendingAuditor || g.pendingCapture || g.pendingTech || g.pendingFaceDance || g.pendingChoamBattleIncome || g.pendingWinnerDiscards || g.moritaniRetention || @@ -5780,11 +5809,13 @@ function semutaOfferSupported( g.players.some(p => p.faction === 'richese') && entries.length > 0 && (continuation.kind === 'battleCleanup' || - continuation.kind === 'winnerMandatoryDiscard' + continuation.kind === 'winnerMandatoryDiscard' || + continuation.kind === 'battleResolved' ? entries.every(entry => entry.publicFace) : entries.length === 1 && entries[0].publicFace) && (continuation.kind === 'battleCleanup' || continuation.kind === 'winnerMandatoryDiscard' || + continuation.kind === 'battleResolved' || (!continuation.resume.response && !continuation.resume.decision && !continuation.resume.pendingKarama && !continuation.resume.phaseOpening)) && !g.karamaShipping && !g.auction && !g.richeseAuction && !g.battle && @@ -18132,6 +18163,14 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) { ...(casualtyCommitment ? { commitment: { forces: {...casualtyCommitment.forces}, dial: casualtyCommitment.dial, support: casualtyCommitment.support } } : {}), }; } + if (discarded.length) { + g.lastBattleContext.mandatoryDiscard = { + entries: discarded.map(entry => ({ card: entry.card.id, owner: entry.discardedBy })), + signature: '', + }; + g.lastBattleContext.mandatoryDiscard.signature = + battleMandatoryDiscardSignature(g.lastBattleContext); + } g.battle = null; const cards = [...quote.winnerCards]; const continuation: Extract< diff --git a/game/reference.ts b/game/reference.ts index a42d3240..08df73ec 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1156,7 +1156,7 @@ export const RULE_TOPICS: RuleTopic[] = [ : definition.card.effect === 'portableSnooper' ? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.' : definition.card.effect === 'semutaDrug' - ? 'In an explicitly opted-in Richese development game, every seat sees a neutral response after clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, movement-order Sapho or winner battle-card discards without pending rewards. Mandatory winning cards and later optional winner discards are separate fresh batches. Continue or commit held Semuta with a free hand slot; a sole card transfers automatically. For several cards, commit first, then privately select exactly one. The original effect or battle suffix resumes once. BG/Ecaz/Moritani arrivals, loser and mixed-owner mandatory battle batches, competing transactions, full-hand sequencing and normal Richese starts remain unfinished.' + ? 'In an explicitly opted-in Richese development game, every seat sees a neutral response after clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, movement-order Sapho or battle-card discards without pending rewards. The battle’s first mandatory batch can include cards from both combatants; a later mandatory winning-card cleanup and optional winner selection are separate fresh batches. Continue or commit held Semuta with a free hand slot; only another player’s card is eligible. A sole card transfers automatically; for several, commit first and privately select exactly one. Casualties and awards never replay. Mixed arrivals, reward-pending battle effects, competing transactions, full-hand sequencing and normal Richese starts remain unfinished.' : 'Reference component only: this Richese card’s game actions are not enabled.', ], ...(definition.card.effect === 'semutaDrug' @@ -1166,13 +1166,13 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation' as const, status: 'Partial' as const, detail: - 'An explicitly opted-in Richese profile interrupts clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, movement-order Sapho, mandatory winner and optional winner battle-card discards with a neutral all-seat offer. Mandatory and later optional winner cards are separate batches; committed multi-card candidates belong only to the holder. Semuta transfers one other-player card, is discarded once and resumes the original effect once. Mixed arrivals, loser battle batches, pending rewards, other typed producers and full-hand exchange remain unfinished.', + 'The explicit Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho and three separate battle discard stages: initial mandatory combatants, mandatory winner and optional winner. A neutral all-seat offer precedes each eligible batch. Only a committed holder sees multi-card candidates from other players; Semuta transfers one physical card, is discarded once and resumes the saved battle/casualty suffix once. Mixed arrivals, pending battle rewards, other producers and full-hand exchange remain unfinished.', }, { area: 'Player controls' as const, status: 'Partial' as const, detail: - 'All seats can Continue at the neutral fresh-discard boundary; only the actual holder sees Commit. A committed winner-battle batch gives that holder the card inspector and exact one-card selector; nonholders receive no candidates. Normal Richese starts remain gated.', + 'All seats can Continue at the neutral fresh-discard boundary; only the actual holder sees Commit. In a mixed-owner battle batch, a committed holder can inspect and select only cards discarded by another player; nonholders receive no candidates. Normal Richese starts remain gated.', }, { area: 'AI' as const, @@ -1184,13 +1184,13 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Documentation' as const, status: 'Partial' as const, detail: - 'The guide distinguishes bounded public ordinary-card, paid Box, clean Ornithopter, Distrans, final Truthtrance, movement-order Sapho and mandatory/optional winner battle batches from privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.', + 'The guide distinguishes bounded clean public ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho and initial/mandatory-winner/optional-winner battle batches from privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.', }, { area: 'Verification' as const, status: 'Partial' as const, detail: - 'Genuine setup, physical custody, hidden-hand parity, saved JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, final Truthtrance, movement-order Sapho and consecutive mandatory/optional winner battle reactions. Loser and mixed-owner batches, full games and live deployment remain unverified.', + 'Genuine setup, physical custody, hidden-hand parity, saved JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho and separate mandatory/optional battle reactions, including a two-owner simultaneous batch. Pending battle rewards, full games and live deployment remain unverified.', evidence: [ 'tests/semuta-engine.test.ts', 'tests/semuta-recovery.test.ts', diff --git a/tests/semuta-engine.test.ts b/tests/semuta-engine.test.ts index 33461331..51cecc23 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -534,6 +534,16 @@ void test('clean winner-selected battle cards allow one committed Semuta choice leader: player(battle, 'e').leaders[0].id, defense: defense.id }); battle = applyAction(battle, 'a', { type: 'traitorCall', call: false }); battle = applyAction(battle, 'e', { type: 'traitorCall', call: false }); + assert.equal(battle.pendingTreacheryDiscard?.continuation.kind, 'battleResolved'); + assert.equal(battle.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.deepEqual(battle.pendingTreacheryDiscard?.batch.entries.map(entry => [ + entry.card.id, entry.discardedBy, + ]), [[defense.id, 'e']]); + const mandatoryEvent = battle.pendingTreacheryDiscard!.batch.event; + assert.equal(viewGame(battle, 'r').semutaReaction?.canCommit, true); + assert.deepEqual(normalizeAutomaticGame(reload(battle)), reload(battle)); + for (const id of ['e', 'a', 'r']) + battle = applyAction(battle, id, { type: 'semutaPass', event: mandatoryEvent }); assert.equal(battle.decision?.kind, 'battleCards'); const pending = applyAction(reload(battle), 'a', { type: 'decision', discard: [weapon.id] }); assert.equal(pending.pendingTreacheryDiscard?.batch.cause, 'battle:winner'); @@ -666,3 +676,74 @@ void test('a mandatory winner Hero discard can be claimed before optional battle assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id)); assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event })); }); + +void test('a mutual-traitor battle offers one mixed-owner mandatory batch with one committed choice', () => { + const { g, semuta, hajr } = fixture(); + player(g, 'a').hand = []; + g.deck.push(hajr); + const [shield] = g.deck.splice(g.deck.findIndex(card => card.kind === 'shield'), 1); + const [snooper] = g.deck.splice(g.deck.findIndex(card => card.kind === 'snooper'), 1); + assert.ok(shield && snooper); + player(g, 'a').hand.push(shield); + player(g, 'e').hand.push(snooper); + player(g, 'a').traitors = [player(g, 'e').leaders[0].id]; + player(g, 'e').traitors = [player(g, 'a').leaders[0].id]; + 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: 'a', order: ['a', 'e', 'r'], + response: null, decision: null, phaseOpening: null, ready: [] }); + const original = inventory(g); + let battle = applyAction(g, 'a', { type: 'chooseBattle', territory: 'arrakeen', target: 'e' }); + for (let step = 0; step < 40; step++) { + if (battle.response) { + const p = battle.players.find(p => !battle.response!.passed.includes(p.id))!; + battle = applyAction(battle, p.id, { type: 'passResponse' }); + } else if (battle.battle?.preparation) + battle = applyAction(battle, battle.battle.preparation.owner, { type: 'declineBattlePower' }); + else break; + } + if (battle.battle?.preLeader && !battle.battle.preLeader.closed) + for (const id of ['a', 'e']) + battle = applyAction(battle, id, { + type: 'battlePreparationReady', event: battle.battle!.preLeader!.event, + }); + battle = applyAction(battle, 'a', { type: 'battlePlan', dial: 2, support: 2, + leader: player(battle, 'a').leaders[0].id, defense: shield.id }); + battle = applyAction(battle, 'e', { type: 'battlePlan', dial: 0, support: 0, + leader: player(battle, 'e').leaders[0].id, defense: snooper.id }); + battle = applyAction(battle, 'a', { type: 'traitorCall', call: true }); + const pending = applyAction(battle, 'e', { type: 'traitorCall', call: true }); + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'battleResolved'); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.deepEqual(pending.pendingTreacheryDiscard?.batch.entries.map(entry => [ + entry.card.id, entry.discardedBy, + ]), [[shield.id, 'a'], [snooper.id, 'e']]); + const event = pending.pendingTreacheryDiscard!.batch.event; + const wrongOwner = reload(pending); + wrongOwner.pendingTreacheryDiscard!.batch.entries[0].discardedBy = 'e'; + assert.throws(() => viewGame(wrongOwner, 'r')); + const oldCard = reload(pending); + const prior = oldCard.deck.shift()!; + oldCard.discard.push(prior); + oldCard.pendingTreacheryDiscard!.batch.entries[0].card = prior; + assert.throws(() => viewGame(oldCard, 'r')); + const committed = applyAction(reload(pending), 'r', { type: 'semutaCommit', event }); + assert.equal(committed.pendingTreacheryDiscard?.reaction?.stage, 'select'); + assert.deepEqual(viewGame(committed, 'r').semutaReaction?.candidates.map(card => card.id), + [shield.id, snooper.id]); + assert.deepEqual(viewGame(committed, 'a').semutaReaction?.candidates, []); + assert.deepEqual(normalizeAutomaticGame(reload(committed)), reload(committed)); + const selected = applyAction(reload(committed), 'r', { type: 'semutaSelect', + event, card: snooper.id }); + assert.equal(selected.pendingTreacheryDiscard, null); + assert.equal(selected.lastBattleContext?.winner, null); + assert.equal(player(selected, 'r').hand.filter(card => card.id === snooper.id).length, 1); + assert.equal(selected.discard.filter(card => card.id === shield.id).length, 1); + assert.equal(selected.discard.filter(card => card.id === semuta.id).length, 1); + assert.deepEqual(inventory(selected), original); + for (const p of selected.players) + assert.deepEqual(viewGame(reload(selected), p.id), viewGame(selected, p.id)); +}); diff --git a/tests/semuta-recovery.test.ts b/tests/semuta-recovery.test.ts index 3e781a45..6cead34e 100644 --- a/tests/semuta-recovery.test.ts +++ b/tests/semuta-recovery.test.ts @@ -361,6 +361,11 @@ void test('authenticated clean Semuta producers claim once through restart and r leader: seat(duel, ids[2]).leaders[0].id, defense: defense.id }); duel = await act(1, { type: 'traitorCall', call: false }); duel = await act(2, { type: 'traitorCall', call: false }); + assert.equal(duel.pendingTreacheryDiscard?.continuation.kind, 'battleResolved'); + assert.equal(duel.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + const mandatoryEvent = duel.pendingTreacheryDiscard!.batch.event; + for (const index of [2, 1, 0]) + duel = await act(index, { type: 'semutaPass', event: mandatoryEvent }); assert.equal(duel.decision?.kind, 'battleCards'); const chosen = await act(1, { type: 'decision', discard: [weapon.id, shield.id] }); assert.equal(chosen.pendingTreacheryDiscard?.batch.cause, 'battle:winner'); @@ -464,5 +469,77 @@ void test('authenticated clean Semuta producers claim once through restart and r assert.equal(optional.discard.filter(card => card.id === nextWeapon.id).length, 1); assert.equal(optional.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1); assert.equal(seat(optional, ids[0]).hand.filter(card => card.id === hero.id).length, 1); + + const mutual = structuredClone(optional); + const mutualSemuta = take(mutual, SEMUTA_DRUG_ID); + mutual.deck.push(take(mutual, hero.id)); + seat(mutual, ids[0]).hand.push(mutualSemuta); + const mixedShield = mutual.deck.splice( + mutual.deck.findIndex(card => card.kind === 'shield'), 1)[0]; + const mixedSnooper = mutual.deck.splice( + mutual.deck.findIndex(card => card.kind === 'snooper'), 1)[0]; + assert.ok(mixedShield && mixedSnooper); + const attacker = seat(mutual, ids[1]), defender = seat(mutual, ids[2]); + attacker.hand.push(mixedShield); + defender.hand.push(mixedSnooper); + for (const p of [attacker, defender]) { + p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0) - 5; + p.forces = { 'arrakeen:10': 5 }; + for (const leader of p.leaders) leader.strength = 0; + } + const attackerLeader = attacker.leaders.find(leader => !leader.dead)!; + const defenderLeader = defender.leaders.find(leader => !leader.dead)!; + attacker.traitors = [defenderLeader.id]; + defender.traitors = [attackerLeader.id]; + Object.assign(mutual, { turn: 11, phase: 6, active: ids[1], + order: [ids[1], ids[2], ids[0]], ready: [], + decision: null, response: null, phaseOpening: null, stormPending: null, + lastBattle: [], lastBattleContext: null }); + mutual.version = optional.version + 1; + assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?') + .run(JSON.stringify(mutual), mutual.version, code, optional.version).changes, 1); + let mutualBattle = await act(1, { type: 'chooseBattle', territory: 'arrakeen', target: ids[2] }); + for (let step = 0; step < 40; step++) { + if (mutualBattle.response) { + const p = mutualBattle.players.find(p => !mutualBattle.response!.passed.includes(p.id))!; + mutualBattle = await act(ids.indexOf(p.id), { type: 'passResponse' }); + } else if (mutualBattle.battle?.preparation) + mutualBattle = await act(ids.indexOf(mutualBattle.battle.preparation.owner), + { type: 'declineBattlePower' }); + else break; + } + if (mutualBattle.battle?.preLeader && !mutualBattle.battle.preLeader.closed) + for (const index of [1, 2]) + mutualBattle = await act(index, { + type: 'battlePreparationReady', event: mutualBattle.battle!.preLeader!.event, + }); + mutualBattle = await act(1, { type: 'battlePlan', dial: 2, support: 2, + leader: attackerLeader.id, defense: mixedShield.id }); + mutualBattle = await act(2, { type: 'battlePlan', dial: 0, support: 0, + leader: defenderLeader.id, defense: mixedSnooper.id }); + mutualBattle = await act(1, { type: 'traitorCall', call: true }); + const mixedOffer = await act(2, { type: 'traitorCall', call: true }); + assert.equal(mixedOffer.pendingTreacheryDiscard?.continuation.kind, 'battleResolved'); + assert.equal(mixedOffer.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.deepEqual(mixedOffer.pendingTreacheryDiscard?.batch.entries.map(entry => [ + entry.card.id, entry.discardedBy, + ]), [[mixedShield.id, ids[1]], [mixedSnooper.id, ids[2]]]); + await f.restart().continueRoomAutomatic(code, clock); + assert.equal((await f.restart().readRoom(code)).version, mixedOffer.version); + const mixedEvent = mixedOffer.pendingTreacheryDiscard!.batch.event; + const mixedCommit = await act(0, { type: 'semutaCommit', event: mixedEvent }); + assert.equal(mixedCommit.pendingTreacheryDiscard?.reaction?.stage, 'select'); + assert.deepEqual((await f.restart().readSeatView(code, auths[0])).semutaReaction?.candidates.map(card => card.id), + [mixedShield.id, mixedSnooper.id]); + assert.deepEqual((await f.restart().readSeatView(code, auths[1])).semutaReaction?.candidates, []); + const mixedClaim = await act(0, { type: 'semutaSelect', event: mixedEvent, card: mixedSnooper.id }); + assert.equal(mixedClaim.pendingTreacheryDiscard, null); + assert.equal(mixedClaim.lastBattleContext?.winner, null); + assert.equal(seat(mixedClaim, ids[0]).hand.filter(card => card.id === mixedSnooper.id).length, 1); + assert.equal(mixedClaim.discard.filter(card => card.id === mixedShield.id).length, 1); + assert.equal(mixedClaim.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats); + for (const auth of auths) + assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(mixedClaim, auth.playerId)); } finally { f.sqlite.close(); } });