From 162f01c160711730371061be638980468eb36e3e Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:53:59 +1000 Subject: [PATCH] Offer Semuta after clean private Distrans transfer --- docs/CURRENT_STATUS.md | 26 +++++----- docs/IMPLEMENTATION_STATUS.md | 32 +++++++++++++ docs/RICHESE_COMPONENTS.md | 2 +- docs/SEMUTA_DISCARD_CONTINUATIONS.md | 10 ++++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 41 ++++++++-------- game/engine.ts | 69 ++++++++++++++++++++++++++- game/reference.ts | 10 ++-- tests/semuta-engine.test.ts | 57 ++++++++++++++++++++++ tests/semuta-recovery.test.ts | 36 +++++++++++++- 9 files changed, 243 insertions(+), 40 deletions(-) diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 14285e5e..96717799 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -8,18 +8,20 @@ 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) now pauses a clean public ordinary-card discard, the used Box after -a completed paid search, or an Ornithopter retired on early movement -ending with the same neutral offer for every seat, independently of -hidden Semuta possession. A holder with a free hand slot can commit -to the one fresh other-player card; its physical claim and Semuta -disposal resume the original effect, Box parent or movement queue once. -All-seat decline, four minimal legal AI profiles, exact JSON/SQLite -continuation and competing-room CAS are connected. Phone width -showed the owned Commit control and the identical rival Continue -event without a private card face. Completed-flight arrival, multi-card -and most other discard producers, full-hand ordering, normal Richese -starts and deployed acceptance remain unfinished; neutral passes and -the free-slot guard are product safety choices, not publisher rulings. +a completed paid search, an Ornithopter retired on early movement +ending, or the public used Distrans after a clean private transfer. +Every seat sees the same neutral offer independently of hidden Semuta +possession; the transferred card remains private with its recipient. +A holder with a free hand slot can commit to the fresh other-player +discard; its physical claim and Semuta disposal resume the original +effect, Box parent or movement queue once. All-seat decline, four +minimal legal AI profiles, exact JSON/SQLite continuation and +competing-room CAS are connected. Phone width showed the owned +Commit control and identical rival Continue event without a private +card face. Completed-flight arrival, competing transactions, +multi-card and most other producers, full-hand ordering, normal +Richese starts and deployed acceptance remain unfinished; neutral +passes and free-slot guard are product safety choices, not 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 93884f35..01527e76 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,37 @@ # Dune implementation status +## 30 September 2026 — Semuta after a completed private Distrans transfer + +An explicitly opted-in Richese preview now pauses after a clean +Distrans action physically gives one privately selected card to its +recipient and publicly discards the used Distrans. Every seat sees +the same neutral fresh-discard event, not the transferred card. A +legal holder may recover only the public used Distrans, discarding +Semuta; all-seat decline leaves Distrans in the ordinary pile. The +recipient keeps its transferred card on either path. Existing +pending gifts, bid or response contexts remain outside this bounded +reaction; ordinary transfers retain their previous completion. + +Genuine setup and isolated migrated SQLite room cases cover physical +custody, owner-only commit and legal AI participation, all-seat +decline, private recipient card projection, saved restart without +auto-advance, exact authenticated claim and unchanged seat rows. +Incomplete producer families, full-hand ordering, normal Richese +starts and deployed acceptance remain gated. + +Independent privacy review found no scoped leak. Rules review found +that a duplicated transferred card in removed Richese custody escaped +the initial saved receipt check; a failing-before/passing-after +regression now requires the recipient's card to be unique across +all physical non-discard zones. Follow-up review found no remaining +scoped defect. A throwaway direct engine run completed genuine AI +setup, private transfer, neutral offer and bot claim; the nonrecipient +view omitted the given card and the recipient retained it. The +phone-width rules guide visibly marks this path Partial. `npm run check` +passed typecheck, lint and 5,804/5,804 offline cases after the custody +fix; `npm run build` and all 55 Vite development HTTP cases passed. No +production deployment was verified. + ## 30 September 2026 — Semuta after an early-ended Ornithopter flight The explicit Richese Semuta preview now interrupts the one public diff --git a/docs/RICHESE_COMPONENTS.md b/docs/RICHESE_COMPONENTS.md index f78a2e64..29a6fe9e 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 now has an explicit bounded development preview for clean public ordinary-card, completed paid Box and early-ended Ornithopter discards; it does not implement other producers, full-hand ordering or normal Richese starts. Mirror Weapon and Juice of Sapho have separate bounded paths, not complete combined-mode certification. Other unfinished faction/module systems, mobile acceptance, difficulty-strength calibration and complete Advanced/expansion games still prevent goal completion. No start gate was removed. +- Semuta Drug now has an explicit bounded development preview for clean public ordinary-card, completed paid Box, early-ended Ornithopter and completed private Distrans transfer discards; the private transferred card is not a fresh target. 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 faction/module systems, mobile acceptance, difficulty-strength 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). diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 3dcf6d56..4c5aa4d8 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -4,6 +4,16 @@ Read-only engine audit, 2026-09-07. Read the complete `docs/SEMUTA_DRUG_ENGINE_A Update: the exchange producers, three battle producers, Sabotage, Robbery overflow and completed Nullentropy Box searches, Ornithopter escrow retirement, definite Truthtrance answers and the five ordinary completed-card effects below now have production automatic continuation frames; see [exchange checkpoint](TREACHERY_DISCARD_CONTINUATIONS.md), [battle checkpoint](BATTLE_DISCARD_CONTINUATIONS.md), [forced Terror checkpoint](TERROR_DISCARD_CONTINUATIONS.md), [Box checkpoint](BOX_DISCARD_CONTINUATIONS.md), [Ornithopter checkpoint](ORNITHOPTER_DISCARD_CONTINUATIONS.md), [Truthtrance checkpoint](TRUTHTRANCE_DISCARD_CONTINUATIONS.md) and [ordinary-card checkpoint](ORDINARY_CARD_DISCARD_CONTINUATIONS.md). The remaining table is an implementation inventory, not a claim that Semuta is active. +30 September 2026 checkpoint: the explicit Semuta development preview +now stages a `distransDiscard` receipt only after a clean completed +private transfer. Its fresh batch contains the public played Distrans, +not the private card already transferred to its recipient. Neutral +all-seat acknowledgement precedes closure; claim or decline retires +the saved receipt without redoing the hand transfer. Pending responses, +gifts and competing parents retain the original direct transfer +behavior without this Semuta reaction. The table below remains the +broader producer/continuation inventory, not a claim of complete coverage. + ## Main findings A post-`applyAction` pile diff cannot implement immediate Semuta. Ix Ambassador and Ixian ally replacement discard then draw directly. Ordinary Karama cancellation/purchase can reach replacement, Harkonnen bonus or a new auction pool indirectly. A stopped frame must resume at a named stage, never rerun its original public action. diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index d31c856d..77f0c7b1 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -64,31 +64,34 @@ Do not ask again about normal targets, forced-discard owner bookkeeping, simulta The explicit `semuta` development profile starts a fresh Richese roster without opening ordinary expansion starts. It now interrupts **one public ordinary-card discard, the used Box after a completed paid Nullentropy -search, or an Ornithopter retired by ending movement early**, provided -the parent transaction is clean, with a neutral fresh-discard opportunity -for every seat regardless of hidden Semuta possession. The flight's -already committed movement and physical escrow remain intact; a claim -or all-seat decline advances the saved movement queue exactly once. -Each seat can Continue; only the canonical holder with a free hand slot -and a card discarded by another player can commit. The sole eligible -fresh card transfers automatically in the same accepted action, then -Semuta goes to the normal discard pile. The saved ordinary effect or -Box parent resumes once; passing by every seat preserves the original -discard and effect. No face from an undisclosed event is projected -before commitment. Owner-only multi-candidate selection support exists -in the reaction component and bot adapter, but no multi-discard -producer is activated in this bounded runtime. +search, an Ornithopter retired by ending movement early, or the used +Distrans after a clean completed private transfer**, provided the parent +transaction is clean, with a neutral fresh-discard opportunity for every +seat regardless of hidden Semuta possession. The flight's already +committed movement and physical escrow remain intact; a claim or +all-seat decline advances the saved movement queue exactly once. +Distrans's transferred card stays privately with its recipient and +cannot be claimed; only the played public Distrans enters this fresh +batch. Each seat can Continue; only the canonical holder with a free +hand slot and a card discarded by another player can commit. The sole +eligible fresh card transfers automatically in the same accepted action, +then Semuta goes to the normal discard pile. The saved ordinary effect +or Box parent resumes once; passing by every seat preserves the +original discard and effect. No face from an undisclosed event is +projected before commitment. Owner-only multi-candidate selection +support exists in the reaction component and bot adapter, but no +multi-discard producer is activated in this bounded runtime. This neutral all-seat acknowledgement is a conservative **application policy**, not a newly discovered publisher timing rule. The explicit 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 flights with -arrival follow-up and other typed discard producers retain their existing -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 +arrival follow-up and other typed discard producers retain their +existing 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/engine.ts b/game/engine.ts index c9882b60..b369aa28 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1134,6 +1134,15 @@ export type Game = { resume: NonNullable['resume']; stateSignature: string; } + | { + kind: 'distransDiscard'; + owner: string; + recipient: string; + transferred: string; + card: string; + resume: NonNullable['resume']; + stateSignature: string; + } | { kind: 'truthtranceDiscard'; consumed: TruthQueueEntry; @@ -4658,6 +4667,20 @@ function stageOrdinaryCardDiscard(g: Game, p: Player, card: Card) { next, ); } +type DistransDiscardContinuation = Extract< + NonNullable['continuation'], + { kind: 'distransDiscard' } +>; +function distransDiscardSignature(g: Game, c: DistransDiscardContinuation) { + const owner = getPlayer(g, c.owner), recipient = getPlayer(g, c.recipient); + return JSON.stringify({ + owner: c.owner, recipient: c.recipient, transferred: c.transferred, + card: c.card, parent: nullentropyParentSignature(g, c.resume, `distrans:${c.card}`), + turn: g.turn, phase: g.phase, active: g.active, + ownerHand: owner.hand.map(card => card.id), + recipientHand: recipient.hand.map(card => card.id), + }); +} type TruthDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'truthtranceDiscard' } @@ -5053,6 +5076,25 @@ function treacheryDiscardIntegrity(g: Game) { : true, 'The completed ordinary-card outcome has changed.', ); + } else if (continuation?.kind === 'distransDiscard') { + const c = continuation, entry = batch.entries[0]; + const owner = g.players.find(p => p.id === c.owner); + const recipient = g.players.find(p => p.id === c.recipient); + requireRule( + owner && recipient && owner !== recipient && + batch.cause === 'distrans' && batch.entries.length === 1 && + entry.publicFace && entry.discardedBy === c.owner && + entry.card.id === c.card && + richeseCardDefinition(entry.card)?.card.effect === 'distrans' && + recipient.hand.filter(card => card.id === c.transferred).length === 1 && + !owner.hand.some(card => card.id === c.transferred || card.id === c.card) && + outside.filter(card => card.id === c.transferred).length === 1 && + !g.discard.some(card => card.id === c.transferred) && + !c.resume.response && !c.resume.decision && + !c.resume.pendingKarama && !c.resume.phaseOpening && + c.stateSignature === distransDiscardSignature(g, c), + 'The completed private Distrans transfer no longer matches its fresh discard.', + ); } else if (continuation?.kind === 'truthtranceDiscard') { const c = continuation, entry = batch.entries[0], @@ -5643,8 +5685,8 @@ function treacheryDiscardIntegrity(g: Game) { } else throw new RuleError('Unknown saved discard continuation.'); } /** Bounded public single-card reaction after a completed ordinary effect, paid - * Box search, or early-ended Ornithopter flight. A completed flight's arrival - * may open further entry windows, so that continuation remains automatic. */ + * Box search, early-ended Ornithopter flight or clean Distrans transfer. A + * completed flight's arrival may open further entry windows and stays automatic. */ function semutaOfferSupported( g: Game, continuation: NonNullable['continuation'], @@ -5652,7 +5694,11 @@ function semutaOfferSupported( ) { if (continuation.kind !== 'ordinaryCardDiscard' && continuation.kind !== 'nullentropyDiscard' && + continuation.kind !== 'distransDiscard' && !(continuation.kind === 'ornithopterDiscard' && continuation.source === 'end')) return false; + if (continuation.kind === 'distransDiscard' && + (g.pendingChoamMove || g.pendingIxMove || g.pendingFremenMove || + g.ornithopter || g.summonedWorm || g.wormRides.length > 0)) return false; return g.semutaPreview === true && g.status === 'playing' && g.players.some(p => p.faction === 'richese') && entries.length === 1 && entries[0].publicFace && @@ -5815,6 +5861,11 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; g.truthtrance = next.remaining; + } else if (next.kind === 'distransDiscard') { + g.response = next.resume.response; + g.decision = next.resume.decision; + g.pendingKarama = next.resume.pendingKarama; + g.phaseOpening = next.resume.phaseOpening; } else if (next.kind === 'ornithopterDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; @@ -23697,6 +23748,20 @@ function applyActionInner( `${p.name} played Distrans to give ${recipient.name} one private Treachery Card, then discarded Distrans. No spice, purchase bonus or recipient confirmation is required.`, { faction: p.faction, name: 'Distrans' }, ); + if (g.semutaPreview && !g.pendingTreacheryDiscard) { + const continuation: DistransDiscardContinuation = { + kind: 'distransDiscard', owner: p.id, recipient: recipient.id, + transferred: result.transferred.id, card: result.discarded.id, + resume: { response: g.response, decision: g.decision, + pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, + stateSignature: '', + }; + const entries = [{ card: result.discarded, discardedBy: p.id, publicFace: true }]; + if (semutaOfferSupported(g, continuation, entries)) { + continuation.stateSignature = distransDiscardSignature(g, continuation); + stageTreacheryDiscard(g, 'distrans', entries, continuation); + } + } return g; } if (t === 'richeseGift') { diff --git a/game/reference.ts b/game/reference.ts index e7b03376..f3db339a 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -690,7 +690,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A transfer must preserve binding battle answers, compulsory discards and both sides of a pending Harkonnen hand exchange. The recipient must retain room for its mandatory incoming return. Optional proposals that become unavailable can still finish through their existing decline or no-effect path.', 'A different legal transfer can fill or free space for a pending Richese gift. The ordinary Distrans transfer resolves first, and the pending gift rechecks capacity before moving its own card. A canceled alliance gift does not automatically prevent using this separate card effect.', 'All four AI profiles use their own legal projected cards. They may give an allied Bene Gesserit or CHOAM a Worthless card, or share a duplicate ordinary weapon, defense or Karama to free a full hand. They do not inspect the recipient’s private cards and preserve existing decision priority.', - 'Semuta responses to a fresh Distrans discard, mobile acceptance, the unresolved bid and self-transfer cases, and full combined Richese games remain unfinished. The separate clean public ordinary-card Semuta preview does not enable this transfer composition, all remaining Richese cards or expansion starts.', + 'The explicitly opted-in Semuta preview now offers a neutral all-seat response to the public used Distrans after a clean completed private transfer. The given card remains private and belongs to its recipient; only the used Distrans is claimable. Responses or decisions already in progress, unresolved bid and self-transfer rulings, full combined Richese games and ordinary expansion starts remain unfinished.', ], related: [ 'card-richese-distrans', @@ -1155,7 +1155,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 a clean public ordinary-card discard, a completed paid Box search, or early ending an Ornithopter flight. Continue or commit your held Semuta with a free hand slot; the sole eligible card transfers automatically before the saved turn advances. A completed flight with arrival follow-up, other discard producers, full-hand sequencing and normal Richese starts remain unfinished.' + ? 'In an explicitly opted-in Richese development game, every seat sees a neutral response after a clean public ordinary-card discard, a completed paid Box search, early ending an Ornithopter flight, or completing a clean private Distrans transfer. Continue or commit your held Semuta with a free hand slot; only the freshly discarded public card transfers, never the private card given to its recipient. Completed-flight arrival, competing transactions, other discard producers, 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' @@ -1165,7 +1165,7 @@ 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, completed paid Box and early-ended Ornithopter discards with a neutral all-seat offer. Committing held Semuta atomically claims the sole eligible other-player card, discards Semuta and resumes the saved ordinary-card, Box or movement-turn parent once. Completed-flight arrival and other typed discard producers and full-hand exchange remain unfinished.', + 'An explicitly opted-in Richese profile interrupts clean public ordinary-card, completed paid Box, early-ended Ornithopter and completed private Distrans discards with a neutral all-seat offer. Committing held Semuta claims the sole eligible other-player discarded card and discards Semuta. The private Distrans gift remains with its recipient; the other saved effects and movement queue resume once. Completed-flight arrival, competing transactions, other typed producers and full-hand exchange remain unfinished.', }, { area: 'Player controls' as const, @@ -1183,13 +1183,13 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Documentation' as const, status: 'Partial' as const, detail: - 'The guide distinguishes bounded public ordinary-card, paid Box and early-ended Ornithopter previews, privacy-neutral acknowledgements and conservative free-slot policy from unresolved full-hand sequencing and remaining discard sources.', + 'The guide distinguishes bounded public ordinary-card, paid Box, early-ended Ornithopter and clean Distrans previews, privacy-neutral acknowledgements and conservative free-slot policy from unresolved full-hand sequencing and remaining discard sources.', }, { area: 'Verification' as const, status: 'Partial' as const, detail: - 'Genuine setup, physical card custody, neutral hidden-hand parity, saved JSON, authenticated SQLite CAS and all four legal AI profiles cover clean ordinary-card, paid Box and early-ended Ornithopter reactions. Other discard producers, full games and live deployment remain unverified.', + 'Genuine setup, physical card custody, neutral hidden-hand parity, saved JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, early-ended Ornithopter and private Distrans reactions. Other discard producers, 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 2875b8fa..4079e1bb 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -263,3 +263,60 @@ void test('a retired Ornithopter can be claimed after one flight group without r assert.deepEqual(normalizeAutomaticGame(reload(claimed)), reload(claimed)); assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event })); }); + +void test('a completed private Distrans transfer offers only its public used card', () => { + const { g, semuta, hajr } = fixture(); + const distrans = take(g, 'richese-distrans'); + player(g, 'a').hand.push(distrans); + const original = inventory(g); + const without = reload(g); + player(without, 'r').hand = []; + without.richeseCache!.push(semuta); + player(without, 'r').hand.push(take(without, 'richese-nullentropy-box')); + const pending = applyAction(g, 'a', { + type: 'card', card: distrans.id, target: 'e', give: hajr.id, + }); + const absent = applyAction(without, 'a', { + type: 'card', card: distrans.id, target: 'e', give: hajr.id, + }); + assert.equal(absent.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + for (const id of ['a', 'e']) + assert.deepEqual(viewGame(pending, id), viewGame(absent, id)); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.deepEqual(pending.pendingTreacheryDiscard?.batch.entries.map(entry => entry.card.id), [distrans.id]); + assert.equal(player(pending, 'e').hand.filter(card => card.id === hajr.id).length, 1); + assert.equal(player(pending, 'a').hand.some(card => card.id === hajr.id), false); + const event = pending.pendingTreacheryDiscard!.batch.event; + const stranger = JSON.stringify(viewGame(pending, 'r')); + assert.equal(stranger.includes(hajr.id), false); + assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true); + assert.equal(viewGame(pending, 'e').semutaReaction?.canCommit, false); + assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending)); + const corrupt = reload(pending); + if (corrupt.pendingTreacheryDiscard?.continuation.kind !== 'distransDiscard') + throw Error('Missing saved private transfer'); + corrupt.pendingTreacheryDiscard.continuation.transferred = semuta.id; + const before = JSON.stringify(corrupt); + assert.throws(() => viewGame(corrupt, 'r')); + assert.throws(() => applyAction(corrupt, 'r', { type: 'semutaCommit', event })); + assert.equal(JSON.stringify(corrupt), before); + const duplicate = reload(pending); + duplicate.richeseRemoved = [...(duplicate.richeseRemoved ?? []), structuredClone(hajr)]; + assert.throws(() => viewGame(duplicate, 'r')); + let declined = reload(pending); + for (const id of ['a', 'e', 'r']) + declined = applyAction(declined, id, { type: 'semutaPass', event }); + assert.equal(declined.pendingTreacheryDiscard, null); + assert.equal(declined.discard.filter(card => card.id === distrans.id).length, 1); + assert.deepEqual(inventory(declined), original); + const claimed = applyAction(reload(pending), 'r', botActions(viewGame(pending, 'r'))[0]!); + assert.equal(claimed.pendingTreacheryDiscard, null); + assert.equal(player(claimed, 'e').hand.filter(card => card.id === hajr.id).length, 1); + assert.equal(player(claimed, 'r').hand.filter(card => card.id === distrans.id).length, 1); + assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1); + assert.equal(claimed.discard.some(card => card.id === distrans.id), false); + assert.deepEqual(inventory(claimed), original); + for (const p of claimed.players) + assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id)); + assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event })); +}); diff --git a/tests/semuta-recovery.test.ts b/tests/semuta-recovery.test.ts index 3c15b971..7e946c53 100644 --- a/tests/semuta-recovery.test.ts +++ b/tests/semuta-recovery.test.ts @@ -16,7 +16,7 @@ function take(g: Game, identity: string) { throw new Error('The genuine setup lost a physical card.'); } -void test('authenticated ordinary and paid-Box discards preserve one Semuta claim through restart and room CAS', async () => { +void test('authenticated ordinary, paid Box, retired flight and private Distrans claims survive restart and room CAS', async () => { const f = unitStore(); try { const created = await f.rooms.createRoom('Semuta QA', 'richese', false, ['choam']); @@ -174,5 +174,39 @@ void test('authenticated ordinary and paid-Box discards preserve one Semuta clai assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats); for (const auth of auths) assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(flown, auth.playerId)); + + const transfer = structuredClone(flown); + const finalSemuta = take(transfer, SEMUTA_DRUG_ID); + const distrans = take(transfer, 'richese-distrans'); + transfer.richeseCache!.push(take(transfer, 'richese-nullentropy-box')); + seat(transfer, ids[0]).hand.push(finalSemuta); + seat(transfer, ids[1]).hand.push(distrans); + const given = seat(transfer, ids[1]).hand.find(card => card.id === selected)!; + assert.ok(given); + Object.assign(transfer, { turn: 5, phase: 5, active: ids[1], ready: [], + decision: null, response: null, phaseOpening: null, stormPending: null, + movementRemaining: [ids[1], ids[0], ids[2]], hajr: [] }); + transfer.version = flown.version + 1; + assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?') + .run(JSON.stringify(transfer), transfer.version, code, flown.version).changes, 1); + const handed = await act(1, { type: 'card', card: distrans.id, + target: ids[2], give: given.id }); + assert.equal(handed.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(seat(handed, ids[2]).hand.filter(card => card.id === given.id).length, 1); + await f.restart().continueRoomAutomatic(code, clock); + assert.equal((await f.restart().readRoom(code)).version, handed.version); + const observer = await f.restart().readSeatView(code, auths[0]); + assert.equal(observer.semutaReaction?.canCommit, true); + assert.equal(JSON.stringify(observer).includes(given.id), false); + const final = await act(0, { type: 'semutaCommit', + event: handed.pendingTreacheryDiscard!.batch.event }); + assert.equal(final.pendingTreacheryDiscard, null); + assert.equal(seat(final, ids[2]).hand.filter(card => card.id === given.id).length, 1); + assert.equal(seat(final, ids[0]).hand.filter(card => card.id === distrans.id).length, 1); + assert.equal(final.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1); + assert.equal(final.discard.some(card => card.id === distrans.id), false); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats); + for (const auth of auths) + assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(final, auth.playerId)); } finally { f.sqlite.close(); } });