diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 079caf34..be959288 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,13 +20,26 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Sapho Truthtrance order in Richese cache auctions — 27 October. Prototyped / Partial.** +The public question-order opportunity also opens during a clean Once Around or +Silent cache lot. The same lot, bidder order, funding and aid continue after the +questions. In the Basic Semuta preview, a claim or pass can suspend and resume +both the Sapho discard and a later definite Truthtrance discard. Silent bids +remain private until the normal reveal. The interaction's precedence remains a +provisional interpretation; Black Market, Ix lots, battle, nested responses and +broader combinations remain open. Sixteen related files pass 165/165; type/lint, +build and 55/55 isolated local HTTP integration tests pass. Browser and deployed +acceptance remain open. +[Scope and source](JUICE_OF_SAPHO_RUNTIME.md#truthtrance-order-in-richese-cache-auctions--27-october-2026). + **Sapho Truthtrance order during ordinary bidding — 27 October. Prototyped / Partial.** The public question-order opportunity now also interrupts a clean live ordinary auction. The original lot, bidder, standing bid, pledge and remaining clock resume without replay; a supported Semuta claim/pass retains the same parent. A card-cost forecast also protects earlier whole-turn commitments and treats -queued Truth cards as reserved rather than imaginary transfer payloads. Richese -special lots, battle and nested parent windows remain open. +queued Truth cards as reserved rather than imaginary transfer payloads. The +Richese cache extension is described above; other special lots, battle and nested +parent windows remain open. [Scope and source](JUICE_OF_SAPHO_RUNTIME.md#truthtrance-order-in-ordinary-bidding--27-october-2026). **Sapho Truthtrance question order — 27 October. Prototyped / Partial.** @@ -36,7 +49,7 @@ of Sapho to move their whole question group first/last. Physical storm order, within-holder card order and the interrupted parent remain unchanged. A supported Basic Semuta discard reaction suspends and restores the same window; an earlier chosen boundary remains protected if Sapho is recovered and used again. The -ordinary auction extension above now applies; special auctions, battle, +auction extensions above now apply; other special auctions, battle, pending-parent and broader optional-module compositions remain open. Eleven affected files pass 137/137, type/lint and build pass, and 55/55 local integration tests pass on a separate verification server. Browser/deployed diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 3971daf2..95dc8afc 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,23 @@ # Dune implementation status +## 27 October 2026 — Sapho Truthtrance order in Richese cache auctions + +Extend the public simultaneous-question ordering opportunity to clean live +Once Around and Silent Richese cache lots. Preserve the original lot, bidder +order, standing bid or sealed submissions, funding and aid through the queue; +Silent amounts remain private until native reveal. In the Basic Semuta preview, +claim/pass resumes the same parent after a Sapho discard or a later definite +Truthtrance discard. Newly supported receipts bind the specific lot while older +receipts retain their existing signature shape. This is a provisional interaction +between the FAQ's storm-order rule and the inspected Sapho face, not a publisher +clarification. Fixtures use a native funded cache lot and explicitly labeled, +conserved cache/hand exchanges and undealt Truth placement; they do not claim +that Sapho was purchased. Black Market, Ix lots, nested responses, battle, wider +combinations, browser and deployed acceptance remain open. Sixteen related files +pass 165/165; type/lint, build and 55/55 isolated local HTTP integration tests +pass. Private proof is `20261027-sapho-truth-richese-proof-v2/`. +[Contract](JUICE_OF_SAPHO_RUNTIME.md#truthtrance-order-in-richese-cache-auctions--27-october-2026). + ## 27 October 2026 — Sapho Truthtrance order in ordinary bidding Permit the possession-independent Truth ordering opportunity in a clean live diff --git a/docs/JUICE_OF_SAPHO_RUNTIME.md b/docs/JUICE_OF_SAPHO_RUNTIME.md index 90636c55..27906c87 100644 --- a/docs/JUICE_OF_SAPHO_RUNTIME.md +++ b/docs/JUICE_OF_SAPHO_RUNTIME.md @@ -1,5 +1,33 @@ # Juice of Sapho: finite ordering implementation +## Truthtrance order in Richese cache auctions — 27 October 2026 + +**Prototyped / Partial.** The possession-independent public response following +simultaneous Truthtrance declarations also opens in a clean live Richese cache +Once Around or Silent lot. It preserves the original offered card, standing bid +or sealed submissions, bidder order, funding, escrow and aid while the ordered +questions finish, then returns to the same auction. Silent amounts stay private +until the native reveal. The response does not inspect who holds Sapho. + +In the supported Basic Semuta preview, a fresh-discard claim or pass can suspend +the question queue after Sapho is used or after a subsequent definite +Truthtrance answer consumes its card. These new continuations bind the original +cache event and parent; old receipts without the new marker retain their prior +signature shape. This does not certify historical recovery or wider combinations. + +The FAQ and Sapho face establish their individual rules, but not their relative +precedence; this combined timing remains provisional. Black Market, Ix lots, +battle, nested response windows and broader modules remain open. Browser and +deployed acceptance are also open. + +Basic and Advanced runtime fixtures start a native funded cache lot, then use +explicitly labeled, conserved exchanges for cached Sapho and optional Semuta, +plus an undealt Truth placed in a vacant hand slot. They do not represent Sapho +as an earned purchase. Runtime and server-rendered controls exercise Once +Around and Silent continuations, private sealed views and physical inventory. +Sixteen related files pass 165/165; type/lint, build and 55/55 isolated local +HTTP integration tests pass. + ## Truthtrance order in ordinary bidding — 27 October 2026 **Prototyped / Partial.** The public Sapho response after simultaneous @@ -14,7 +42,7 @@ The physical Sapho cost must retain an earlier whole-turn answer. Detached feasibility quotes reserve the actually queued Truth cards; they cannot be borrowed as generic Distrans payloads after the live window is temporarily removed from a forecast. This is a conservative guarantee, not a new publisher -ruling. Special auctions, battle, nested response windows and broader modules +ruling. Other special auctions, battle, nested response windows and broader modules remain outside this slice. The FAQ and Sapho face establish the individual rules; their relative order remains a provisional implementation choice. @@ -52,8 +80,8 @@ neutral Semuta reaction and suspends the Truth window; claim/pass restores the same queued event or proceeds to the first question when the last holder used Sapho. The full parent signature, original queue and physical card custody are checked without replaying the effect. The original scope excluded live auctions -and battle; the ordinary-auction extension above now applies. Battle, special -auctions, active pending responses/preparations and broader Semuta/module +and battle; the ordinary and cache-auction extensions above now apply. Battle, +other special auctions, active pending responses/preparations and broader Semuta/module profiles remain open rather than treating the restriction as printed rules. Wider repeated-card combinations, browser, deployed and complete-mode acceptance remain open. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index ee3e4c85..d817c842 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,15 @@ # Rule decision index +## Sapho Truthtrance order during Richese cache auctions — 27 October 2026 + +Provisionally apply the simultaneous-question ordering response to a clean +Once Around or Silent Richese cache lot. Preserve the native lot, opportunities, +funding and aid while questions and supported Semuta reactions finish, and keep +Silent amounts private until the normal reveal. The FAQ's storm-order instruction +and the Sapho face establish the component rules but do not determine their +precedence. Black Market, Ix lots, battle and nested windows remain separate. +[Contract](JUICE_OF_SAPHO_RUNTIME.md#truthtrance-order-in-richese-cache-auctions--27-october-2026). + ## Sapho Truthtrance order during ordinary bidding — 27 October 2026 Provisionally extend the existing simultaneous-question ordering response to a diff --git a/game/engine.ts b/game/engine.ts index 8817eefc..9f1b6bce 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1626,6 +1626,8 @@ export type Game = { revision: number; mode: 'first' | 'last'; truth: TruthWindow; + /** Present only on new supported Richese cache continuations. */ + richeseLotEvent?: string; resume: NonNullable['resume']; stateSignature: string; } @@ -1823,6 +1825,8 @@ export type Game = { historyIndex: number; record: TruthRecord; remaining: TruthWindow | null; + /** Absent on older saved Truth discard receipts. */ + richeseLotEvent?: string; resume: NonNullable['resume']; promise: | { kind: 'shipment'; index: number; value: ShipmentPromise } @@ -5911,7 +5915,9 @@ function saphoTruthOrderDiscardSignature(g: Game, c: SaphoTruthOrderDiscardConti order:g.order,active:g.active, // Older clean-parent receipts omit these fields; only an actual ordinary // auction adds a binding to its original lot and pledged funding. - ...(g.auction ? {auction:g.auction,aid:g.aid} : {})}); + ...(g.auction ? {auction:g.auction,aid:g.aid} : {}), + ...(c.richeseLotEvent ? {richeseLotEvent:c.richeseLotEvent,richeseAuction:g.richeseAuction, + richeseBidding:g.richeseBidding,richeseFunding:g.richeseFunding,richeseCache:g.richeseCache,aid:g.aid} : {})}); } type SaphoMovementDiscardContinuation = Extract< NonNullable['continuation'], @@ -7121,6 +7127,8 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) { setupStage: g.setupStage, auction: g.auction, richeseAuction: g.richeseAuction, + ...(next.richeseLotEvent ? {richeseLotEvent:next.richeseLotEvent,richeseBidding:g.richeseBidding, + richeseFunding:g.richeseFunding,richeseCache:g.richeseCache,aid:g.aid,richeseBidPromises:g.richeseBidPromises} : {}), currentAuctionSale: g.currentAuctionSale, ornithopter: g.ornithopter, spiceWindow: g.spiceWindow, @@ -7186,6 +7194,7 @@ function finishTruthtranceAnswer(g: Game) { historyIndex, record, remaining, + ...(supportedRicheseTruthLot(g) ? {richeseLotEvent:g.richeseAuction!.event} : {}), resume, promise, parentSignature: '', @@ -7572,6 +7581,7 @@ function treacheryDiscardIntegrity(g: Game) { 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.richeseLotEvent || supportedRicheseTruthLot(g) && g.richeseAuction?.event === c.richeseLotEvent) && truth && truth.question === null && (truth.stage === 'order' || truth.stage === 'ask') && truth.queue.length > 0 && new Set(truth.queue.map(row => row.card)).size === truth.queue.length && truth.queue.every(row => queuedTruthCardMatches(g.players.find(player => player.id === row.player)?.hand ?? [],row)) && @@ -8079,6 +8089,8 @@ function treacheryDiscardIntegrity(g: Game) { 'The consumed Truthtrance does not match its completed answer history.', ); const remaining = c.remaining; + requireRule(!c.richeseLotEvent || supportedRicheseTruthLot(g) && g.richeseAuction?.event === c.richeseLotEvent, + 'The completed Truthtrance no longer matches its original Richese cache lot.'); requireRule( remaining === null || (remaining && @@ -8722,6 +8734,12 @@ function treacheryDiscardIntegrity(g: Game) { /** Bounded public single-card reaction after completed effects. Battle cleanup * preserves the original winner queue and bounded native retention/rewards without replaying them. * Basic ordinary entry discards retain their ordered Ambassador/Terror parent. */ +function supportedRicheseTruthLot(g: Game): boolean { + const lot = g.richeseAuction; + return g.phase === 3 && !!lot && lot.source === 'cache' && + (lot.method === 'onceAround' || lot.method === 'silent') && !lot.outcome && + !g.currentAuctionSale && !g.auction && !g.ixAuction; +} function semutaOfferSupported( g: Game, continuation: NonNullable['continuation'], @@ -8733,6 +8751,9 @@ function semutaOfferSupported( const ordinaryAuctionDiscard = g.phase === 3 && !!g.auction && !g.richeseAuction && !g.currentAuctionSale && (continuation.kind === 'truthtranceDiscard' || continuation.kind === 'distransDiscard' || continuation.kind === 'saphoTruthOrderDiscard'); + const richeseTruthDiscard = supportedRicheseTruthLot(g) && + (continuation.kind === 'truthtranceDiscard' || continuation.kind === 'saphoTruthOrderDiscard') && + !!continuation.richeseLotEvent && continuation.richeseLotEvent === g.richeseAuction?.event; const richeseGiftDiscard = continuation.kind === 'karamaRicheseGiftDiscard'; const richeseDiscard = richeseGiftDiscard || continuation.kind === 'karamaRicheseAuctionDiscard'; const auctionAdvantageDiscard = continuation.kind === 'karamaAuctionAdvantageDiscard'; @@ -9062,7 +9083,7 @@ function semutaOfferSupported( (advisorDiscard || auctionCostDiscard || bonusDiscard || auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' || continuation.kind === 'ixAllyCard' || (continuation.kind === 'karamaIncomeDiscard' && - continuation.use.response.kind === 'emperorIncome') || !g.richeseAuction) && + continuation.use.response.kind === 'emperorIncome') || richeseTruthDiscard || !g.richeseAuction) && (richeseGiftDiscard || combatDiscard || alliedTraitorDiscard || occupyDiscard || continuation.kind === 'saphoAggressorDiscard' || bgDiscard || continuation.kind === 'specialKaramaDiscard' || @@ -26457,7 +26478,9 @@ function saphoTruthOrderSemutaSupported(g: Game,p: Player,card: string,mode: 'fi w.queue = result.queue; if (result.done) { delete w.order; w.stage = 'ask'; } else w.order = result.frame; const c: SaphoTruthOrderDiscardContinuation = {kind:'saphoTruthOrderDiscard',player:p.id,card:used.id, - event,revision,mode,truth:structuredClone(w),resume:{response:trial.response,decision:trial.decision, + event,revision,mode,truth:structuredClone(w), + ...(supportedRicheseTruthLot(trial) ? {richeseLotEvent:trial.richeseAuction!.event} : {}), + resume:{response:trial.response,decision:trial.decision, pendingKarama:trial.pendingKarama,phaseOpening:trial.phaseOpening},stateSignature:''}; trial.truthtrance = null; trial.response = null; trial.decision = null; trial.pendingKarama = null; trial.phaseOpening = null; @@ -26494,7 +26517,9 @@ function playSaphoTruthOrder(g: Game,p: Player,action: Action) { if (g.semutaPreview) { const c: SaphoTruthOrderDiscardContinuation = {kind:'saphoTruthOrderDiscard',player:p.id,card:used.id, event:action.event as string,revision:action.revision as number,mode:action.mode as 'first'|'last', - truth:structuredClone(w),resume:{response:g.response,decision:g.decision,pendingKarama:g.pendingKarama,phaseOpening:g.phaseOpening}, + truth:structuredClone(w), + ...(supportedRicheseTruthLot(g) ? {richeseLotEvent:g.richeseAuction!.event} : {}), + resume:{response:g.response,decision:g.decision,pendingKarama:g.pendingKarama,phaseOpening:g.phaseOpening}, stateSignature:''}; g.truthtrance = null; g.response = null; g.decision = null; g.pendingKarama = null; g.phaseOpening = null; const entries = [{card:used,discardedBy:p.id,publicFace:true}]; diff --git a/game/reference.ts b/game/reference.ts index 94946de4..7f83c9bf 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -502,7 +502,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'At a clean boundary after another player has begun shipment and movement, an unstarted holder may provisionally reorder only later unstarted turns. First means first after that current actor finishes; last remains after Guild. The current actor keeps its entire turn and original counters/resources. This does not split your own started turn or interrupt pending card/arrival/payment preparations. Guild retains its next-boundary timing choice. The panel names the actor who must finish.', 'Between completed battles, choose first or last among remaining battle choosers. The priority lasts for the rest of the Battle phase. You still need a real unresolved battle. Others may choose battles against you before your own turn; last is not immunity. This scope changes the chooser, not each battle’s aggressor or tie advantage.', 'In a fresh Richese battle, use unreserved Sapho at a clean boundary before both combatants finish final battle actions, including after sealed plans and revealed declarations. Habbanya still precedes ordinary aggressor tie priority. Participants, plans, promises, weapon choices and votes do not swap or replay. Older battles retain their pre-plan opportunity.', - 'When two or more players declared Truthtrance in a supported clean parent, each queued holder may continue or use their held Sapho to move their entire pending question group first or last. Physical storm order and each holder’s card order stay unchanged. Supported Basic Semuta claim/pass restores the same question queue. Clean ordinary auction lots also preserve their standing bid and remaining clock; special auctions, battle and pending-parent combinations remain unfinished.', + 'When two or more players declared Truthtrance in a supported clean parent, each queued holder may continue or use their held Sapho to move their entire pending question group first or last. Physical storm order and each holder’s card order stay unchanged. Supported Basic Semuta claim/pass restores the same question queue. Clean ordinary auction lots also preserve their standing bid and remaining clock. Richese cache Once Around and Silent lots preserve their original bidding and funding; Black Market, other special auctions, battle and pending-parent combinations remain unfinished.', 'During a clean native Extortion payer decision, use Sapho to take your optional choice first/last among unanswered non-Moritani players. Earlier declines, collected bank5 and the one-payment3 limit remain. The next payer event changes even when that player stays next. You may decline instead of paying.', 'During ordered Advanced Charity, take your remaining turn first or last, then discard Sapho. An accepted current claim provisionally protects that actor while another holder reorders only later unstarted turns. Each player still claims at most once, completed turns stay closed, and the holder cannot split its own started turn. Physical First Player order is unchanged. Basic and older concurrent phases have no ordered scope.', 'During ordered Advanced Revival, take your unfinished opportunity first or last, then discard Sapho. If the current player has already begun a native return or negotiation, provisional queued timing lets that player finish unchanged; first means first among the later unstarted turns. You cannot split your own started turn, repeat a completed turn or consume the card for a position you already hold. Basic and older concurrent Revival phases have no ordered scope.', diff --git a/game/truthtrance.ts b/game/truthtrance.ts index acdad93e..fbb2c4eb 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1290,9 +1290,12 @@ export function resolveTruthAction( // decision to open it never inspects who holds Juice of Sapho. const ordinaryLot = g.phase === 3 && !!g.auction?.cards[g.auction.index] && !!g.auction.active && !g.currentAuctionSale && !g.ixAuction; + const richeseCacheLot = g.phase === 3 && !!g.richeseAuction && g.richeseAuction.source === 'cache' && + (g.richeseAuction.method === 'onceAround' || g.richeseAuction.method === 'silent') && + !g.richeseAuction.outcome && !g.currentAuctionSale && !g.auction && !g.ixAuction; const clean = g.status === 'playing' && g.expansions.includes('choam') && !g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard && !g.pendingNullentropy && (!g.auction || ordinaryLot) && - !g.richeseAuction && !g.battle && !g.pendingRevival && !g.pendingShipment && + (!g.richeseAuction || richeseCacheLot) && !g.battle && !g.pendingRevival && !g.pendingShipment && !g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingChoamMove && !g.pendingIxMove && !g.pendingFremenMove && !g.pendingExchange && !g.pendingRicheseGift && !g.karamaShipping && !g.ornithopter && !g.summonedWorm && g.wormRides.length === 0 && diff --git a/tests/fixture-sapho-truth-richese.ts b/tests/fixture-sapho-truth-richese.ts new file mode 100644 index 00000000..86ffb4ed --- /dev/null +++ b/tests/fixture-sapho-truth-richese.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { type Game } from '../game/engine'; +import { createTruthRicheseBidFixture } from './fixture-truth-richese-bid'; +import { createTruthRicheseSilentFixture } from './fixture-truth-richese-silent'; +import { act, inventory, player } from './fixture-truth-richese-bid'; +import { truthOrderFrame } from './fixture-sapho-truth-order'; + +export { act, inventory, player }; + +/** Native public R/A/E setup and actual started first cache lot. Controlled, + * conserved custody supplements: swap the original cached Sapho for Emperor's + * dealt card; move an undealt original Truth to his vacant hand slot; optionally + * swap original cached Semuta for Richese's dealt card. None is represented as + * an earned purchase. The other actual dealt Truth, lot and funds stay native. */ +export function createSaphoTruthRicheseFixture(options: { + advanced?: boolean; method?: 'onceAround' | 'silent'; semuta?: boolean; standingBid?: boolean; +} = {}) { + const advanced = options.advanced ?? false, method = options.method ?? 'onceAround'; + const source = method === 'silent' ? createTruthRicheseSilentFixture(advanced) : createTruthRicheseBidFixture(advanced); + let game = structuredClone(source.game); + const owner = source.target, other = source.asker, semutaHolder = source.owner, lot = source.lot; + const cache = game.richeseCache!; + const saphoAt = cache.findIndex(card => card.effect === 'juiceOfSapho'); + assert.ok(saphoAt >= 0, 'The original cache must contain Sapho'); + const receiver = player(game,owner); + assert.ok(receiver.hand.length); + const [sapho] = cache.splice(saphoAt,1), [exchange] = receiver.hand.splice(0,1); + receiver.hand.push(sapho); cache.push(exchange); + const truthAt = game.deck.findIndex(card => card.effect === 'truthtrance'); + assert.ok(truthAt >= 0, 'A second original Truth is available in the undealt deck'); + const [truth] = game.deck.splice(truthAt,1); receiver.hand.push(truth); + let semuta: string | null = null; + if (options.semuta) { + const at = cache.findIndex(card => card.effect === 'semutaDrug'); assert.ok(at >= 0); + const claimant = player(game,semutaHolder); assert.ok(claimant.hand.length); + const [drug] = cache.splice(at,1), [held] = claimant.hand.splice(0,1); + claimant.hand.push(drug); cache.push(held); semuta = drug.id; + } + assert.deepEqual(inventory(game),source.originalCards); + if (options.standingBid) { + const bidder = method === 'silent' ? semutaHolder : game.richeseAuction!.active!; + game = act(game,bidder,{type:'richeseBid',event:lot,amount:1,allyPayment:0}); + assert.ok(game.richeseAuction && !game.richeseAuction.outcome); + } + const mode = game.order.indexOf(owner) > game.order.indexOf(other) ? 'first' as const : 'last' as const; + const parent = { + turn:game.turn, phase:game.phase, active:game.active, order:structuredClone(game.order), + auction:structuredClone(game.richeseAuction), funding:structuredClone(game.richeseFunding), + bidding:structuredClone(game.richeseBidding), aid:structuredClone(game.aid), + offered:structuredClone(game.richeseOfferedCard), + players:game.players.map(row => ({id:row.id,spice:row.spice,reserves:row.reserves,tanks:row.tanks})), + }; + const beforeTruth = structuredClone(game); + for (const [actor,card] of [[owner,truth.id],[other,source.truth]] as const) + game = act(game,actor,{type:'card',card}); + while (game.truthtrance?.stage === 'priority') { + const next = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(next); + game = act(game,next.id,{type:'truthPass'}); + } + assert.deepEqual([...truthOrderFrame(game).holders].sort(),[owner,other].sort()); + assertRicheseParent(game,parent); + return {game,source,advanced,method,owner,other,semutaHolder,card:sapho.id,semuta,truth:truth.id, + otherTruth:source.truth,lot,mode,parent,beforeTruth,originalCards:source.originalCards, + disclosure:'Actual public setup and native cache lot; labelled conserved cache/hand swaps and undealt Truth placement.'}; +} +export type SaphoTruthRicheseFixture = ReturnType; +export function assertRicheseParent(game: Game, parent: SaphoTruthRicheseFixture['parent']) { + assert.equal(game.turn,parent.turn); assert.equal(game.phase,parent.phase); assert.equal(game.active,parent.active); + assert.deepEqual(game.order,parent.order); assert.deepEqual(game.richeseAuction,parent.auction); + assert.deepEqual(game.richeseFunding,parent.funding); assert.deepEqual(game.richeseBidding,parent.bidding); + assert.deepEqual(game.aid,parent.aid); assert.deepEqual(game.richeseOfferedCard,parent.offered); + assert.deepEqual(game.players.map(row => ({id:row.id,spice:row.spice,reserves:row.reserves,tanks:row.tanks})),parent.players); +} diff --git a/tests/fixture-truth-richese-bid.ts b/tests/fixture-truth-richese-bid.ts index cf45d6bb..98c6bc69 100644 --- a/tests/fixture-truth-richese-bid.ts +++ b/tests/fixture-truth-richese-bid.ts @@ -4,12 +4,13 @@ import { actTruthtranceRichesePromises as act, advanceTruthtranceRichesePromises as advance, createTruthtranceRichesePromisesFixture, + passTruthRicheseFreshDiscard, truthtranceRichesePromisesClean as clean, truthtranceRichesePromisesInventory as inventory, truthtranceRichesePromisesPlayer as player, } from './fixture-truthtrance-richese-promises'; -export { act, advance, clean, inventory, player }; +export { act, advance, clean, inventory, player, passTruthRicheseFreshDiscard }; /** Actual public R/A/E Basic or Advanced setup. The parent fixture selects only * unopened original deal/Spice positions and native scalar lotteries. Use its @@ -60,7 +61,7 @@ export function bindTruthRicheseBid(f: TruthRicheseBidFixture, answer: 'yes' | ' source = f.game, target = f.target) { const asked = askTruthRicheseBid(f, target, minimum, source); assert.ok(viewGame(asked, target).truthRicheseBidAnswers?.includes(answer)); - const game = act(asked, target, { type: 'truthAnswer', answer }); + const game = passTruthRicheseFreshDiscard(act(asked, target, { type: 'truthAnswer', answer })); return { game, asked }; } export function rejectTruthRicheseBid(game: Game, actor: string, action: Action) { diff --git a/tests/fixture-truth-richese-silent.ts b/tests/fixture-truth-richese-silent.ts index 276bf708..5c285f58 100644 --- a/tests/fixture-truth-richese-silent.ts +++ b/tests/fixture-truth-richese-silent.ts @@ -4,12 +4,13 @@ import { actTruthtranceRichesePromises as act, advanceTruthtranceRichesePromises as advance, createTruthtranceRichesePromisesFixture, + passTruthRicheseFreshDiscard, truthtranceRichesePromisesClean as clean, truthtranceRichesePromisesInventory as inventory, truthtranceRichesePromisesPlayer as player, } from './fixture-truthtrance-richese-promises'; -export { act, inventory, player }; +export { act, inventory, player, passTruthRicheseFreshDiscard }; /** Original public Basic/Advanced Richese/Atreides/Emperor setup, genuine * Charity prefix and actual first Silent cache declaration. The parent fixture @@ -45,7 +46,7 @@ export function askTruthRicheseSilent(f: TruthRicheseSilentFixture, minimum = 4, export function bindTruthRicheseSilent(f: TruthRicheseSilentFixture, answer: 'yes'|'no', minimum = 4, source = f.game) { const asked = askTruthRicheseSilent(f, minimum, source); assert.ok(viewGame(asked, f.target).truthRicheseBidAnswers?.includes(answer)); - return { asked, game:act(asked, f.target, {type:'truthAnswer', answer}) }; + return { asked, game:passTruthRicheseFreshDiscard(act(asked, f.target, {type:'truthAnswer', answer})) }; } export function rejectTruthRicheseSilent(game: Game, actor: string, action: Action) { const before = structuredClone(game); diff --git a/tests/fixture-truthtrance-richese-promises.ts b/tests/fixture-truthtrance-richese-promises.ts index c7644818..27ea2a04 100644 --- a/tests/fixture-truthtrance-richese-promises.ts +++ b/tests/fixture-truthtrance-richese-promises.ts @@ -15,6 +15,20 @@ export type TruthtranceRichesePromisesTransition = { }; export const actTruthtranceRichesePromises = actTruthMovement; export const truthtranceRichesePromisesClean = truthMovementClean; +/** Pass the neutral Basic Semuta offer following a completed Truth discard. + * This uses real player actions rather than bypassing the continuation. */ +export function passTruthRicheseFreshDiscard(state: Game): Game { + let game = state; + for (let count = 0; game.pendingTreacheryDiscard?.reaction?.stage === 'offer' && count < game.players.length; count++) { + const pending = game.pendingTreacheryDiscard, reaction = pending.reaction!; + assert.equal(reaction.stage, 'offer'); + if (reaction.stage !== 'offer') throw Error('The Semuta response must remain an offer'); + const actor = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(actor); + game = actTruthtranceRichesePromises(game, actor.id, {type:'semutaPass', event:pending.batch.event}); + } + assert.ok(!game.pendingTreacheryDiscard?.reaction || game.pendingTreacheryDiscard.reaction.stage !== 'offer'); + return game; +} export const truthtranceRichesePromisesPlayer = (game: Game, actor = 'richese') => truthMovementPlayer(game, actor); export function truthtranceRichesePromisesInventory(game: Game) { const start = (game.auction?.index ?? 0) + Number(game.currentAuctionSale?.origin === 'normal'); diff --git a/tests/sapho-truth-richese-controls.test.tsx b/tests/sapho-truth-richese-controls.test.tsx new file mode 100644 index 00000000..22e66d25 --- /dev/null +++ b/tests/sapho-truth-richese-controls.test.tsx @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { viewGame, type Action, type Game, type GameView } from '../game/engine'; +import { SaphoTruthOrderPass, SaphoTruthOrderChoiceButton } from '../components/sapho-truth-order'; +import { act, inventory, player, createSaphoTruthRicheseFixture, assertRicheseParent } from './fixture-sapho-truth-richese'; +import { passFreshDiscard, finishOriginalTruthQuestions } from './fixture-sapho-truth-order'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); + return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true}; + }, +}); +const { GameTable } = await import('../components/game-table'); +const { Truthtrance } = await import('../components/truthtrance'); +aliases.deregister(); +const table = (game: GameView) => renderToStaticMarkup(createElement(GameTable, + {game,busy:false,send:async()=>{},onExit() {}})); +const truth = (game: GameView) => renderToStaticMarkup(createElement(Truthtrance, + {game,busy:false,act() {}})); + +/** Never project another player's sealed amount or the collective results + * before every eligible player submits. Public submitted identities remain. */ +function checkSilentPrivacy(game: Game, observer: string) { + const lot = game.richeseAuction; assert.ok(lot); assert.equal(lot.method,'silent'); + const view = viewGame(game,observer).richeseAuction; assert.ok(view); + assert.equal(view.revealedBids,null); + if (!Object.hasOwn(lot.sealed,observer)) assert.equal(view.ownBid,null); + for (const other of Object.keys(lot.sealed).filter(id => id !== observer)) + assert.ok(view.submitted.includes(other)); +} + +for (const method of ['onceAround','silent'] as const) void test( + `Basic ${method} actual Truth-order controls preserve the Richese lot and Semuta continuation`, () => { + // Genuine public R/A/E setup and a real native Richese cache lot. The + // fixture explicitly stages conserved original Sapho/Truth/Semuta card + // custody; it does not claim those card acquisitions were earned. + const f = createSaphoTruthRicheseFixture({advanced:false,method,semuta:true,standingBid:true}); + let game = f.game; + assertRicheseParent(game,f.parent); + if (method === 'silent') checkSilentPrivacy(game,f.other); + const other = viewGame(game,f.other), frame = other.truthtrance?.order; + assert.ok(frame); assert.deepEqual(other.saphoTruthOrderChoices,[]); + assert.match(truth(other),/Continue without changing question order/); + assert.match(table(other),/Continue without changing question order/); + assert.doesNotMatch(truth(other),/Use Juice of Sapho to ask/); + assert.doesNotMatch(truth(other),new RegExp(f.card)); + + // Render real table controls, then invoke their actual hook-free leaf + // callbacks. Server rendering does not operate a browser or hydration. + const sent: Action[] = []; + const pass = SaphoTruthOrderPass({frame,me:f.other,choices:other.saphoTruthOrderChoices, + busy:false,act(action) {sent.push(action);}}); + assert.equal(pass.props.disabled,false); pass.props.onClick(); + assert.deepEqual([...sent],[{type:'truthOrderPass',event:frame.event,revision:frame.revision}]); + game = act(game,f.other,sent.pop()!); assertRicheseParent(game,f.parent); + + const own = viewGame(game,f.owner), current = own.truthtrance?.order; + assert.ok(current); assert.ok(current.passed.includes(f.other)); + const choice = own.saphoTruthOrderChoices.find(row => row.card === f.card && row.mode === f.mode); + assert.ok(choice); assert.match(truth(own),new RegExp(`Use Juice of Sapho to ask ${f.mode}`)); + assert.match(table(own),new RegExp(`Use Juice of Sapho to ask ${f.mode}`)); + const use = SaphoTruthOrderChoiceButton({frame:current,me:f.owner,choices:own.saphoTruthOrderChoices, + choice,busy:false,act(action) {sent.push(action);}}); + assert.equal(use.props.disabled,false); use.props.onClick(); + assert.deepEqual([...sent],[{type:'saphoTruthOrder',event:current.event,revision:current.revision, + card:f.card,mode:f.mode}]); + game = act(game,f.owner,sent.pop()!); assertRicheseParent(game,f.parent); + assert.ok(game.pendingTreacheryDiscard?.reaction,'The fresh Sapho discard opens neutral Semuta'); + if (method === 'silent') checkSilentPrivacy(game,f.other); + + if (method === 'silent') { + const event = game.pendingTreacheryDiscard.batch.event; + assert.equal(viewGame(game,f.semutaHolder).semutaReaction?.canCommit,true); + game = act(game,f.semutaHolder,{type:'semutaCommit',event}); + assert.ok(player(game,f.semutaHolder).hand.some(card => card.id === f.card)); + assert.ok(game.discard.some(card => card.id === f.semuta)); + } else game = passFreshDiscard(game); + assertRicheseParent(game,f.parent); + assert.equal(game.truthtrance?.stage,'ask'); + if (method === 'silent') checkSilentPrivacy(game,f.other); + assert.deepEqual(inventory(game),f.originalCards); + game = finishOriginalTruthQuestions(game); + assert.equal(game.truthtrance,null); assertRicheseParent(game,f.parent); + if (method === 'silent') checkSilentPrivacy(game,f.other); + assert.deepEqual(inventory(game),f.originalCards); + const resumed = game.richeseAuction; assert.ok(resumed); + const bidder = method === 'silent' + ? resumed.eligible.find(id => !Object.hasOwn(resumed.sealed,id)) + : resumed.active; + assert.ok(bidder); + game = act(game,bidder,{type:'richeseBid',event:resumed.event, + amount:method === 'silent' ? 0 : null,allyPayment:0}); + assert.deepEqual(inventory(game),f.originalCards); + }, +); diff --git a/tests/sapho-truth-richese-runtime.test.ts b/tests/sapho-truth-richese-runtime.test.ts new file mode 100644 index 00000000..47e9b2cc --- /dev/null +++ b/tests/sapho-truth-richese-runtime.test.ts @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Game } from '../game/engine'; +import { createSaphoTruthRicheseFixture as fixture, assertRicheseParent, act, inventory, player } from './fixture-sapho-truth-richese'; +import { truthOrderFrame as frame, passTruthOrder as pass, playSaphoTruthOrder as play, + finishTruthOrder as finish, finishOriginalTruthQuestions as answer, passFreshDiscard, + rejectSaphoTruthOrder as reject, ownOrderChoices as choices } from './fixture-sapho-truth-order'; + +function assertSealedPrivacy(game: Game, bidder: string, observer: string) { + const own = viewGame(game,bidder).richeseAuction!, other = viewGame(game,observer).richeseAuction!; + assert.equal(own.ownBid,1); assert.equal(other.ownBid,null); + assert.equal(other.revealedBids,null); + assert.equal('sealed' in other,false); +} +function continueBidding(game: Game, method: 'onceAround'|'silent', lot: string) { + const actor = method === 'onceAround' ? game.richeseAuction!.active! : + game.richeseAuction!.order.find(id => !Object.hasOwn(game.richeseAuction!.sealed,id))!; + assert.ok(actor); + return act(game,actor,{type:'richeseBid',event:lot,amount:method === 'silent' ? 0 : null,allyPayment:0}); +} + +for (const advanced of [false,true]) for (const method of ['onceAround','silent'] as const) { + void test(`${advanced ? 'Advanced' : 'Basic'} ${method} Sapho preserves the funded native cache lot while ordering Truth`, () => { + const f = fixture({advanced,method,standingBid:true}), state = f.game, current = frame(state); + assertRicheseParent(state,f.parent); + if (method === 'silent') assertSealedPrivacy(state,f.semutaHolder,f.owner); + assert.ok(choices(state,f.owner).some(row => row.card === f.card && row.mode === f.mode)); + const noOp = f.mode === 'first' ? 'last' : 'first'; + reject(state,f.owner,{type:'saphoTruthOrder',event:current.event,revision:current.revision,card:f.card,mode:noOp}); + reject(state,f.owner,{type:'saphoTruthOrder',event:'stale',revision:current.revision,card:f.card,mode:f.mode}); + reject(state,f.owner,{type:'saphoTruthOrder',event:current.event,revision:current.revision + 1,card:f.card,mode:f.mode}); + let game = play(state,f.owner,f.card,f.mode); + assertRicheseParent(game,f.parent); + if (method === 'silent') assertSealedPrivacy(game,f.semutaHolder,f.owner); + game = passFreshDiscard(game); + assertRicheseParent(game,f.parent); + reject(game,f.owner,{type:'saphoTruthOrder',event:current.event,revision:current.revision,card:f.card,mode:f.mode}); + game = finish(game); + assert.equal(f.mode === 'first' ? game.truthtrance!.queue[0].player : game.truthtrance!.queue.at(-1)!.player,f.owner); + assertRicheseParent(game,f.parent); + game = answer(game); + assert.equal(game.truthtrance,null); assertRicheseParent(game,f.parent); + if (method === 'silent') assertSealedPrivacy(game,f.semutaHolder,f.owner); + assert.deepEqual(inventory(game),f.originalCards); + game = continueBidding(game,method,f.lot); + assert.equal(game.richeseAuction?.event,f.lot); + assert.deepEqual(inventory(game),f.originalCards); + }); +} + +for (const method of ['onceAround','silent'] as const) for (const claim of [false,true]) { + void test(`Basic ${method} Sapho Semuta ${claim ? 'claim' : 'pass'} restores the original funded cache lot`, () => { + const f = fixture({method,standingBid:true,semuta:true}); + assert.ok(f.semuta); assertRicheseParent(f.game,f.parent); + let game = pass(f.game,f.other); + game = play(game,f.owner,f.card,f.mode); + const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); + assert.equal(pending.continuation.kind,'saphoTruthOrderDiscard'); + assert.equal(pending.batch.entries[0].card.id,f.card); + assert.equal(viewGame(game,f.semutaHolder).semutaReaction?.canCommit,true); + assertRicheseParent(game,f.parent); + if (method === 'silent') assertSealedPrivacy(game,f.semutaHolder,f.owner); + if (claim) game = act(game,f.semutaHolder,{type:'semutaCommit',event:pending.batch.event}); + else game = passFreshDiscard(game); + assert.equal(game.pendingTreacheryDiscard,null); assert.equal(game.truthtrance?.stage,'ask'); + assertRicheseParent(game,f.parent); + assert.equal(player(game,f.semutaHolder).hand.some(card => card.id === f.card),claim); + assert.equal(game.discard.some(card => card.id === f.semuta),claim); + assert.deepEqual(inventory(game),f.originalCards); + game = answer(game); assert.equal(game.truthtrance,null); assertRicheseParent(game,f.parent); + game = continueBidding(game,method,f.lot); + assert.equal(game.richeseAuction?.event,f.lot); assert.deepEqual(inventory(game),f.originalCards); + }); +} + +for (const method of ['onceAround','silent'] as const) { + void test(`Basic ${method} definite Truth cost offers a fresh Semuta reaction after Sapho was passed`, () => { + const f = fixture({method,standingBid:true,semuta:true}); + let game = pass(f.game,f.other); + game = play(game,f.owner,f.card,f.mode); + assert.equal(game.pendingTreacheryDiscard?.continuation.kind,'saphoTruthOrderDiscard'); + game = passFreshDiscard(game); + assert.equal(game.truthtrance?.stage,'ask'); assertRicheseParent(game,f.parent); + const asker = game.truthtrance!.queue[0].player; + const target = game.players.find(row => row.id !== asker)!.id; + const consumed = game.truthtrance!.queue[0].card; + game = act(game,asker,{type:'truthAsk',question:{kind:'fact',target,fact:{kind:'spice',compare:'gte',value:0}}}); + game = act(game,target,{type:'truthAnswer',answer:'yes'}); + const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); + assert.equal(pending.continuation.kind,'truthtranceDiscard'); + assert.equal(pending.batch.entries[0].card.id,consumed); + assert.equal(viewGame(game,f.semutaHolder).semutaReaction?.canCommit,true); + assertRicheseParent(game,f.parent); + if (method === 'silent') assertSealedPrivacy(game,f.semutaHolder,f.owner); + game = act(game,f.semutaHolder,{type:'semutaCommit',event:pending.batch.event}); + assert.equal(game.pendingTreacheryDiscard,null); + assert.ok(player(game,f.semutaHolder).hand.some(card => card.id === consumed)); + assertRicheseParent(game,f.parent); + game = answer(game); assert.equal(game.truthtrance,null); assertRicheseParent(game,f.parent); + assert.deepEqual(inventory(game),f.originalCards); + game = continueBidding(game,method,f.lot); + assert.equal(game.richeseAuction?.event,f.lot); assert.deepEqual(inventory(game),f.originalCards); + }); +} diff --git a/tests/truth-richese-bid-integrated-controls.test.tsx b/tests/truth-richese-bid-integrated-controls.test.tsx index 89311b7f..6e44ffc7 100644 --- a/tests/truth-richese-bid-integrated-controls.test.tsx +++ b/tests/truth-richese-bid-integrated-controls.test.tsx @@ -6,7 +6,7 @@ import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { viewGame, type Action, type GameView } from '../game/engine'; import { RicheseBidPromiseGuide } from '../components/richese-bid-promise-panel'; -import { act, inventory, createTruthRicheseBidFixture, declareTruthRicheseBid, +import { act, inventory, passTruthRicheseFreshDiscard, createTruthRicheseBidFixture, declareTruthRicheseBid, askTruthRicheseBid } from './fixture-truth-richese-bid'; const aliases = registerHooks({ @@ -61,7 +61,7 @@ for (const advanced of [false, true]) void test( assert.match(auction(respondent), /Richese auction/); for (const answer of ['yes', 'no'] as const) { - let game = act(asked, f.target, { type: 'truthAnswer', answer }); + let game = passTruthRicheseFreshDiscard(act(asked, f.target, { type: 'truthAnswer', answer })); const own = viewGame(game, f.target), sent: Action[] = []; assert.ok(own.richeseBidPromises.some(promise => promise.player === f.target && promise.answer === (answer === 'yes'))); assert.ok(own.richeseBidPromiseGuide); diff --git a/tests/truth-richese-bid-runtime.test.ts b/tests/truth-richese-bid-runtime.test.ts index 12b90815..73a92df7 100644 --- a/tests/truth-richese-bid-runtime.test.ts +++ b/tests/truth-richese-bid-runtime.test.ts @@ -4,7 +4,7 @@ import { viewGame } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { - act, inventory, player, createTruthRicheseBidFixture, askTruthRicheseBid, + act, inventory, player, passTruthRicheseFreshDiscard, createTruthRicheseBidFixture, askTruthRicheseBid, bindTruthRicheseBid, rejectTruthRicheseBid as reject, } from './fixture-truth-richese-bid'; @@ -85,7 +85,7 @@ for (const advanced of [false, true]) { const answer = botActions(answerView)[0]; assert.ok(answer, difficulty); assert.equal(answer.type, 'truthAnswer'); assert.ok(['yes', 'no'].includes(String(answer.answer))); - const bound = act(structuredClone(asked), f.target, answer); + const bound = passTruthRicheseFreshDiscard(act(structuredClone(asked), f.target, answer)); const bidView = viewGame(bound, f.target); bidView.players.find(row => row.id === f.target)!.bot = difficulty; const guide = bidView.richeseBidPromiseGuide; assert.ok(guide, difficulty); diff --git a/tests/truth-richese-silent-integrated-controls.test.tsx b/tests/truth-richese-silent-integrated-controls.test.tsx index 6e142e34..2f1637bd 100644 --- a/tests/truth-richese-silent-integrated-controls.test.tsx +++ b/tests/truth-richese-silent-integrated-controls.test.tsx @@ -6,7 +6,7 @@ import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { viewGame, type Action, type Game, type GameView } from '../game/engine'; import { RicheseBidPromiseGuide } from '../components/richese-bid-promise-panel'; -import { act, inventory, createTruthRicheseSilentFixture, askTruthRicheseSilent } from './fixture-truth-richese-silent'; +import { act, inventory, passTruthRicheseFreshDiscard, createTruthRicheseSilentFixture, askTruthRicheseSilent } from './fixture-truth-richese-silent'; const aliases = registerHooks({ resolve(specifier, context, next) { @@ -68,7 +68,7 @@ for (const advanced of [false, true]) void test( assert.match(auction(respondent), /Silent auction/); for (const answer of ['yes', 'no'] as const) { - let game = act(asked, f.target, {type:'truthAnswer', answer}); + let game = passTruthRicheseFreshDiscard(act(asked, f.target, {type:'truthAnswer', answer})); const own = viewGame(game, f.target), sent: Action[] = []; assert.ok(own.richeseBidPromises.some(row => row.player === f.target && row.answer === (answer === 'yes'))); assert.ok(own.richeseBidPromiseGuide); diff --git a/tests/truth-richese-silent-runtime.test.ts b/tests/truth-richese-silent-runtime.test.ts index 558bf02d..3e0223fd 100644 --- a/tests/truth-richese-silent-runtime.test.ts +++ b/tests/truth-richese-silent-runtime.test.ts @@ -3,7 +3,7 @@ import test from 'node:test'; import { handLimit, normalizeAutomaticGame, viewGame } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; -import { act, inventory, player, createTruthRicheseSilentFixture, askTruthRicheseSilent, +import { act, inventory, player, passTruthRicheseFreshDiscard, createTruthRicheseSilentFixture, askTruthRicheseSilent, bindTruthRicheseSilent, rejectTruthRicheseSilent as reject } from './fixture-truth-richese-silent'; for (const advanced of [false, true]) { @@ -59,7 +59,7 @@ for (const advanced of [false, true]) { assert.equal(viewGame(source, f.target).richeseAuction?.ownBid, null); const asked = askTruthRicheseSilent(f, 4, source); assert.ok(viewGame(asked, f.target).truthRicheseBidAnswers?.includes('yes')); - const bound = act(asked, f.target, {type:'truthAnswer', answer:'yes'}); + const bound = passTruthRicheseFreshDiscard(act(asked, f.target, {type:'truthAnswer', answer:'yes'})); const guide = viewGame(bound, f.target).richeseBidPromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 4); const submitted = act(bound, f.target, guide.action); @@ -84,7 +84,7 @@ for (const advanced of [false, true]) { const answer = botActions(answerView)[0]; assert.ok(answer, difficulty); assert.equal(answer.type, 'truthAnswer'); assert.ok(['yes','no'].includes(String(answer.answer))); - const bound = act(structuredClone(asked), f.target, answer); + const bound = passTruthRicheseFreshDiscard(act(structuredClone(asked), f.target, answer)); const bidView = viewGame(bound, f.target); bidView.players.find(row => row.id === f.target)!.bot = difficulty; const guide = bidView.richeseBidPromiseGuide; assert.ok(guide, difficulty);