diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 84d272f6..852e173e 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -12,11 +12,11 @@ the physical Ix deck. Clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, Once Around, movement-order, between-battles chooser or pre-plan aggressor Sapho, clean pre-leader Residual Poison, printed Karama canceling clean -CHOAM Charity, Inflation or Bene Gesserit Charity or purchasing a -clean normal-auction lot, battle-card and Ix-deck Thumper/Amal discards -open the same neutral offer regardless of hidden Semuta custody. -Karama waits after its public cost but before cancellation or the -reserved free purchase settles. +CHOAM Charity, Inflation or Bene Gesserit Charity or using a clean +normal-auction lot for a free purchase or winning-bid payment, +battle-card and Ix-deck Thumper/Amal discards open the same neutral +offer regardless of hidden Semuta custody. Karama waits after its +public cost but before cancellation or the reserved lot settles. Residual Poison commits one random death before the offer; only an uninspected battle with nobody ready qualifies. Once Around commits bidder order and frees a slot; other Sapho scopes commit chooser or @@ -32,11 +32,18 @@ full-hand ordering, normal Richese starts and deployed acceptance remain unfinished. Neutral passes and free-slot guard are product choices. -The clean printed Karama normal-auction purchase passed engine, -authenticated SQLite and guide/browser checks. `npm run check` passed -5,835 offline tests and the build passed. An isolated built-worker -HTTP suite passed 51/55 cases; four origin/malformed-request cases -returned 503 instead of 403/400. The HTTP gate remains open. +Clean printed Karama normal-auction purchase and winning-bid payment +passed engine and authenticated SQLite checks. `npm run check` passed +types, lint and 5,837 offline tests; the build and phone-width guide +inspection passed. A focused built-worker Advanced-lobby HTTP test +timed out at its 15-second request limit; direct QA-room create/join +and two authenticated reads passed. The previous purchase build's +full HTTP suite passed 51/55 cases; four origin/malformed-request +cases returned 503 instead of 403/400. The full HTTP gate remains open. + +The previous purchase revision `eede77a` passed container workflow +`36664985601`, including its isolated storage/HTTP gate, and published +an image. This does not deploy it to the live NAS. Container workflow `36656043783` built `32d9ef4` but failed isolated storage/HTTP verification; that image was not published and diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 475114d7..7d6736c3 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,37 @@ # Dune implementation status +## 30 September 2026 — Semuta after printed Karama winning-bid payment + +The explicit Richese preview now stages a second normal-auction +Karama discard when the winning bidder chooses its printed card +instead of a spice payment. The bid and bidder are already fixed; +the card enters the public pile before the same neutral Semuta offer. +Claim or all-seat decline settles the reserved lot once, without +charging bid spice, and preserves the following Richese cache offer. +The saved receipt binds the bid winner, round, cache, deck and hand +custody. Richese lots, Worthless conversion and nested responses stay +outside this bounded reaction. + +Engine and authenticated SQLite tests cover the winning decision, +full bid above available spice, legal AI claim, JSON/restart +continuation, forged bidder/round rejection, exact stale retry, +physical card custody and the following cache choice. A direct engine +smoke settled the printed card, lot and cache sequence. Public +expansion starts and complete Semuta coverage remain gated. + +`npm run check` passed types, lint and 5,837 offline tests; the +production build passed. A phone-width browser view of the rules +guide visibly included the winning-bid timing. Independent review +found no scoped defect. The prior purchase checkpoint passed its +container workflow's isolated storage/HTTP check and published an +image; it is not deployed to the NAS. On this paid-payment build, a +focused built-worker HTTP Advanced-lobby test timed out at its +15-second request limit; a separate direct QA-room create/join and +two authenticated seat reads returned 201/200/200/200 with distinct +seat identities. The prior full local HTTP suite passed 51/55; +four origin/malformed-request cases returned 503 instead of 403/400. +Full HTTP acceptance remains open. No production game was touched. + ## 30 September 2026 — Semuta after printed Karama normal-auction purchase The explicit Richese development preview now offers a neutral Semuta diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 83c99ca9..90abffd2 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -56,12 +56,13 @@ suspended response, spent card and hand/spice custody; claim or decline restores that response and finishes cancellation once. Other cancellations and shipping retain their automatic paths. -Clean printed Karama normal-auction purchase now stages -`karamaPurchaseDiscard` after the public cost and before the reserved -lot is settled. The receipt binds the original lot, order, deck and -hands; a neutral claim or decline completes the same free purchase -once. Richese lots, paid auction Karama and nested responses retain -their previous automatic paths. +Clean printed Karama normal-auction purchase or winning-bid payment +stages `karamaPurchaseDiscard` or `karamaPaymentDiscard` after the +public cost and before the reserved lot settles. The receipt binds +the original lot, round, cache, order, deck, hands and bid winner; +a neutral claim or decline completes the same free settlement once. +Richese lots, Worthless conversion and nested responses retain their +previous automatic paths. Completed Ornithopter movement now offers the same retired public card before its already committed arrival suffix only in a roster without diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index cbbdb6db..cd6598eb 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -67,8 +67,8 @@ opening public expansion starts. It interrupts **clean public ordinary-card, paid Box, retired Ornithopter, used Distrans, final Truthtrance, four bounded Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene -Gesserit Charity or buying a clean normal-auction lot, battle and -Ix-deck Thumper/Amal discards**. Other +Gesserit Charity or settling a clean normal-auction purchase or +winning bid, battle and Ix-deck Thumper/Amal discards**. Other than the one explicitly suspended charity/Inflation response, the parent transaction has no competing response. Every seat sees the same neutral fresh-discard opportunity @@ -101,14 +101,16 @@ its accepted cancellation runs. The sole suspended response is bound to the saved receipt; claim or decline resumes the same cancellation once. CHOAM income or BG's extra allowance is withheld, while Inflation retains its spent placement attempt without a token. -Worthless conversion, other cancellations, shipping, auction payment -and nested Karama responses retain their separate automatic paths. +Worthless conversion, other cancellations, shipping and nested +Karama responses retain their separate automatic paths. A clean printed Karama purchase of an unresolved normal auction lot -also pauses after its public card cost. The lot, deck and seating order +or free payment of an already won bid pauses after its public card +cost. The lot, bidder, bid, Richese round, deck and seating order remain bound while every seat sees the neutral offer. Claim or decline -settles that same free purchase once; no second bid, payment or draw -precedes the offer. Richese lots and other Karama forms are unchanged. +settles that same free transaction once; no second bid, spice payment +or draw precedes the offer. Richese lots and other Karama forms remain +unchanged. For a genuine Ix-deck Thumper, its played physical card reaches the discard before an injected worm or actual spice-card draw. diff --git a/game/engine.ts b/game/engine.ts index 173dd2d1..7b56ccb9 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1199,7 +1199,7 @@ export type Game = { stateSignature: string; } | { - kind: 'karamaPurchaseDiscard'; + kind: 'karamaPurchaseDiscard' | 'karamaPaymentDiscard'; player: string; card: string; resume: NonNullable['resume']; @@ -4892,14 +4892,14 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu })), }); } -type KaramaPurchaseDiscardContinuation = Extract< +type KaramaAuctionDiscardContinuation = Extract< NonNullable['continuation'], - { kind: 'karamaPurchaseDiscard' } + { kind: 'karamaPurchaseDiscard' | 'karamaPaymentDiscard' } >; -function karamaPurchaseDiscardSignature(g: Game, c: KaramaPurchaseDiscardContinuation) { +function karamaAuctionDiscardSignature(g: Game, c: KaramaAuctionDiscardContinuation) { return JSON.stringify({ player: c.player, card: c.card, - parent: nullentropyParentSignature(g, c.resume, `karama:purchase:${c.card}`), + parent: nullentropyParentSignature(g, c.resume, `karama:${c.kind === 'karamaPaymentDiscard' ? 'auctionPayment' : 'purchase'}:${c.card}`), turn: g.turn, phase: g.phase, active: g.active, auction: g.auction, richeseBidding: g.richeseBidding, richeseFunding: g.richeseFunding, richeseCache: g.richeseCache?.map(card => card.id), @@ -5500,20 +5500,22 @@ function treacheryDiscardIntegrity(g: Game) { c.stateSignature === karamaResponseDiscardSignature(g, c), 'The spent Karama no longer matches its suspended charity or Inflation cancellation.', ); - } else if (continuation?.kind === 'karamaPurchaseDiscard') { + } else if (continuation?.kind === 'karamaPurchaseDiscard' || + continuation?.kind === 'karamaPaymentDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( g.phase === 3 && !g.response && !g.decision && !g.pendingKarama && !g.phaseOpening && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && - batch.cause === 'karama:purchase' && batch.entries.length === 1 && + batch.cause === (c.kind === 'karamaPaymentDiscard' ? 'karama:auctionPayment' : 'karama:purchase') && + batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && entry.card.effect === 'karama' && g.players.some(p => p.id === c.player) && - c.stateSignature === karamaPurchaseDiscardSignature(g, c), - 'The spent Karama no longer matches its reserved auction purchase.', + c.stateSignature === karamaAuctionDiscardSignature(g, c), + 'The spent Karama no longer matches its reserved auction settlement.', ); - normalKaramaAuction(g, getPlayer(g, c.player), false); + normalKaramaAuction(g, getPlayer(g, c.player), c.kind === 'karamaPaymentDiscard'); } else if (continuation?.kind === 'thumperDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( @@ -6175,6 +6177,7 @@ function semutaOfferSupported( continuation.kind !== 'karamaInflationDiscard' && continuation.kind !== 'karamaBgCharityDiscard' && continuation.kind !== 'karamaPurchaseDiscard' && + continuation.kind !== 'karamaPaymentDiscard' && continuation.kind !== 'thumperDiscard' && continuation.kind !== 'amalDiscard' && !(continuation.kind === 'battleCleanup' && continuation.source === 'winner') && @@ -6249,7 +6252,8 @@ function semutaOfferSupported( ? continuation.resume.phaseOpening?.passed.length === 0 : !continuation.resume.phaseOpening)))) && !g.karamaShipping && - (continuation.kind === 'karamaPurchaseDiscard' + (continuation.kind === 'karamaPurchaseDiscard' || + continuation.kind === 'karamaPaymentDiscard' ? !!g.auction && !g.currentAuctionSale && !g.richeseAuction : !g.auction) && (continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) && @@ -6456,13 +6460,15 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string } else if (next.kind === 'karamaCharityDiscard' || next.kind === 'karamaInflationDiscard' || next.kind === 'karamaBgCharityDiscard' || - next.kind === 'karamaPurchaseDiscard') { + next.kind === 'karamaPurchaseDiscard' || + next.kind === 'karamaPaymentDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; - completeKarama(g, getPlayer(g, next.player), - next.kind === 'karamaPurchaseDiscard' ? { kind: 'purchase' } : next.use); + const use: KaramaUse = 'use' in next ? next.use : + { kind: next.kind === 'karamaPurchaseDiscard' ? 'purchase' : 'auctionPayment' }; + completeKarama(g, getPlayer(g, next.player), use); } else if (next.kind === 'amalDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; @@ -10832,17 +10838,19 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse) { return; } } - if (g.semutaPreview && card.effect === 'karama' && use.kind === 'purchase') { - const continuation: KaramaPurchaseDiscardContinuation = { - kind: 'karamaPurchaseDiscard', player: p.id, card: used.id, + if (g.semutaPreview && card.effect === 'karama' && + (use.kind === 'purchase' || use.kind === 'auctionPayment')) { + const continuation: KaramaAuctionDiscardContinuation = { + kind: use.kind === 'purchase' ? 'karamaPurchaseDiscard' : 'karamaPaymentDiscard', + player: p.id, card: used.id, 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 = karamaPurchaseDiscardSignature(g, continuation); - stageTreacheryDiscard(g, 'karama:purchase', entries, continuation); + continuation.stateSignature = karamaAuctionDiscardSignature(g, continuation); + stageTreacheryDiscard(g, `karama:${use.kind}`, entries, continuation); return; } } diff --git a/game/reference.ts b/game/reference.ts index 1886958b..e534b6ce 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1140,7 +1140,7 @@ export const RULE_TOPICS: RuleTopic[] = [ definition.card.effect === 'juiceOfSapho' ? 'Use the Juice of Sapho panel for an available order change or pre-plan aggressor. Later intervention and other timing modes remain unfinished; consult the timing guide.' : definition.card.effect === 'karama' - ? 'This physical Karama uses the existing generic handler in development fixtures. In the explicit Semuta preview, clean printed cancellations of CHOAM Charity, Inflation or Bene Gesserit Charity pause on the public cost before settling that response; a clean printed normal-auction purchase pauses before its reserved lot settles. Full Richese starts and other Karama-to-Semuta reactions remain unavailable.' + ? 'This physical Karama uses the existing generic handler in development fixtures. In the explicit Semuta preview, clean printed cancellations of CHOAM Charity, Inflation or Bene Gesserit Charity pause on the public cost before settling that response; a clean printed normal-auction purchase or winning-bid payment pauses before its reserved lot settles. Full Richese starts and other Karama-to-Semuta reactions remain unavailable.' : definition.card.effect === 'distrans' ? 'Use the Distrans transfer panel to choose another player and a separate held card. Open-bid timing and self-transfer remain guarded pending clarification.' : definition.card.effect === 'nullentropyBox' @@ -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 the explicit Richese development preview, every seat sees a neutral response to supported clean public discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase before lot settlement, and three battle batches. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other Karama uses, inspected/partly ready battles, nested responses, pending rewards, full-hand sequencing and public starts remain unfinished.' + ? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean public discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, and three battle batches. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other Karama uses, inspected/partly ready battles, nested responses, pending rewards, full-hand sequencing and public starts remain unfinished.' : 'Reference component only: this Richese card’s game actions are not enabled.', ], ...(definition.card.effect === 'semutaDrug' @@ -1166,7 +1166,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation' as const, status: 'Partial' as const, detail: - 'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or buying a normal auction lot, Ix-deck Thumper/Amal and three battle discard stages. Karama’s physical cost is discarded before a neutral offer; the original response is canceled or the reserved lot is bought once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Neutral offers, owner-only candidates and one physical Semuta transfer retain privacy. Other Karama uses, nested responses, pending rewards, other producers and full-hand exchange remain unfinished.', + 'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, Ix-deck Thumper/Amal and three battle discard stages. Karama’s physical cost is discarded before a neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Neutral offers, owner-only candidates and one physical Semuta transfer retain privacy. Other Karama uses, nested responses, pending rewards, other producers and full-hand exchange remain unfinished.', }, { area: 'Player controls' as const, @@ -1184,13 +1184,13 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Documentation' as const, status: 'Partial' as const, detail: - 'The guide distinguishes clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase, Ix-deck Thumper/Amal and three battle batches from privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.', + 'The guide distinguishes clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, Ix-deck Thumper/Amal and three 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, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase, Ix-deck Thumper/Amal and three battle discard stages. Other Karama uses, pending rewards, full games and live deployment remain unverified.', + 'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, Ix-deck Thumper/Amal and three battle discard stages. Other Karama uses, pending 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 a9759224..4efb3be7 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -48,6 +48,68 @@ function fixture(ix = false) { return { g, semuta, hajr }; } +void test('printed Karama auction payment offers its card before the winning lot settles', () => { + const { g, semuta, hajr } = fixture(); + player(g, 'a').hand = [take(g, 'karama')]; + g.deck.push(hajr); + const karama = player(g, 'a').hand[0], lot = g.deck.shift()!; + Object.assign(g, { phase: 3, active: 'a', order: ['a', 'e', 'r'], + movementRemaining: [], auction: { cards: [lot], index: 0, bid: 0, + bidder: null, active: 'a', passed: [], opener: 0 } }); + g.richeseBidding = { owner: 'r', event: 'richese-normal:2', turn: g.turn, + stage: 'normal', position: 'last', normalCount: 1, + blackMarketSold: false, cacheCanceled: false, opener: 0 }; + g.richeseFunding = {}; + const startingSpice = player(g, 'a').spice; + const original = [...inventory(g), lot.id].sort(); + let bidding = applyAction(g, 'a', { type: 'bid', amount: startingSpice + 2 }); + for (const id of ['e', 'r']) + bidding = applyAction(bidding, id, { type: 'passBid' }); + assert.equal(bidding.decision?.kind, 'auctionPayment'); + assert.equal(bidding.auction?.bidder, 'a'); + const pending = applyAction(bidding, 'a', + { type: 'decision', karama: true, card: karama.id }); + const event = pending.pendingTreacheryDiscard!.batch.event; + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'karamaPaymentDiscard'); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(player(pending, 'a').spice, startingSpice); + assert.equal(player(pending, 'a').hand.some(card => card.id === lot.id), false); + assert.equal(pending.auction?.bidder, 'a'); + assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true); + assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending)); + for (const mutate of [ + (state: Game) => { state.auction!.bidder = 'e'; }, + (state: Game) => { state.richeseBidding!.position = 'first'; }, + (state: Game) => { + const c = state.pendingTreacheryDiscard!.continuation; + if (c.kind === 'karamaPaymentDiscard') c.stateSignature = 'wrong'; + }, + ]) { + const altered = reload(pending); + mutate(altered); + const before = JSON.stringify(altered); + assert.throws(() => applyAction(altered, 'r', { type: 'semutaCommit', event })); + assert.equal(JSON.stringify(altered), before); + } + let declined = reload(pending); + for (const id of ['a', 'r', 'e']) + declined = applyAction(declined, id, { type: 'semutaPass', event }); + const claimed = applyAction(reload(pending), 'r', botActions(viewGame(pending, 'r'))[0]!); + for (const state of [declined, claimed]) { + assert.equal(state.pendingTreacheryDiscard, null); + assert.equal(state.auction, null); + assert.equal(state.richeseBidding?.stage, 'cacheOffer'); + assert.equal(state.decision?.kind, 'richeseCache'); + assert.equal(player(state, 'a').spice, startingSpice); + assert.equal(player(state, 'a').hand.filter(card => card.id === lot.id).length, 1); + assert.deepEqual([...inventory(state)].sort(), original); + assert.throws(() => applyAction(state, 'r', { type: 'semutaCommit', event })); + } + assert.equal(declined.discard.filter(card => card.id === karama.id).length, 1); + assert.equal(player(claimed, 'r').hand.filter(card => card.id === karama.id).length, 1); + assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1); +}); + void test('printed Karama purchase offers its spent card before one normal auction settlement', () => { const { g, semuta, hajr } = fixture(); player(g, 'a').hand = [take(g, 'karama')]; diff --git a/tests/semuta-recovery.test.ts b/tests/semuta-recovery.test.ts index b1cfa12e..0ee99009 100644 --- a/tests/semuta-recovery.test.ts +++ b/tests/semuta-recovery.test.ts @@ -728,6 +728,77 @@ void test('authenticated pre-plan Sapho discard restores one aggressor choice be } finally { f.sqlite.close(); } }); +void test('authenticated printed Karama auction payment resumes one saved Richese cache offer', async () => { + const f = unitStore(); + try { + const made = await f.rooms.createRoom('Semuta Payment', 'richese', false, ['choam']); + const code = made.view.code; + const buyer = await f.rooms.joinRoom(code, 'Atreides', 'atreides'); + const third = await f.rooms.joinRoom(code, 'Emperor', 'emperor'); + const auths = await Promise.all([made.token!, buyer.token!, third.token!] + .map(token => f.restart().authenticate(code, token))); + const existing = await f.restart().readRoom(code); + const g = createGame(code, newPlayer(auths[0].playerId, 'Richese', 'richese'), false, ['choam']); + g.players.push(newPlayer(auths[1].playerId, 'Atreides', 'atreides'), + newPlayer(auths[2].playerId, 'Emperor', 'emperor')); + Object.assign(g, { status: 'playing', phase: 3, active: auths[1].playerId, + order: auths.map(auth => auth.playerId), deck: baseDeck(), + richeseCache: richeseCards(), semutaPreview: true }); + for (const p of g.players) { + p.hand = []; + p.traitors = p.leaders.length ? [p.leaders[0].id] : []; + p.traitorChoices = []; + } + const karama = take(g, 'karama'), semuta = take(g, SEMUTA_DRUG_ID); + const lot = g.deck.shift()!; + seat(g, auths[1].playerId).hand.push(karama); + seat(g, auths[0].playerId).hand.push(semuta); + g.auction = { cards: [lot], index: 0, bid: 0, bidder: null, + active: auths[1].playerId, passed: [], opener: 0 }; + g.richeseBidding = { owner: auths[0].playerId, event: 'richese-normal:1', + turn: g.turn, stage: 'normal', position: 'last', normalCount: 1, + blackMarketSold: false, cacheCanceled: false, opener: 0 }; + g.richeseFunding = {}; + g.version = existing.version + 1; + assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?') + .run(JSON.stringify(g), g.version, code, existing.version).changes, 1); + const seats = f.sqlite.prepare('SELECT * FROM seats').all(); + const act = async (index: number, action: Action) => { + const state = await f.restart().readRoom(code); + await f.restart().act(code, auths[index], state.version, action, clock); + return f.restart().readRoom(code); + }; + const initialSpice = seat(g, auths[1].playerId).spice; + await act(1, { type: 'bid', amount: initialSpice + 2 }); + await act(2, { type: 'passBid' }); + const payment = await act(0, { type: 'passBid' }); + assert.equal(payment.decision?.kind, 'auctionPayment'); + const pending = await act(1, { type: 'decision', karama: true, card: karama.id }); + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'karamaPaymentDiscard'); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(pending.auction?.cards[0].id, lot.id); + assert.equal(seat(pending, auths[1].playerId).spice, initialSpice); + await f.restart().continueRoomAutomatic(code, clock); + assert.equal((await f.restart().readRoom(code)).version, pending.version); + assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true); + const event = pending.pendingTreacheryDiscard!.batch.event; + const claimed = await act(0, { type: 'semutaCommit', event }); + assert.equal(claimed.pendingTreacheryDiscard, null); + assert.equal(claimed.auction, null); + assert.equal(claimed.richeseBidding?.stage, 'cacheOffer'); + assert.equal(claimed.decision?.kind, 'richeseCache'); + assert.equal(seat(claimed, auths[1].playerId).spice, initialSpice); + assert.equal(seat(claimed, auths[1].playerId).hand.filter(card => card.id === lot.id).length, 1); + assert.equal(seat(claimed, auths[0].playerId).hand.filter(card => card.id === karama.id).length, 1); + assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1); + await assert.rejects(f.restart().act(code, auths[0], pending.version, + { type: 'semutaCommit', event }, clock)); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats); + for (const auth of auths) + assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId)); + } finally { f.sqlite.close(); } +}); + void test('authenticated printed Karama purchase preserves the lot across a saved Semuta claim', async () => { const f = unitStore(); try {