diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 93893117..413a5dbf 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -12,12 +12,14 @@ the physical Ix deck. Clean public ordinary-card, paid Box, retired Ornithopter, used Distrans, final Truthtrance, four bounded Sapho, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a clean -normal-auction lot, battle-card and Ix-deck Thumper/Amal discards -open the same neutral offer regardless of hidden Semuta custody. +normal-auction lot, a clean completed CHOAM card sale, battle-card +and Ix-deck Thumper/Amal discards open the same neutral offer +regardless of hidden Semuta custody. A clean **private** normal-auction Ixian ally replacement discard also pauses before its draw; no unknown face is shown before a -holder commits. Karama waits after its public cost but before -cancellation or the reserved lot settles. +holder commits. A completed CHOAM sale pays its quoted bank spice +before the offer and resumes the same market afterward. 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 @@ -33,6 +35,15 @@ full-hand ordering, normal Richese starts and deployed acceptance remain unfinished. Neutral passes and free-slot guard are product choices. +The completed CHOAM card-sale reaction passed two- and three-spice +engine cases, canceled-sale exclusion, authenticated SQLite recovery, +direct engine smoke and rendered phone-width guide inspection. +`npm run check` passed types, lint and 5,843 offline tests; the build +passed. The isolated built-worker HTTP suite passed 50/55; four +origin/validation cases returned 503 instead of 403/400 and one +Advanced-lobby request timed out after 15 seconds. Full HTTP +acceptance remains open. + The private normal-auction Ixian ally reaction passed genuine-setup engine, authenticated SQLite and direct engine smoke checks. The full `npm run check` passed types, lint and 5,839 offline tests; build and @@ -53,6 +64,10 @@ The following paid Karama revision `8f6b1c8` also passed container workflow `36665999972` through isolated storage/HTTP verification and published its image. Neither checkpoint has been deployed to the NAS. +The subsequent private Ixian ally revision `9bfc3ec` passed workflow +`36667534804` and published after isolated storage/HTTP verification. +It too is not a live NAS deployment. + Container workflow `36656043783` built `32d9ef4` but failed isolated storage/HTTP verification; that image was not published and the public job exposes no assertion log. The next checkpoint diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index f2c65652..6d6004a4 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,34 @@ # Dune implementation status +## 30 September 2026 — Semuta after completed CHOAM card sale + +In the explicit Richese preview, a clean CHOAM market sale now pays +its printed two- or three-spice income and discards the one publicly +named physical card before a neutral Semuta offer. A canceled or +invalid sale leaves its card in hand and creates no fresh batch. +Claim or all-seat decline resumes the same CHOAM market choice +without repeating income or disposal. The saved receipt binds +owner, exact card, duplicate witness if used, earned balance and +the open market. Concurrent market obligations and other sale +families retain their earlier automatic paths. + +Focused engine checks cover hidden Semuta-holder parity in outsider +views, a quoted Worthless sale, income once, exact card custody, +rejected altered receipts, legal AI claim and resumed market choice. +Authenticated SQLite checks cover restart, stale retry, preserved +seats and the next market decision. A direct engine smoke exercised +the committed income, one claim and same market return. The public +expansion and complete Semuta gates remain closed. + +`npm run check` passed types, lint and 5,843 offline tests; the +production build passed. The phone-width built-worker guide showed +the sold-card timing. Independent review found no scoped defect. +The isolated built-worker HTTP suite passed 50/55: four origin/ +validation requests returned 503 instead of expected 403/400, +and one Advanced-lobby request timed out after 15 seconds. +Full HTTP and production acceptance remain open; no production +game was touched. + ## 30 September 2026 — Semuta before private Ixian ally replacement The explicit Richese preview with the Ix expansion now interrupts a diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index d9e96c61..b4f029f6 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -73,6 +73,14 @@ sale continuation. Richese cache and Black Market sales retain the automatic path until their distinct custody and offer windows are integrated. +A clean completed CHOAM market sale stages `choamSaleDiscard` only after +the accepted public card disposal and its quoted two- or three-spice +income. The saved receipt binds the sold card, duplicate witness when +present, earned balance and still-open market; claim or decline +restores the same owner's next market decision without paying again. +Canceled/invalid sales have no batch; competing market parents retain +their automatic continuation. + Completed Ornithopter movement now offers the same retired public card before its already committed arrival suffix only in a roster without Bene Gesserit intrusion, Ecaz Ambassador or Moritani Terror response diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index d6a98d5f..36c1b2b9 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -67,9 +67,10 @@ 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 settling a clean normal-auction lot, battle and -Ix-deck Thumper/Amal discards**, plus a clean **private** normal-auction -Ixian ally replacement discard before its draw. Other +Gesserit Charity or settling a clean normal-auction lot, a completed +CHOAM card sale, battle and Ix-deck Thumper/Amal discards**, plus +a clean **private** normal-auction Ixian ally replacement discard +before its draw. 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 @@ -82,6 +83,14 @@ removes it before the saved single replacement draw and subsequent normal-auction settlement. Richese cache and Black Market replacement discards, competing parents and other private producers remain separate. +A clean CHOAM card sale commits its one named public discard and the +quoted two- or three-spice bank payment before the neutral offer. +Canceled or invalid sales leave the card in hand and have no fresh +batch. The saved receipt binds the exact sold card, optional duplicate +witness, earned spice and still-open market; claim or decline resumes +the market once without paying or discarding again. Nested card +responses and other sale families retain their previous paths. + An early-ended Ornithopter advances the saved queue; a completed flight resumes one committed arrival only without BG, Ecaz or Moritani entry reactions. Distrans's gift remains private with its diff --git a/game/engine.ts b/game/engine.ts index 8a7b40fc..46ffd085 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1205,6 +1205,15 @@ export type Game = { resume: NonNullable['resume']; stateSignature: string; } + | { + kind: 'choamSaleDiscard'; + owner: string; + card: string; + witness: string | null; + price: number; + spiceAfter: number; + stateSignature: string; + } | { kind: 'thumperDiscard'; player: string; @@ -4910,6 +4919,19 @@ function karamaAuctionDiscardSignature(g: Game, c: KaramaAuctionDiscardContinuat })), }); } +type ChoamSaleDiscardContinuation = Extract< + NonNullable['continuation'], + { kind: 'choamSaleDiscard' } +>; +function choamSaleDiscardSignature(g: Game, c: ChoamSaleDiscardContinuation) { + const owner = getPlayer(g, c.owner); + return JSON.stringify({ + owner: c.owner, card: c.card, witness: c.witness, price: c.price, + spiceAfter: c.spiceAfter, turn: g.turn, phase: g.phase, + active: g.active, order: g.order, market: g.choamMarket, + hand: owner.hand.map(card => card.id), spice: owner.spice, + }); +} type ThumperDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'thumperDiscard' } @@ -5516,6 +5538,25 @@ function treacheryDiscardIntegrity(g: Game) { 'The spent Karama no longer matches its reserved auction settlement.', ); normalKaramaAuction(g, getPlayer(g, c.player), c.kind === 'karamaPaymentDiscard'); + } else if (continuation?.kind === 'choamSaleDiscard') { + const c = continuation, entry = batch.entries[0]; + const owner = g.players.find(p => p.id === c.owner); + requireRule( + g.choamMarket?.owner === c.owner && !g.choamMarket.sale && + !g.choamMarket.trade && !g.response && !g.decision && + !g.pendingKarama && !g.phaseOpening && + owner?.faction === 'choam' && + batch.cause === 'choamSale' && batch.entries.length === 1 && + entry.publicFace && entry.discardedBy === c.owner && + entry.card.id === c.card && + (c.witness === null + ? c.price === 2 && entry.card.kind === 'worthless' + : c.price === 3 && owner.hand.some(card => + card.id === c.witness && card.name === entry.card.name)) && + owner.spice === c.spiceAfter && + c.stateSignature === choamSaleDiscardSignature(g, c), + 'The sold CHOAM card no longer matches its saved income and market.', + ); } else if (continuation?.kind === 'thumperDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( @@ -6181,6 +6222,7 @@ function semutaOfferSupported( continuation.kind !== 'thumperDiscard' && continuation.kind !== 'amalDiscard' && continuation.kind !== 'ixAllyCard' && + continuation.kind !== 'choamSaleDiscard' && !(continuation.kind === 'battleCleanup' && continuation.source === 'winner') && continuation.kind !== 'winnerMandatoryDiscard' && continuation.kind !== 'battleResolved' && @@ -6247,6 +6289,7 @@ function semutaOfferSupported( continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'battleResolved' || continuation.kind === 'ixAllyCard' || + continuation.kind === 'choamSaleDiscard' || (continuation.kind === 'karamaCharityDiscard' || continuation.kind === 'karamaInflationDiscard' || continuation.kind === 'karamaBgCharityDiscard' || @@ -6269,7 +6312,11 @@ function semutaOfferSupported( !g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy && !g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingRicheseGift && - !g.pendingRichesePurchaseIncome && !g.choamMarket && + !g.pendingRichesePurchaseIncome && + (continuation.kind === 'choamSaleDiscard' + ? !!g.choamMarket && !g.choamMarket.sale && + !g.choamMarket.trade && !g.biddingEnd && !g.currentAuctionSale + : !g.choamMarket) && !g.pendingChoamMarketGhola; } function semutaContext(g: Game, owner: Player): SemutaContext { @@ -6410,6 +6457,8 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string // CHOAM's selected-count payout was already committed with its discard. g.pendingAmbassador = next.entry; finishAmbassador(g); + } else if (next.kind === 'choamSaleDiscard') { + resumeChoamMarket(g); } else if (next.kind === 'nullentropyDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; @@ -20924,18 +20973,34 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) const saleBlock = saleCard ? homeworldRule(() => homeworldWorthlessSaleBlock(g, owner.id, saleCard)) : null; - if (quoteSale(owner.hand, sale.card, sale.witness) && !saleBlock) { - discard(g, owner, sale.card); + const quoted = quoteSale(owner.hand, sale.card, sale.witness); + if (quoted?.price === sale.price && !saleBlock) { + const used = discard(g, owner, sale.card); owner.spice += sale.price; log(g, `${owner.name} received ${sale.price} spice from the card sale.`); - } else + delete market.sale; + if (g.semutaPreview) { + const continuation: ChoamSaleDiscardContinuation = { + kind: 'choamSaleDiscard', owner: owner.id, card: used.id, + witness: sale.witness ?? null, price: sale.price, + spiceAfter: owner.spice, stateSignature: '', + }; + const entries = [{ card: used, discardedBy: owner.id, publicFace: true }]; + if (semutaOfferSupported(g, continuation, entries)) { + continuation.stateSignature = choamSaleDiscardSignature(g, continuation); + stageTreacheryDiscard(g, 'choamSale', entries, continuation); + return; + } + } + } else { log( g, saleBlock ? 'Tupile is now high population, so the declared Worthless sale cannot finish. The card stays in hand and no spice is paid.' : 'The declared card sale is no longer possible; no spice was paid.', ); - delete market.sale; + delete market.sale; + } resumeChoamMarket(g); } else if (response.kind === 'choamInflation') { if (!canceled) { diff --git a/game/reference.ts b/game/reference.ts index d4e09c5e..63fcce65 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1156,7 +1156,7 @@ export const RULE_TOPICS: RuleTopic[] = [ : definition.card.effect === 'portableSnooper' ? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.' : definition.card.effect === 'semutaDrug' - ? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean 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 public 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. A private normal-auction Ixian ally replacement also pauses before drawing, without showing its face until a holder commits. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, 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 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 public 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, a completed CHOAM card sale after its bank income, and three battle batches. A private normal-auction Ixian ally replacement also pauses before drawing, without showing its face until a holder commits. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, 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 settling a normal auction purchase or winning bid, Ix-deck Thumper/Amal and three battle discard stages. A private normal-auction Ixian ally replacement also pauses before its draw with a neutral, face-hidden offer. Karama’s physical cost is discarded before its 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. Owner-only candidates and one physical Semuta transfer retain privacy. Other Karama uses, private discards, nested responses, pending rewards 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, a completed CHOAM card sale, Ix-deck Thumper/Amal and three battle discard stages. A private normal-auction Ixian ally replacement pauses before its draw with a neutral, face-hidden offer. CHOAM sale income is committed before its neutral offer and the same market resumes once. Karama’s physical cost is discarded before its 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. Owner-only candidates and one physical Semuta transfer retain privacy. Other Karama uses, private discards, nested responses, pending rewards 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 public 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 a private normal-auction Ixian ally replacement, privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.', + 'The guide distinguishes clean public 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, completed CHOAM card sales, Ix-deck Thumper/Amal and three battle batches from a private normal-auction Ixian ally replacement, 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 or winning-bid payment, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, 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, completed CHOAM card sales, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, 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/choam-market.test.ts b/tests/choam-market.test.ts index 559c2b7b..b6d2a1c8 100644 --- a/tests/choam-market.test.ts +++ b/tests/choam-market.test.ts @@ -4,6 +4,7 @@ import { applyAction, createGame, newPlayer, + normalizeAutomaticGame, viewGame, RuleError, type Game, @@ -13,6 +14,8 @@ import { saleOptions } from '../game/choam-market'; import { createTechTokens } from '../game/tech-tokens'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; +import { richeseCards } from '../game/richese-cards'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; function fixture() { const g = createGame('MARKET22', newPlayer('c', 'CHOAM', 'choam'), false, [ 'choam', @@ -129,6 +132,117 @@ void test('Worthless sale reveals its card but only discards and pays after the assert.equal(g.phase, 2); assert.deepEqual(live(g), live(initial)); }); +void test('clean CHOAM sale offers Semuta after committed income before the market resumes', () => { + const g = fixture(); + g.players[1] = newPlayer('e', 'Richese', 'richese'); + g.players[1].spice = 20; + g.richeseCache = richeseCards(); + g.semutaPreview = true; + const index = g.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID); + assert.ok(index >= 0); + const semuta = g.richeseCache.splice(index, 1)[0]; + g.players[1].hand.push(semuta); + contest(g, 1, 'b'); + const sold = hold(g, 'c', 'Baliset'); + const original = live(g); + const absent = structuredClone(g); + absent.players[1].hand = [absent.richeseCache!.splice(0, 1)[0]]; + absent.richeseCache!.push(semuta); + const offered = (state: Game) => allow(sell(ready(state), sold.id)); + const pending = offered(g), neutral = offered(absent); + const event = pending.pendingTreacheryDiscard!.batch.event; + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'choamSaleDiscard'); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(neutral.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(pending.choamMarket?.sale, undefined); + assert.equal(pending.decision, null); + assert.equal(pending.players[0].spice, 22); + assert.equal(pending.players[0].hand.some(card => card.id === sold.id), false); + assert.equal(pending.discard.filter(card => card.id === sold.id).length, 1); + assert.deepEqual(viewGame(pending, 'c'), viewGame(neutral, 'c')); + assert.deepEqual(viewGame(pending, 'b'), viewGame(neutral, 'b')); + assert.equal(viewGame(pending, 'e').semutaReaction?.canCommit, true); + assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))), pending); + for (const change of [ + (state: Game) => { + const next = state.pendingTreacheryDiscard!.continuation; + if (next.kind === 'choamSaleDiscard') next.price = 3; + }, + (state: Game) => { state.players[0].spice += 2; }, + (state: Game) => { state.pendingTreacheryDiscard!.batch.entries[0].card.name = 'Forgery'; }, + ]) { + const corrupt: Game = JSON.parse(JSON.stringify(pending)); + change(corrupt); + const before = JSON.stringify(corrupt); + assert.throws(() => applyAction(corrupt, 'e', { type: 'semutaCommit', event })); + assert.equal(JSON.stringify(corrupt), before); + } + let declined: Game = JSON.parse(JSON.stringify(pending)); + for (const id of ['c', 'b', 'e']) + declined = applyAction(declined, id, { type: 'semutaPass', event }); + const claimed = applyAction(JSON.parse(JSON.stringify(pending)), 'e', + botActions(viewGame(pending, 'e'))[0]!); + for (const state of [declined, claimed]) { + assert.equal(state.pendingTreacheryDiscard, null); + assert.equal(state.decision?.kind, 'choamMarket'); + assert.equal(state.players[0].spice, 22); + assert.deepEqual(live(state), original); + assert.throws(() => applyAction(state, 'e', { type: 'semutaCommit', event })); + } + assert.equal(declined.discard.filter(card => card.id === sold.id).length, 1); + assert.equal(claimed.players[1].hand.filter(card => card.id === sold.id).length, 1); + assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1); + const resumed = done(claimed); + assert.equal(resumed.choamMarket, null); + assert.equal(resumed.phase, 3); +}); +void test('CHOAM duplicate sale retains its witness and three spice through Semuta', () => { + const g = fixture(); + g.players[1] = newPlayer('e', 'Richese', 'richese'); + g.players[1].spice = 20; + g.richeseCache = richeseCards(); + g.semutaPreview = true; + const semutaIndex = g.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID); + const semuta = g.richeseCache.splice(semutaIndex, 1)[0]; + g.players[1].hand.push(semuta); + contest(g, 1, 'b'); + const sold = hold(g, 'c', 'Snooper'), witness = hold(g, 'c', 'Snooper'); + const pending = allow(sell(ready(g), sold.id, witness.id)); + const continuation = pending.pendingTreacheryDiscard?.continuation; + assert.equal(continuation?.kind, 'choamSaleDiscard'); + if (continuation?.kind === 'choamSaleDiscard') { + assert.equal(continuation.witness, witness.id); + assert.equal(continuation.price, 3); + } + assert.equal(pending.players[0].spice, 23); + assert.deepEqual(pending.players[0].hand.map(card => card.id), [witness.id]); + const event = pending.pendingTreacheryDiscard!.batch.event; + const claimed = applyAction(pending, 'e', { type: 'semutaCommit', event }); + assert.equal(claimed.players[0].spice, 23); + assert.deepEqual(claimed.players[0].hand.map(card => card.id), [witness.id]); + assert.equal(claimed.players[1].hand.filter(card => card.id === sold.id).length, 1); + assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1); + assert.equal(claimed.decision?.kind, 'choamMarket'); +}); +void test('canceled CHOAM sale creates no Semuta event or bank income', () => { + const g = fixture(); + g.players[1] = newPlayer('e', 'Richese', 'richese'); + g.richeseCache = richeseCards(); + g.semutaPreview = true; + const semutaIndex = g.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID); + g.players[1].hand.push(g.richeseCache.splice(semutaIndex, 1)[0]); + contest(g, 1, 'b'); + const sold = hold(g, 'c', 'Baliset'); + const declaration = sell(ready(g), sold.id); + const karama = declaration.players[2].hand.find(card => card.effect === 'karama')!; + const canceled = applyAction(declaration, 'b', + { type: 'card', card: karama.id, mode: 'cancel' }); + assert.ok(!canceled.pendingTreacheryDiscard); + assert.equal(canceled.players[0].spice, 20); + assert.equal(canceled.players[0].hand.filter(card => card.id === sold.id).length, 1); + assert.equal(canceled.choamMarket?.sale, undefined); + assert.equal(canceled.decision?.kind, 'choamMarket'); +}); void test('surplus sales keep one exact duplicate and permit selling several surplus copies', () => { let g = fixture(); const cards = [ diff --git a/tests/semuta-recovery.test.ts b/tests/semuta-recovery.test.ts index 8d10f13e..3ea9c917 100644 --- a/tests/semuta-recovery.test.ts +++ b/tests/semuta-recovery.test.ts @@ -808,6 +808,83 @@ void test('authenticated private Ixian ally discard waits for Semuta before its } finally { f.sqlite.close(); } }); +void test('authenticated CHOAM sale retains paid spice and market after a saved Semuta claim', async () => { + const f = unitStore(); + try { + const made = await f.rooms.createRoom('Semuta Sale', 'choam', false, ['choam']); + const code = made.view.code; + const richese = await f.rooms.joinRoom(code, 'Richese', 'richese'); + const bg = await f.rooms.joinRoom(code, 'BG', 'beneGesserit'); + const auths = await Promise.all([made.token!, richese.token!, bg.token!] + .map(token => f.restart().authenticate(code, token))); + const existing = await f.restart().readRoom(code); + let g = createGame(code, newPlayer(auths[0].playerId, 'CHOAM', 'choam'), false, ['choam']); + g.players.push(newPlayer(auths[1].playerId, 'Richese', 'richese'), + newPlayer(auths[2].playerId, 'BG', 'beneGesserit')); + Object.assign(g, { status: 'playing', phase: 2, order: auths.map(auth => auth.playerId), + deck: baseDeck(), richeseCache: richeseCards(), semutaPreview: true, + choamCharity: { turn: 1, canceled: false } }); + for (const p of g.players) { + p.spice = 20; + p.hand = []; + p.traitors = p.leaders.length ? [p.leaders[0].id] : []; + p.traitorChoices = []; + } + const semuta = take(g, SEMUTA_DRUG_ID), karama = take(g, 'karama'); + const index = g.deck.findIndex(card => card.name === 'Baliset'); + assert.ok(index >= 0); + const sold = g.deck.splice(index, 1)[0]; + g.players[0].hand.push(sold); + g.players[1].hand.push(semuta); + g.players[2].hand.push(karama); + for (const p of g.players) + g = applyAction(g, p.id, { type: 'ready' }); + assert.equal(g.decision?.kind, 'choamMarket'); + 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 current = await f.restart().readRoom(code); + await f.restart().act(code, auths[index], current.version, action, clock); + return f.restart().readRoom(code); + }; + let current = await act(0, { type: 'decision', mode: 'sell', card: sold.id }); + assert.equal(current.response?.kind, 'choamSale'); + for (let i = 0; current.response?.kind === 'choamSale' && i < 3; i++) { + const index = auths.findIndex(auth => + !viewGame(current, auth.playerId).responseControls?.hasPassed && + !!viewGame(current, auth.playerId).responseControls?.cancelCards.length); + assert.ok(index >= 0); + current = await act(index, { type: 'passResponse' }); + } + const event = current.pendingTreacheryDiscard!.batch.event; + assert.equal(current.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(current.players[0].spice, 22); + assert.equal(current.choamMarket?.sale, undefined); + assert.equal(current.decision, null); + await f.restart().continueRoomAutomatic(code, clock); + assert.equal((await f.restart().readRoom(code)).version, current.version); + const ownerView = await f.restart().readSeatView(code, auths[1]); + assert.equal(ownerView.semutaReaction?.canCommit, true); + assert.deepEqual((await f.restart().readSeatView(code, auths[0])).semutaReaction?.candidates, []); + const claimed = await act(1, { type: 'semutaCommit', event }); + assert.equal(claimed.pendingTreacheryDiscard, null); + assert.equal(claimed.decision?.kind, 'choamMarket'); + assert.equal(claimed.players[0].spice, 22); + assert.equal(claimed.players[1].hand.filter(card => card.id === sold.id).length, 1); + assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1); + await assert.rejects(f.restart().act(code, auths[1], current.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)); + const resumed = await act(0, { type: 'decision', done: true }); + assert.equal(resumed.phase, 3); + assert.equal(resumed.players[0].spice, 22); + } finally { f.sqlite.close(); } +}); + void test('authenticated printed Karama auction payment resumes one saved Richese cache offer', async () => { const f = unitStore(); try {