diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index c4f2bcd6..f82bb668 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -7,19 +7,19 @@ Expansion starts and full rules/publication acceptance remain gated.** ## Current checkpoint and work The explicit [Semuta development preview](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime) -pauses a clean public ordinary-card discard, the used Box after a -paid search, early-ended Ornithopter, public used Distrans after a -private transfer, or a final definite Truthtrance after its answer -is bound. Every seat sees the same neutral offer independently of -hidden Semuta possession. The Distrans gift remains private with -its recipient; Truthtrance history stays fixed through the claim. -A holder with a free hand slot can recover the fresh other-player -discard and dispose Semuta; saved effects and the movement queue -resume once. All-seat decline, minimal legal AI, exact JSON/SQLite -continuation and competing-room CAS are connected. Phone width -showed the owned Commit and rival Continue event without a private -card face. Promise-bound and queued Truthtrance, completed-flight -arrival, competing transactions, multi-card and most other producers, +pauses a clean public ordinary-card discard, used Box after paid search, +early-ended Ornithopter, public used Distrans after private transfer, +final definite Truthtrance or movement-order Juice of Sapho after +its queue reorder. Every seat sees the same neutral offer independently +of hidden Semuta possession. The private gift stays with its recipient, +Truthtrance history stays fixed, and Sapho's first/last queue resumes +once after claim or decline. A holder with a free hand slot can recover +the fresh other-player discard and dispose Semuta. All-seat decline, +minimal legal AI, exact JSON/SQLite continuation and competing-room +CAS are connected. Phone width showed the owned Commit and rival +Continue event without a private card face. Auction/battle Sapho, +promise-bound and queued Truthtrance, 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 the free-slot guard are product safety choices, not publisher rulings. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 4168d9f7..057db8a4 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,40 @@ # Dune implementation status +## 30 September 2026 — Semuta after clean movement-order Sapho + +The explicit Richese Semuta preview now pauses after Juice of Sapho +physically discards its public card and commits a clean first/last +reorder of remaining combined shipment and movement turns. Every seat +sees the same neutral event before the active turn changes. A legal +holder can claim only the used Sapho and discard Semuta; all-seat +decline leaves Sapho in the ordinary pile. Both paths resume the +already committed order once, retaining protected last and prior +Guild timing without restarting any completed turn. + +Genuine setup covers first reordering, last protection, all-seat +decline, one physical claim, exact card census and JSON views. A +corrupted saved queue, newly spent movement, changed Basic/Advanced +profile or altered faction roster rejects before claim; an isolated +migrated SQLite room restores the paused reorder and commits an +authenticated claim with unchanged seat rows. Independent rules +review found the initially missing timing-profile binding; the +failing-before/passing-after regression now covers both Advanced +mode and Guild identity. Auction and battle Sapho, competing parents, +full-hand ordering, normal Richese starts and deployed acceptance +remain outside scope. + +Follow-up rules and independent privacy reviews found no remaining +scoped defect. A throwaway production-engine run completed genuine +AI setup, Sapho first reorder, neutral Semuta offer and bot claim; +the saved turn advanced to the reordered actor exactly once. The +phone-width rules guide rendered the bounded Sapho path as Partial. +An initial `npm run check` failed one expansion-roster CLI resume +while source edits were concurrent; the focused case passed after +source stabilized. The stable rerun passed typecheck, lint and +5,807/5,807 offline cases; `npm run build` and all 55 Vite development +HTTP cases passed after the timing-profile fix. No deployed revision +was exercised. + ## 30 September 2026 — Semuta after a final definite Truthtrance answer The explicit Richese Semuta preview now pauses the public card discard diff --git a/docs/RICHESE_COMPONENTS.md b/docs/RICHESE_COMPONENTS.md index ee8d3e4a..b51b3d61 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, paid Box, early-ended Ornithopter, completed private Distrans and final definite Truthtrance discards; no private gift or unresolved promise becomes 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. +- Semuta Drug now has an explicit bounded development preview for clean public ordinary-card, paid Box, early-ended Ornithopter, completed private Distrans, final definite Truthtrance and movement-order Sapho discards; private gifts, unresolved promises and auction/battle Sapho are not fresh targets. 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). @@ -113,8 +113,9 @@ 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 now work after clean public ordinary-card discards, the used Box after a paid search, early-ended -Ornithopter, clean private Distrans transfer and final definite -Truthtrance without a queued question or bound promise. Other semantic -discard families and full-hand ordering remain unconnected; the +Ornithopter, clean private Distrans, final definite Truthtrance and +movement-order Sapho after its first/last queue is committed. Queued +questions, promise-bound answers, auction/battle Sapho 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 ae86dbfa..1df42684 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -21,6 +21,13 @@ promise or competing parent qualifies. A claim may move that consumed card, never re-answer the question or rewrite its history; other Truthtrance continuations remain automatic. +Clean movement-order Sapho now stages `saphoMovementDiscard` after +its finite first/last queue and public card disposal are committed. +The reaction retires before `movementTurn` opens the new actor's turn; +the queue, prior active seat and protected-last receipt bind saved +continuation. Once Around and Battle Sapho remain outside this +Semuta preview. + ## 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 d0a5b8ed..2fd366fe 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -65,15 +65,17 @@ 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, an Ornithopter retired by ending movement early, the used Distrans after -a clean private transfer, or a final definite Truthtrance after its -answer is bound**, provided the parent transaction is clean. Every seat -sees a neutral fresh-discard opportunity regardless of hidden Semuta -possession. The flight's committed movement and escrow remain intact; -a claim or all-seat decline advances its saved queue once. Distrans's -transferred card stays privately with its recipient and cannot be -claimed; only the played public Distrans enters the fresh batch. The -Truthtrance's public answer and history are already committed before -the offer, and neither is re-evaluated afterward. +a clean private transfer, a final definite Truthtrance after its answer +is bound, or movement-order Juice of Sapho after its queue is committed**, +provided the parent transaction is clean. Every seat sees a neutral +fresh-discard opportunity regardless of hidden Semuta possession. The +flight's committed movement and escrow remain intact; a claim or +all-seat decline advances its saved queue once. Distrans's transferred +card stays privately with its recipient and cannot be claimed; only +the played public Distrans enters the fresh batch. Truthtrance's +public answer and history are already committed before the offer. +Sapho's first/last queue and protected Guild-last choice stay committed; +only the turn-activation suffix waits for the reaction. Each seat can Continue; only the canonical holder with a free hand slot and a card discarded by another player can commit. The sole @@ -90,11 +92,12 @@ 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, queued or promise-bound Truthtrance 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, queued or promise-bound Truthtrance, auction/battle +Sapho 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 35c5b03c..fe6408cf 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1143,6 +1143,16 @@ export type Game = { resume: NonNullable['resume']; stateSignature: string; } + | { + kind: 'saphoMovementDiscard'; + player: string; + card: string; + event: string; + mode: 'first' | 'last'; + previousActive: string; + resume: NonNullable['resume']; + stateSignature: string; + } | { kind: 'truthtranceDiscard'; consumed: TruthQueueEntry; @@ -4681,6 +4691,25 @@ function distransDiscardSignature(g: Game, c: DistransDiscardContinuation) { recipientHand: recipient.hand.map(card => card.id), }); } +type SaphoMovementDiscardContinuation = Extract< + NonNullable['continuation'], + { kind: 'saphoMovementDiscard' } +>; +function saphoMovementDiscardSignature(g: Game, c: SaphoMovementDiscardContinuation) { + return JSON.stringify({ + player: c.player, card: c.card, event: c.event, mode: c.mode, + previousActive: c.previousActive, + parent: nullentropyParentSignature(g, c.resume, c.event), + turn: g.turn, phase: g.phase, advanced: g.advanced, + active: g.active, order: g.order, + remaining: g.movementRemaining, protectedLast: g.saphoMovementLast, + guildTimingGranted: g.guildTimingGranted, guildTimingLocked: g.guildTimingLocked, + hajr: g.hajr, karamaShipping: g.karamaShipping, choamMovement: g.choamMovement, + players: g.players.map(p => ({ + id: p.id, faction: p.faction, moved: p.moved, shipped: p.shipped, + })), + }); +} type TruthDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'truthtranceDiscard' } @@ -5095,6 +5124,34 @@ function treacheryDiscardIntegrity(g: Game) { c.stateSignature === distransDiscardSignature(g, c), 'The completed private Distrans transfer no longer matches its fresh discard.', ); + } else if (continuation?.kind === 'saphoMovementDiscard') { + const c = continuation, entry = batch.entries[0]; + requireRule( + g.phase === 5 && g.active === c.previousActive && + c.event === `movement:${g.turn}` && + g.players.some(p => p.id === c.player) && + Array.isArray(g.movementRemaining) && + g.movementRemaining.length > 1 && + new Set(g.movementRemaining).size === g.movementRemaining.length && + g.movementRemaining.every(id => g.order.includes(id)) && + g.movementRemaining.includes(c.previousActive) && + (c.mode === 'first' + ? g.movementRemaining[0] === c.player && + c.previousActive !== c.player && !g.saphoMovementLast + : c.mode === 'last' && + g.movementRemaining.at(-1) === c.player && + g.saphoMovementLast?.event === c.event && + g.saphoMovementLast.turn === g.turn && + g.saphoMovementLast.player === c.player) && + batch.cause === 'sapho:movement' && batch.entries.length === 1 && + entry.publicFace && entry.discardedBy === c.player && + entry.card.id === c.card && + richeseCardDefinition(entry.card)?.card.effect === 'juiceOfSapho' && + !c.resume.response && !c.resume.decision && + !c.resume.pendingKarama && !c.resume.phaseOpening && + c.stateSignature === saphoMovementDiscardSignature(g, c), + 'The used Juice of Sapho no longer matches its committed movement order.', + ); } else if (continuation?.kind === 'truthtranceDiscard') { const c = continuation, entry = batch.entries[0], @@ -5685,8 +5742,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, early-ended Ornithopter flight, clean Distrans transfer or final - * definite Truthtrance answer without a new promise or parent transaction. */ + * Box search, early-ended Ornithopter, clean Distrans transfer, final definite + * Truthtrance answer or clean Sapho movement reorder. */ function semutaOfferSupported( g: Game, continuation: NonNullable['continuation'], @@ -5695,11 +5752,14 @@ function semutaOfferSupported( if (continuation.kind !== 'ordinaryCardDiscard' && continuation.kind !== 'nullentropyDiscard' && continuation.kind !== 'distransDiscard' && + continuation.kind !== 'saphoMovementDiscard' && !(continuation.kind === 'ornithopterDiscard' && continuation.source === 'end') && !(continuation.kind === 'truthtranceDiscard' && continuation.consumed.source === undefined && continuation.remaining === null && continuation.promise === null)) return false; - if ((continuation.kind === 'distransDiscard' || continuation.kind === 'truthtranceDiscard') && + if ((continuation.kind === 'distransDiscard' || + continuation.kind === 'truthtranceDiscard' || + continuation.kind === 'saphoMovementDiscard') && (g.pendingChoamMove || g.pendingIxMove || g.pendingFremenMove || g.ornithopter || g.summonedWorm || g.wormRides.length > 0)) return false; return g.semutaPreview === true && g.status === 'playing' && @@ -5869,6 +5929,12 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; + } else if (next.kind === 'saphoMovementDiscard') { + g.response = next.resume.response; + g.decision = next.resume.decision; + g.pendingKarama = next.resume.pendingKarama; + g.phaseOpening = next.resume.phaseOpening; + if (g.active !== g.movementRemaining?.[0]) movementTurn(g); } else if (next.kind === 'ornithopterDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; @@ -14214,12 +14280,27 @@ function playSapho(g: Game, p: Player, action: Action) { g.movementRemaining = [...next.remaining]; if (option.mode === 'last') g.saphoMovementLast = { event: option.event, turn: g.turn, player: p.id }; - discard(g, p, 'richese-juice-of-sapho'); + const used = discard(g, p, 'richese-juice-of-sapho'); log( g, `${p.name} discarded Juice of Sapho to take the ${option.mode} remaining combined shipment and movement turn${option.mode === 'last' ? ', including after the Spacing Guild' : ''}. Completed turns and physical storm order are unchanged.`, { faction: p.faction, name: 'Juice of Sapho' }, ); + if (g.semutaPreview) { + const continuation: SaphoMovementDiscardContinuation = { + kind: 'saphoMovementDiscard', player: p.id, card: used.id, + event: option.event, mode: option.mode, previousActive: g.active!, + resume: { response: g.response, decision: g.decision, + pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, + stateSignature: '', + }; + const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; + if (semutaOfferSupported(g, continuation, entries)) { + continuation.stateSignature = saphoMovementDiscardSignature(g, continuation); + stageTreacheryDiscard(g, 'sapho:movement', entries, continuation); + return; + } + } // Preserve the Guild decision already settled for an unchanged current actor. if (g.active !== g.movementRemaining[0]) movementTurn(g); } diff --git a/game/reference.ts b/game/reference.ts index 7f36637b..8f930c7e 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -488,6 +488,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'During your open pre-plan preparation, use Sapho to become the current battle’s aggressor before declaring ready. You win ordinary ties, while the Habbanya Stronghold advantage still takes precedence. Physical participants, plans and later battle-choice order do not swap. The accepted priority survives refresh.', 'Finish existing shipment, movement, card preparation and pending decisions before changing order. Your hand panel lists only currently available choices. A reserved card or a position you already hold cannot be selected.', 'Completed bids and turns stay completed. Storm order, committed funding and movement counters do not reset. Refreshing preserves the same remaining opportunities and any declared last position.', + 'In the explicitly opted-in Semuta preview, a clean movement-order Sapho discard pauses after its queue reorder. Every seat receives the same neutral offer before the active turn changes. A claim transfers only the used Sapho card; the saved queue and any protected last position resume once. Auction and battle Sapho discards remain automatic without this preview reaction.', 'These are the supported development controls, not additional printed restrictions. Later aggressor intervention, ordinary cyclic auction scope, other phase ordering and intervention during a partly completed combined turn remain unfinished. Silent bids are simultaneous; their storm-order tie rule is unchanged.', ], example: @@ -704,7 +705,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Synchronous two-card custody, private transfer, normal Distrans disposal, both-card reservations and prospective transaction/promise checks are integrated. Open-lot timing and self-transfer remain explicit guards; Semuta reaction to Distrans disposal is not yet integrated.', + 'Synchronous two-card custody, private transfer, normal Distrans disposal, both-card reservations and prospective transaction/promise checks are integrated. The explicit Semuta preview offers a neutral reaction only after a clean completed transfer; open-lot timing, competing transactions and self-transfer remain guarded.', }, { area: 'Player controls', @@ -1155,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 a clean public ordinary-card discard, completed paid Box search, early-ended Ornithopter flight, clean private Distrans transfer or final definite Truthtrance answer. Continue or commit held Semuta with a free hand slot; only the freshly discarded public card transfers. A Distrans gift remains private with its recipient, and the Truthtrance answer/history is already bound. Competing transactions, later queued questions, 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, paid Box search, early-ended Ornithopter flight, private Distrans transfer, final definite Truthtrance answer or completed movement-order Sapho discard. Continue or commit held Semuta with a free hand slot; only the freshly discarded public card transfers. Private gifts and bound answers stay intact; a Sapho claim resumes its saved movement queue once. Competing transactions, auction and battle Sapho, 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 +1166,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, paid Box, early-ended Ornithopter, private Distrans and final definite Truthtrance discards with a neutral all-seat offer. Committing held Semuta claims only the other-player discarded card and discards Semuta. The private gift stays with its recipient, the answered Truthtrance history remains public, and saved effects or movement resume once. Promise-bound or queued questions, competing transactions, other typed producers and full-hand exchange remain unfinished.', + 'An explicitly opted-in Richese profile interrupts clean public ordinary-card, paid Box, early-ended Ornithopter, private Distrans, final definite Truthtrance and movement-order Sapho discards with a neutral all-seat offer. Committing held Semuta claims only the other-player discarded card and discards Semuta. Private gifts and answered history remain intact; completed effects, Sapho ordering and movement resume once. Auction/battle Sapho, competing transactions, other typed producers and full-hand exchange remain unfinished.', }, { area: 'Player controls' as const, @@ -1183,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, early-ended Ornithopter, clean Distrans and final definite Truthtrance 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, clean Distrans, final definite Truthtrance and movement-order Sapho 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 legal AI participation cover clean ordinary-card, paid Box, early-ended Ornithopter, private Distrans and final definite Truthtrance 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, private Distrans, final definite Truthtrance and movement-order Sapho 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 0e919f30..50dd804e 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -369,3 +369,83 @@ void test('a final definite Truthtrance answer offers its consumed card after pu assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id)); assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event })); }); + +void test('a completed Sapho first-movement reorder offers its public card before changing the active turn', () => { + const { g, semuta, hajr } = fixture(); + player(g, 'a').hand = []; + g.deck.push(hajr); + const sapho = take(g, 'richese-juice-of-sapho'); + player(g, 'e').hand.push(sapho); + const original = inventory(g); + const pending = applyAction(g, 'e', { + type: 'card', card: sapho.id, scope: 'movement', mode: 'first', + event: `movement:${g.turn}`, + }); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.deepEqual(pending.movementRemaining, ['e', 'a', 'r']); + assert.equal(pending.active, 'a'); + assert.equal(pending.discard.filter(card => card.id === sapho.id).length, 1); + const event = pending.pendingTreacheryDiscard!.batch.event; + assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true); + assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending)); + 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.active, 'e'); + assert.deepEqual(declined.movementRemaining, ['e', 'a', 'r']); + assert.equal(declined.discard.filter(card => card.id === sapho.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(claimed.active, 'e'); + assert.deepEqual(claimed.movementRemaining, ['e', 'a', 'r']); + assert.equal(player(claimed, 'r').hand.filter(card => card.id === sapho.id).length, 1); + assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1); + assert.equal(claimed.discard.some(card => card.id === sapho.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 })); +}); + +void test('Sapho last protects the saved queue and its discard receipt against alteration', () => { + const { g, semuta, hajr } = fixture(); + player(g, 'a').hand = []; + g.deck.push(hajr); + const sapho = take(g, 'richese-juice-of-sapho'); + player(g, 'a').hand.push(sapho); + const pending = applyAction(g, 'a', { + type: 'card', card: sapho.id, scope: 'movement', mode: 'last', + event: `movement:${g.turn}`, + }); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.deepEqual(pending.movementRemaining, ['r', 'e', 'a']); + assert.equal(pending.active, 'a'); + assert.equal(pending.saphoMovementLast?.player, 'a'); + const tampered = reload(pending); + tampered.movementRemaining = ['e', 'r', 'a']; + const before = JSON.stringify(tampered); + assert.throws(() => viewGame(tampered, 'r')); + assert.throws(() => normalizeAutomaticGame(tampered)); + assert.equal(JSON.stringify(tampered), before); + const spent = reload(pending); + player(spent, 'a').moved = 1; + assert.throws(() => applyAction(spent, 'r', { + type: 'semutaCommit', event: pending.pendingTreacheryDiscard!.batch.event, + })); + const profile = reload(pending); + profile.advanced = !profile.advanced; + assert.throws(() => viewGame(profile, 'r')); + const roster = reload(pending); + player(roster, 'e').faction = 'guild'; + assert.throws(() => viewGame(roster, 'r')); + const claimed = applyAction(reload(pending), 'r', { type: 'semutaCommit', + event: pending.pendingTreacheryDiscard!.batch.event }); + assert.equal(claimed.active, 'r'); + assert.deepEqual(claimed.movementRemaining, ['r', 'e', 'a']); + assert.equal(claimed.saphoMovementLast?.player, 'a'); + assert.equal(player(claimed, 'r').hand.filter(card => card.id === sapho.id).length, 1); + assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1); + assert.deepEqual(normalizeAutomaticGame(reload(claimed)), reload(claimed)); +}); diff --git a/tests/semuta-recovery.test.ts b/tests/semuta-recovery.test.ts index f305f380..9d2d3a42 100644 --- a/tests/semuta-recovery.test.ts +++ b/tests/semuta-recovery.test.ts @@ -247,5 +247,38 @@ void test('authenticated clean Semuta producers claim once through restart and r assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats); for (const auth of auths) assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(recovered, auth.playerId)); + + const ordered = structuredClone(recovered); + const again = take(ordered, SEMUTA_DRUG_ID); + ordered.deck.push(take(ordered, truth.id)); + seat(ordered, ids[0]).hand.push(again); + const sapho = take(ordered, 'richese-juice-of-sapho'); + seat(ordered, ids[2]).hand.push(sapho); + for (const p of ordered.players) { p.moved = 0; p.shipped = false; } + Object.assign(ordered, { turn: 7, phase: 5, active: ids[1], ready: [], + decision: null, response: null, phaseOpening: null, stormPending: null, + movementRemaining: [ids[1], ids[0], ids[2]], hajr: [] }); + ordered.version = recovered.version + 1; + assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?') + .run(JSON.stringify(ordered), ordered.version, code, recovered.version).changes, 1); + const reordered = await act(2, { type: 'card', card: sapho.id, scope: 'movement', + mode: 'first', event: `movement:${ordered.turn}` }); + assert.equal(reordered.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.deepEqual(reordered.movementRemaining, [ids[2], ids[1], ids[0]]); + assert.equal(reordered.active, ids[1]); + await f.restart().continueRoomAutomatic(code, clock); + assert.equal((await f.restart().readRoom(code)).version, reordered.version); + assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true); + const reorderedClaim = await act(0, { type: 'semutaCommit', + event: reordered.pendingTreacheryDiscard!.batch.event }); + assert.equal(reorderedClaim.pendingTreacheryDiscard, null); + assert.equal(reorderedClaim.active, ids[2]); + assert.deepEqual(reorderedClaim.movementRemaining, [ids[2], ids[1], ids[0]]); + assert.equal(seat(reorderedClaim, ids[0]).hand.filter(card => card.id === sapho.id).length, 1); + assert.equal(reorderedClaim.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1); + assert.equal(reorderedClaim.discard.some(card => card.id === sapho.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(reorderedClaim, auth.playerId)); } finally { f.sqlite.close(); } });