diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index d36edb2f..079caf34 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,14 +20,24 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**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. +[Scope and source](JUICE_OF_SAPHO_RUNTIME.md#truthtrance-order-in-ordinary-bidding--27-october-2026). + **Sapho Truthtrance question order — 27 October. Prototyped / Partial.** When multiple players declare Truthtrance in a supported clean parent, each holder receives a public opportunity to continue or privately use an owned Juice 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. Auction, -battle, pending-parent and broader optional-module compositions remain open. +chosen boundary remains protected if Sapho is recovered and used again. The +ordinary auction extension above now applies; 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 acceptance remains open. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index d10baa61..3971daf2 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,21 @@ # Dune implementation status +## 27 October 2026 — Sapho Truthtrance order in ordinary bidding + +Permit the possession-independent Truth ordering opportunity in a clean live +ordinary auction, preserving its original actor, lot, standing bid, funds and +native timer. The new Semuta continuation binds the actual auction and aid ledger +without signing the separate clock, so claim/pass resumes the same queue and the +clock resumes its remaining budget. Earlier non-auction continuation signatures +retain their original shape. A shared post-cost forecast now honors accepted +whole-turn answers and cannot borrow physically queued Truth cards as later +Distrans payloads. Basic/Advanced auction tests use earned Sapho, real bids, +and explicitly labeled conserved card swaps. Nested responses, special auctions, +battle, browser and deployed acceptance remain open. Thirteen affected files +pass 136/136; type/lint, build and 55/55 local integration tests pass. Private +proof is `20261027-sapho-truth-auction-proof-v2/`. +[Contract](JUICE_OF_SAPHO_RUNTIME.md#truthtrance-order-in-ordinary-bidding--27-october-2026). + ## 27 October 2026 — Sapho simultaneous Truthtrance order A public response window follows storm-order sorting when two or more different diff --git a/docs/JUICE_OF_SAPHO_RUNTIME.md b/docs/JUICE_OF_SAPHO_RUNTIME.md index 1b00c02f..90636c55 100644 --- a/docs/JUICE_OF_SAPHO_RUNTIME.md +++ b/docs/JUICE_OF_SAPHO_RUNTIME.md @@ -1,5 +1,32 @@ # Juice of Sapho: finite ordering implementation +## Truthtrance order in ordinary bidding — 27 October 2026 + +**Prototyped / Partial.** The public Sapho response after simultaneous +Truthtrance declarations now also opens at a clean live ordinary lot. The +original lot, card, actor, standing bid, pass order and aid remain native. +A Semuta claim/pass temporarily suspends the question queue and binds that +original auction and aid ledger in the continuation; the separate trusted clock +is deliberately not signed or reset. It pauses throughout Truth and Semuta, +then resumes the same bidder's remaining time once the questions end. + +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 +remain outside this slice. The FAQ and Sapho face establish the individual +rules; their relative order remains a provisional implementation choice. + +Original Basic/Advanced public setup earns Sapho and reaches an unbid or an +actual standing-bid lot; labeled conserved physical card swaps give a queued +holder Truth or Semuta. Controlled-clock cases exercise time spent in ordering, +Semuta and original questions, followed by the real timeout. A separate labeled +whole-turn promise test verifies its actual legal Distrans route and rejects +using the last required card. Thirteen affected files pass 136/136; type/lint, +build and 55/55 isolated local integration tests pass. Browser and deployed +acceptance remain open. + ## Simultaneous Truthtrance order — 27 October 2026 **Prototyped / Partial.** The [GF9 November 2020 FAQ p8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8) @@ -24,10 +51,10 @@ In the supported bare Basic Semuta preview, the original discard event opens a 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. Initial scope excludes live auction or -battle, active pending responses/preparations and broader Semuta/module -profiles; these remain open rather than treating the restriction as printed -rules. Wider repeated-card combinations, browser, deployed and complete-mode +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 +profiles remain open rather than treating the restriction as printed rules. Wider repeated-card combinations, browser, deployed and complete-mode acceptance remain open. Runtime tests use a genuine native Basic/Advanced setup and an earned Sapho, diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 0a6cb76f..ee3e4c85 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,15 @@ # Rule decision index +## Sapho Truthtrance order during ordinary bidding — 27 October 2026 + +Provisionally extend the existing simultaneous-question ordering response to a +clean ordinary auction. Keep the actual lot, actor, standing bid, funding and +clock budget; the existing Semuta reaction resumes the exact suspended question +order. Preserve earlier binding answers when spending Sapho. The FAQ and card +face support the constituent actions but do not decide their precedence. Special +auctions and nested response windows remain separate. +[Contract](JUICE_OF_SAPHO_RUNTIME.md#truthtrance-order-in-ordinary-bidding--27-october-2026). + ## Sapho simultaneous Truthtrance order — 27 October 2026 The FAQ orders simultaneous Truthtrance questions by storm order, while the diff --git a/game/engine.ts b/game/engine.ts index 7e59c1eb..8817eefc 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -5115,6 +5115,8 @@ function savedTransferResponses(g: Game) { ]; } function transferCardBlock(g: Game, owner: Player, card: Card, opaqueTransfer = false) { + if ((g as TurnActionCardProbe)._turnActionReservedTruthCards?.includes(card.id)) + return 'A queued Truthtrance cannot fund a later card transfer.'; if (!opaqueTransfer && (g as TurnActionCardProbe)._turnActionUnknownCards?.includes(card.id)) return 'An unseen future acquired card cannot fund this completion.'; if (isPortableSnooper(card) && homeworldSavedDecisions(g).some((d) => d.kind === 'homeworldDefense' && d.player === owner.id)) @@ -5906,7 +5908,10 @@ type SaphoTruthOrderDiscardContinuation = Extract< function saphoTruthOrderDiscardSignature(g: Game, c: SaphoTruthOrderDiscardContinuation) { return JSON.stringify({player:c.player,card:c.card,event:c.event,revision:c.revision,mode:c.mode, truth:c.truth,parent:nullentropyParentSignature(g,c.resume,c.event),turn:g.turn,phase:g.phase, - order:g.order,active:g.active}); + 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} : {})}); } type SaphoMovementDiscardContinuation = Extract< NonNullable['continuation'], @@ -8726,7 +8731,8 @@ function semutaOfferSupported( // existing signed continuation. Semuta itself has no during-bid exception; // native Distrans still cannot produce a transfer after that bid starts. const ordinaryAuctionDiscard = g.phase === 3 && !!g.auction && !g.richeseAuction && !g.currentAuctionSale && - (continuation.kind === 'truthtranceDiscard' || continuation.kind === 'distransDiscard'); + (continuation.kind === 'truthtranceDiscard' || continuation.kind === 'distransDiscard' || + continuation.kind === 'saphoTruthOrderDiscard'); const richeseGiftDiscard = continuation.kind === 'karamaRicheseGiftDiscard'; const richeseDiscard = richeseGiftDiscard || continuation.kind === 'karamaRicheseAuctionDiscard'; const auctionAdvantageDiscard = continuation.kind === 'karamaAuctionAdvantageDiscard'; @@ -19646,6 +19652,7 @@ type TurnActionCardProbe = Game & { _turnActionCardPolicy?: { player: string; forbidden: PrintedCardEffect[] }; _turnActionWindowCardPolicy?: { player: string; forbidden: { effect: WindowCardEffect; source: string }[] }; _turnActionUnknownCards?: string[]; + _turnActionReservedTruthCards?: string[]; _turnActionPriceCursor?: { order: string[]; index: number }; _turnActionShipmentReceipt?: { player: string; event?: { territory: string; amount: number } | null }; _turnActionThumperForecast?: true; @@ -20073,6 +20080,19 @@ function guardTurnActionPrintedCardCost(g: Game, player: Player, used: Card) { if (!liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).length) return; const trial = structuredClone(g), owner = getPlayer(trial, player.id); owner.spice += pendingBattleRevivalIncome(trial, owner); + const suspended = g.pendingTreacheryDiscard?.continuation; + const queued = g.truthtrance?.stage === 'order' ? g.truthtrance.queue : + suspended?.kind === 'saphoTruthOrderDiscard' ? suspended.truth.queue : []; + // These physical cards are committed to unresolved questions and cannot be + // assumed available for a later guaranteed completion when the overlay is + // removed from this detached forecast. + if (queued.length) { + const probe = trial as TurnActionCardProbe, cards = queued.map(row => row.card); + probe._turnActionUnknownCards = [...new Set([...(probe._turnActionUnknownCards ?? []), ...cards])]; + // Unlike unknown faces, a queued physical Truth cannot even be transferred + // opaquely as a generic Distrans payload. + probe._turnActionReservedTruthCards = [...new Set([...(probe._turnActionReservedTruthCards ?? []), ...cards])]; + } trial.truthtrance = null; trial.response = null; trial.pendingKarama = null; if (used.effect === 'juiceOfSapho' || used.effect === 'distrans') { // The effect is committed before its optional used-card claim. The actual @@ -26425,7 +26445,6 @@ function saphoExtortionDecision(g: Game) { * This does not inspect whether anyone holds Semuta; it prevents offering a * Sapho action whose mandatory supported reaction cannot be staged. */ function saphoTruthOrderSemutaSupported(g: Game,p: Player,card: string,mode: 'first'|'last'): boolean { - if (!g.semutaPreview) return true; const trial = structuredClone(g), actor = getPlayer(trial,p.id), w = trial.truthtrance; if (w?.stage !== 'order' || !w.order) return false; try { @@ -26433,6 +26452,8 @@ function saphoTruthOrderSemutaSupported(g: Game,p: Player,card: string,mode: 'fi const result = reorderSaphoTruthOrder(w.order,w.queue,{event,revision,player:p.id,mode}); recordNativePrintedCardPlay(trial,p.id,'juiceOfSapho'); const used = discard(trial,actor,card); + guardTurnActionPrintedCardCost(trial,actor,used); + if (!trial.semutaPreview) return true; 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, @@ -40034,7 +40055,12 @@ function applyActionInner( ), 'The card reserved for this Black Market auction cannot be played or queued for another effect.', ); - if (t === 'saphoTruthOrder') { playSaphoTruthOrder(g,p,action); return g; } + if (t === 'saphoTruthOrder') { + const used = p.hand.find(card => card.id === action.card); + playSaphoTruthOrder(g,p,action); + if (execution === 'live' && used) guardTurnActionPrintedCardCost(g,p,used); + return g; + } try { if ( resolveTruthAction(g, p, action, { diff --git a/game/reference.ts b/game/reference.ts index 0ea6a5fb..94946de4 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. Auction, 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; 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 c92dd075..acdad93e 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1288,8 +1288,10 @@ export function resolveTruthAction( ); // Every committed holder gets the same public response window. The // 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 clean = g.status === 'playing' && g.expansions.includes('choam') && !g.response && !g.decision && !g.phaseOpening && - !g.pendingKarama && !g.pendingTreacheryDiscard && !g.pendingNullentropy && !g.auction && + !g.pendingKarama && !g.pendingTreacheryDiscard && !g.pendingNullentropy && (!g.auction || ordinaryLot) && !g.richeseAuction && !g.battle && !g.pendingRevival && !g.pendingShipment && !g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingChoamMove && !g.pendingIxMove && !g.pendingFremenMove && !g.pendingExchange && !g.pendingRicheseGift && diff --git a/tests/fixture-sapho-truth-auction.ts b/tests/fixture-sapho-truth-auction.ts new file mode 100644 index 00000000..7e74b08e --- /dev/null +++ b/tests/fixture-sapho-truth-auction.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { type Game } from '../game/engine'; +import { createSaphoStartedMovementFixture, advanceSaphoStartedMovement, + saphoStartedMovementClean } from './fixture-sapho-started-movement'; +import { act, inventory, player, truthOrderFrame } from './fixture-sapho-truth-order'; + +export { act, inventory, player }; + +/** Genuine public R/A/E/G setup and original cache1 Sapho purchase. Continue + * that acquired snapshot through native actions to a quiet original ordinary + * auction. Controlled supplemental custody swaps one actually dealt Truth + * with Emperor's other held original card so the Sapho owner is a holder. + * No purse, force, phase, auction, receipt or physical card is installed. */ +export function createSaphoTruthAuctionFixture(options: { + advanced?:boolean; standingBid?:boolean; mode?:'first'|'last'; +} = {}) { + const advanced = options.advanced ?? false, standingBid = options.standingBid ?? false, mode = options.mode ?? 'first'; + const source = createSaphoStartedMovementFixture({advanced,truthFor:['richese','guild']}); + let game = advanceSaphoStartedMovement(structuredClone(source.acquired), state => + state.phase === 3 && !!state.auction && saphoStartedMovementClean(state)); + assert.ok(game.auction); assert.equal(game.auction.bid,0); assert.equal(game.auction.bidder,null); + if (standingBid) { + const actor = game.auction.active; + game = act(game,actor,{type:'bid',amount:1,allyPayment:0}); + assert.equal(game.auction?.bid,1); assert.equal(game.auction?.bidder,actor); + assert.ok(saphoStartedMovementClean(game)); + } + const owner = source.owner; + const possible = (['richese','guild'] as const).filter(id => mode === 'first' + ? game.order.indexOf(id) < game.order.indexOf(owner) + : game.order.indexOf(id) > game.order.indexOf(owner)); + assert.ok(possible.length,`Original public storm order needs a holder on the ${mode} side of Emperor`); + const other = possible[0], donor = other === 'richese' ? 'guild' : 'richese'; + const from = player(game,donor), receiver = player(game,owner); + const truthAt = from.hand.findIndex(card => card.effect === 'truthtrance'); assert.ok(truthAt >= 0); + const exchangeAt = receiver.hand.findIndex(card => card.id !== source.card && card.effect !== 'truthtrance'); assert.ok(exchangeAt >= 0); + const [truth] = from.hand.splice(truthAt,1), [exchange] = receiver.hand.splice(exchangeAt,1); + from.hand.push(exchange); receiver.hand.push(truth); + const otherTruth = player(game,other).hand.find(card => card.effect === 'truthtrance'); assert.ok(otherTruth); + assert.ok(receiver.hand.some(card => card.id === source.card)); + assert.deepEqual(inventory(game),source.originalInventory); + const parent = {auction:structuredClone(game.auction), aid:structuredClone(game.aid), + turn:game.turn, phase:game.phase, active:game.active, order:[...game.order], + players:game.players.map(row => ({id:row.id,spice:row.spice,reserves:row.reserves,tanks:row.tanks}))}; + const beforeTruth = structuredClone(game); + for (const actor of [owner,other]) { + const card = player(game,actor).hand.find(row => row.effect === 'truthtrance'); assert.ok(card); + game = act(game,actor,{type:'card',card:card.id}); + } + while (game.truthtrance?.stage === 'priority') { + const actor = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(actor); + game = act(game,actor.id,{type:'truthPass'}); + } + const frame = truthOrderFrame(game); + assert.deepEqual([...frame.holders].sort(),[owner,other].sort()); + assert.deepEqual(game.auction,parent.auction); assert.deepEqual(game.aid,parent.aid); + const queue = game.truthtrance!.queue.map(row => row.player); + assert.equal(mode === 'first' ? queue.at(-1) : queue[0],owner); + assert.deepEqual(inventory(game),source.originalInventory); + return {game,source,beforeTruth,advanced,standingBid,mode,owner,other,donor,card:source.card, + truth:truth.id,otherTruth:otherTruth.id,parent,originalCards:source.originalInventory, + disclosure:'Original public setup, real cache Sapho purchase and ordinary lot; one labelled original dealt Truth↔held card swap.'}; +} +export type SaphoTruthAuctionFixture = ReturnType; +/** Keep the actual ordinary lot and winning invoice intact while the overlay is live. */ +export function assertAuctionParent(game: Game, parent: SaphoTruthAuctionFixture['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.auction,parent.auction); assert.deepEqual(game.aid,parent.aid); + assert.deepEqual(game.players.map(row => ({id:row.id,spice:row.spice,reserves:row.reserves,tanks:row.tanks})),parent.players); +} diff --git a/tests/sapho-truth-auction-clock.test.ts b/tests/sapho-truth-auction-clock.test.ts new file mode 100644 index 00000000..f7250914 --- /dev/null +++ b/tests/sapho-truth-auction-clock.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { advanceAuctionClock, hasAuctionClockOpportunity, synchronizeAuctionClock, type Game } from '../game/engine'; +import { createSaphoTruthAuctionFixture as fixture, act, inventory, player, assertAuctionParent } from './fixture-sapho-truth-auction'; +import { truthOrderFrame as frame, playSaphoTruthOrder as play, passFreshDiscard, + finishTruthOrder as finish, finishOriginalTruthQuestions as answer } from './fixture-sapho-truth-order'; + +const epoch = '0:0'; + +/** Replays the original starting deal and earned cache Sapho fixture into its + * original ordinary lot. A Truth↔hand exchange is already labeled there; for + * Basic Semuta cases, additionally exchange the actual cached Semuta with an + * Atreides hand card, preserving physical inventory and the original purse. + * Trusted clock times below are supplied directly, not read from wall time. */ +function openTimedTruth(advanced: boolean, semuta: boolean) { + const f = fixture({advanced,standingBid:true,mode:'first'}); + let game: Game = structuredClone(f.beforeTruth); + if (semuta) { + const cache = game.richeseCache, holder = player(game,'atreides'); assert.ok(cache); + const at = cache.findIndex(card => card.effect === 'semutaDrug'); assert.ok(at >= 0 && holder.hand.length > 0); + const [card] = cache.splice(at,1), [exchange] = holder.hand.splice(0,1); + holder.hand.push(card); cache.push(exchange); + } + assertAuctionParent(game,f.parent); assert.deepEqual(inventory(game),f.originalCards); + assert.equal(game.auction?.clockVersion,1); assert.equal(hasAuctionClockOpportunity(game),true); + game = synchronizeAuctionClock(game,1_000,epoch); + assert.ok(game.auctionClock); assert.equal(game.auctionClock.remainingMs,10_000); + assert.equal(game.auctionClock.startedAt,1_000); assert.equal(game.auctionClock.player,game.auction!.active); + const initial = structuredClone(game.auctionClock); + // A real Truth declaration interrupts the bidder after two elapsed seconds. + game = act(game,f.owner,{type:'card',card:f.truth}); + game = synchronizeAuctionClock(game,3_000,epoch); + assert.equal(game.auctionClock?.event,initial.event); assert.equal(game.auctionClock?.remainingMs,8_000); + assert.equal(game.auctionClock?.startedAt,null); + game = act(game,f.other,{type:'card',card:f.otherTruth}); + game = synchronizeAuctionClock(game,10_000,epoch); + while (game.truthtrance?.stage === 'priority') { + const actor = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(actor); + game = act(game,actor.id,{type:'truthPass'}); + game = synchronizeAuctionClock(game,20_000,epoch); + } + assert.equal(game.truthtrance?.stage,'order'); + assert.equal(game.auctionClock?.event,initial.event); assert.equal(game.auctionClock?.remainingMs,8_000); + assert.equal(game.auctionClock?.startedAt,null); assertAuctionParent(game,f.parent); + return {f,game,initial}; +} + +for (const scenario of [ + {label:'Basic Semuta pass',advanced:false,semuta:true,claim:false}, + {label:'Basic Semuta claim',advanced:false,semuta:true,claim:true}, + {label:'Advanced without Semuta preview',advanced:true,semuta:false,claim:false}, +]) void test(`${scenario.label}: the native bidder clock pauses through Truth order and Sapho disposal`, () => { + const {f,game:opened,initial} = openTimedTruth(scenario.advanced,scenario.semuta); + let game = opened; + const event = game.auctionClock!.event, playerId = game.auctionClock!.player; + assert.equal(event,initial.event); + assert.equal(game.auctionClock!.remainingMs,8_000); assert.equal(game.auctionClock!.startedAt,null); + const beforeLot = structuredClone(game.auction), orderFrame = frame(game); + game = play(game,f.owner,f.card,'first'); + game = synchronizeAuctionClock(game,70_000,epoch); + assert.equal(game.auctionClock?.event,event); assert.equal(game.auctionClock?.player,playerId); + assert.equal(game.auctionClock?.remainingMs,8_000); assert.equal(game.auctionClock?.startedAt,null); + assert.deepEqual(game.auction,beforeLot); + if (scenario.semuta) { + assert.ok(game.pendingTreacheryDiscard?.reaction); + const pendingEvent = game.pendingTreacheryDiscard.batch.event; + const held = advanceAuctionClock(game,100_000,epoch); + assert.equal(held.auctionClock?.event,event); assert.equal(held.auctionClock?.remainingMs,8_000); + assert.equal(held.auctionClock?.startedAt,null); assert.deepEqual(held.auction,beforeLot); + game = held; + if (scenario.claim) game = act(game,'atreides',{type:'semutaCommit',event:pendingEvent}); + else game = passFreshDiscard(game); + game = synchronizeAuctionClock(game,120_000,epoch); + assert.equal(game.pendingTreacheryDiscard,null); + assert.equal(frame(game).event,orderFrame.event); + } + assert.equal(game.auctionClock?.event,event); assert.equal(game.auctionClock?.remainingMs,8_000); + assert.equal(game.auctionClock?.startedAt,null); + game = finish(game); game = synchronizeAuctionClock(game,150_000,epoch); + assert.equal(game.truthtrance?.stage,'ask'); assert.equal(game.auctionClock?.startedAt,null); + game = answer(game); assert.equal(game.truthtrance,null); + game = synchronizeAuctionClock(game,200_000,epoch); + assert.equal(game.auctionClock?.event,event); assert.equal(game.auctionClock?.player,playerId); + assert.equal(game.auctionClock?.remainingMs,8_000); assert.equal(game.auctionClock?.startedAt,200_000); + assert.deepEqual(game.auction,beforeLot); assert.deepEqual(inventory(game),f.originalCards); + const stillLive = advanceAuctionClock(game,207_999,epoch); + assert.equal(stillLive.auctionClock?.event,event); assert.deepEqual(stillLive.auction,beforeLot); + const expired = advanceAuctionClock(stillLive,208_000,epoch); + assert.ok(expired.auction?.passed.includes(playerId) || expired.auction?.index !== beforeLot!.index, + 'The original bidder receives the native timeout only after the retained budget expires'); + assert.deepEqual(inventory(expired),f.originalCards); + assert.ok(initial.event,'A real clock event was created'); +}); diff --git a/tests/sapho-truth-auction-controls.test.tsx b/tests/sapho-truth-auction-controls.test.tsx new file mode 100644 index 00000000..9847a384 --- /dev/null +++ b/tests/sapho-truth-auction-controls.test.tsx @@ -0,0 +1,80 @@ +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 GameView } from '../game/engine'; +import { SaphoTruthOrderPass, SaphoTruthOrderChoiceButton } from '../components/sapho-truth-order'; +import { act, inventory, createSaphoTruthAuctionFixture, assertAuctionParent } from './fixture-sapho-truth-auction'; +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() {}})); + +for (const advanced of [false,true]) void test( + `${advanced ? 'Advanced' : 'Basic'} actual Truth-order controls preserve a standing ordinary bid`, () => { + // Original public setup, genuinely purchased Sapho, actual ordinary lot + // and standing bid. A labeled conserved card swap gives its holder an + // original Truth; this does not claim a native earned Truth acquisition. + const f = createSaphoTruthAuctionFixture({advanced,standingBid:true,mode:'first'}); + let game = f.game; + assertAuctionParent(game,f.parent); + 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/); + + // Invoke the actual rendered leaf callbacks. Server rendering does not + // exercise a stateful browser, selectors 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()!); assertAuctionParent(game,f.parent); + const owner = viewGame(game,f.owner), order = owner.truthtrance?.order; + assert.ok(order); assert.ok(order.passed.includes(f.other)); + const choice = owner.saphoTruthOrderChoices.find(row => row.card === f.card && row.mode === 'first'); + assert.ok(choice); assert.match(truth(owner),/Use Juice of Sapho to ask first/); + assert.match(table(owner),/Use Juice of Sapho to ask first/); + const use = SaphoTruthOrderChoiceButton({frame:order,me:f.owner,choices:owner.saphoTruthOrderChoices, + choice,busy:false,act(action) { sent.push(action); }}); + assert.equal(use.props.disabled,false); use.props.onClick(); + assert.deepEqual([...sent],[{type:'saphoTruthOrder',event:order.event,revision:order.revision, + card:f.card,mode:'first'}]); + game = act(game,f.owner,sent.pop()!); assertAuctionParent(game,f.parent); + // Basic can open the neutral Semuta discard response. Finish real passes + // before resuming the original questions; the invoice remains committed. + game = passFreshDiscard(game); assertAuctionParent(game,f.parent); + assert.equal(game.truthtrance?.stage,'ask'); + assert.equal(game.truthtrance.queue[0].player,f.owner); + game = finishOriginalTruthQuestions(game); + assert.equal(game.truthtrance,null); assertAuctionParent(game,f.parent); + assert.deepEqual(inventory(game),f.originalCards); + const actor = game.auction!.active, original = structuredClone(game.auction!); + game = act(game,actor,{type:'passBid'}); + assert.ok(game.auction); assert.equal(game.auction.index,original.index); + assert.equal(game.auction.cards[original.index].id,original.cards[original.index].id); + assert.equal(game.auction.bid,original.bid); assert.equal(game.auction.bidder,original.bidder); + assert.ok(game.auction.passed.includes(actor)); + assert.deepEqual(inventory(game),f.originalCards); + }, +); diff --git a/tests/sapho-truth-auction-runtime.test.ts b/tests/sapho-truth-auction-runtime.test.ts new file mode 100644 index 00000000..8df06ee4 --- /dev/null +++ b/tests/sapho-truth-auction-runtime.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame } from '../game/engine'; +import { createSaphoTruthAuctionFixture as fixture, assertAuctionParent, act, inventory, player } from './fixture-sapho-truth-auction'; +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'; + +for (const advanced of [false,true]) for (const standingBid of [false,true]) for (const mode of ['first','last'] as const) { + const label = `${advanced ? 'Advanced' : 'Basic'} ${standingBid ? 'standing bid' : 'unbid'} ${mode}`; + void test(`${label} Sapho orders the original Truth questions without altering the ordinary auction`, () => { + const f = fixture({advanced,standingBid,mode}), state = f.game, current = frame(state); + assertAuctionParent(state,f.parent); + assert.ok(choices(state,f.owner).some(row => row.card === f.card && row.mode === mode)); + const noOp = 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-auction-order',revision:current.revision,card:f.card,mode}); + reject(state,f.owner,{type:'saphoTruthOrder',event:current.event,revision:current.revision+1,card:f.card,mode}); + assertAuctionParent(state,f.parent); + let game = play(state,f.owner,f.card,mode); + assertAuctionParent(game,f.parent); + game = passFreshDiscard(game); + assertAuctionParent(game,f.parent); + assert.ok(frame(game).passed.includes(f.owner)); + game = finish(game); + assert.equal(mode === 'first' ? game.truthtrance!.queue[0].player : game.truthtrance!.queue.at(-1)!.player,f.owner); + assertAuctionParent(game,f.parent); + game = answer(game); + assert.equal(game.truthtrance,null); assertAuctionParent(game,f.parent); + assert.deepEqual(inventory(game),f.originalCards); + // Continue the real original bidder: a normal pass cannot recreate the lot, + // discard its actual card, replay the standing bid or spend the invoice. + const actor = game.auction!.active, previous = structuredClone(game.auction!); + game = act(game,actor,{type:'passBid'}); + assert.ok(game.auction); assert.equal(game.auction.index,previous.index); + assert.equal(game.auction.cards[previous.index].id,previous.cards[previous.index].id); + assert.equal(game.auction.bid,previous.bid); assert.equal(game.auction.bidder,previous.bidder); + assert.ok(game.auction.passed.includes(actor)); + assert.deepEqual(inventory(game),f.originalCards); + }); +} + +for (const claim of [false,true]) void test( + `Basic ordinary standing bid survives a Sapho Truth-order Semuta ${claim ? 'claim' : 'pass'}`, () => { + const f = fixture({advanced:false,standingBid:true,mode:'first'}), state = structuredClone(f.game); + // Controlled conserved physical supplement: swap the actual cached Semuta + // with an existing Atreides hand card. The public game, funded standing + // invoice, original lot and previously queued Truths are not manufactured. + const claimant = player(state,'atreides'), cache = state.richeseCache; + assert.ok(cache); const semutaAt = cache.findIndex(card => card.effect === 'semutaDrug'); assert.ok(semutaAt >= 0); + assert.ok(claimant.hand.length > 0); + const [semuta] = cache.splice(semutaAt,1), [exchange] = claimant.hand.splice(0,1); + claimant.hand.push(semuta); cache.push(exchange); + assertAuctionParent(state,f.parent); assert.deepEqual(inventory(state),f.originalCards); + // The other holder passes first, so the Sapho use finishes the public + // ordering but its real fresh-discard Semuta window must settle first. + let game = pass(state,f.other); + assertAuctionParent(game,f.parent); + game = play(game,f.owner,f.card,'first'); + assert.ok(game.pendingTreacheryDiscard?.reaction); + assert.equal(game.pendingTreacheryDiscard.batch.entries[0].card.id,f.card); + assert.equal(viewGame(game,'atreides').semutaReaction?.canCommit,true); + assertAuctionParent(game,f.parent); + const event = game.pendingTreacheryDiscard.batch.event; + if (claim) game = act(game,'atreides',{type:'semutaCommit',event}); + else game = passFreshDiscard(game); + assert.equal(game.pendingTreacheryDiscard,null); assert.equal(game.truthtrance?.stage,'ask'); + assert.equal(game.truthtrance?.queue[0].player,f.owner); assertAuctionParent(game,f.parent); + assert.equal(player(game,'atreides').hand.some(card => card.id === f.card),claim); + assert.equal(game.discard.some(card => card.id === semuta.id),claim); + game = answer(game); assert.equal(game.truthtrance,null); assertAuctionParent(game,f.parent); + assert.deepEqual(inventory(game),f.originalCards); + const actor = game.auction!.active; + game = act(game,actor,{type:'passBid'}); + assert.equal(game.auction?.bid,1); assert.equal(game.auction?.bidder,f.parent.auction!.bidder); + assert.deepEqual(inventory(game),f.originalCards); + }, +); diff --git a/tests/sapho-truth-order-turn-promise.test.ts b/tests/sapho-truth-order-turn-promise.test.ts new file mode 100644 index 00000000..cc3ec4fe --- /dev/null +++ b/tests/sapho-truth-order-turn-promise.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 { act, inventory, player, createSaphoTruthOrderFixture, + passFreshDiscard, truthOrderFrame, finishTruthOrder, finishOriginalTruthQuestions } from './fixture-sapho-truth-order'; + +/** Declare one original Truth, allowing every other player to pass the original + * neutral priority. The caller chooses the actual question afterwards. */ +function declare(game: Game, actor: string, card: string) { + game = act(game,actor,{type:'card',card}); + while (game.truthtrance?.stage === 'priority') { + const seat = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(seat); + game = act(game,seat.id,{type:'truthPass'}); + } + assert.equal(game.truthtrance?.stage,'ask'); + return game; +} + +void test('Sapho Truth-order disposal cannot consume the last card needed by an earlier Distrans Yes', () => { + const f = createSaphoTruthOrderFixture({advanced:true,mode:'first'}); + let game = structuredClone(f.before); + const owner = player(game,f.owner), cache = game.richeseCache; + assert.ok(cache); + // Controlled, conserved custody supplement on top of the original public + // setup and earned Sapho: give the owner the physical cached Distrans, and + // put any other unneeded held cards in the original deck. The owner retains + // exactly their real Sapho, original dealt Truth and Distrans. No resources, + // phases, promises, auction records or extra cards are manufactured. + const at = cache.findIndex(card => card.effect === 'distrans'); assert.ok(at >= 0); + const [distrans] = cache.splice(at,1); + const retained = owner.hand.filter(card => card.id === f.card || card.id === f.truth); + const returned = owner.hand.filter(card => card.id !== f.card && card.id !== f.truth); + owner.hand = [...retained,distrans]; game.deck.push(...returned); + assert.equal(owner.hand.length,3); + assert.deepEqual(inventory(game),f.originalCards); + + // Ask the earlier obligation through the real Truthtrance engine. At this + // moment the owner's other physical Truth card can legally be transferred; + // Sapho is another real alternative, so a Yes has a native completion. + game = declare(game,f.other,f.otherTruth); + game = act(game,f.other,{type:'truthAsk',question:{kind:'turnActions',target:f.owner,turn:game.turn, + mixed:{kind:'action',action:{kind:'cardPlay',effect:'distrans'}}}}); + assert.ok(viewGame(game,f.owner).truthTurnActionAnswers?.includes('yes')); + game = act(game,f.owner,{type:'truthAnswer',answer:'yes'}); + game = passFreshDiscard(game); + assert.ok(game.turnActionPromises?.some(row => row.player === f.owner && row.answer && !row.fulfilled && !row.released)); + assert.deepEqual(inventory(game),f.originalCards); + const quote = viewGame(game,f.owner).distrans; + assert.ok(quote && !quote.blocked); + const recipient = quote.choices.find(row => !row.blocked && row.cards.some(card => card.id === f.card)); + assert.ok(recipient,'The original physical Sapho is a legal Distrans transfer'); + let completed = act(structuredClone(game),f.owner,{type:'card',card:distrans.id, + target:recipient.recipient,give:f.card}); + completed = passFreshDiscard(completed); + assert.ok(completed.turnActionPromises?.some(row => row.player === f.owner && row.fulfilled), + 'The earlier Yes has an actual legal physical transfer route before reordering'); + assert.deepEqual(inventory(completed),f.originalCards); + + // The previously played original Truth is moved from the discard back to + // its original holder solely to construct two simultaneous questions. + // This is a labeled controlled card-custody setup, not a claimed native + // recovery; physical inventory and the already-bound answer are preserved. + const atDiscard = game.discard.findIndex(card => card.id === f.otherTruth); assert.ok(atDiscard >= 0); + const [priorTruth] = game.discard.splice(atDiscard,1); + player(game,f.other).hand.push(priorTruth); + assert.deepEqual(inventory(game),f.originalCards); + + // Both original Truth cards now become committed physical questions. The + // owner's Truth is reserved, leaving Sapho as the only card Distrans can + // transfer after this window. Reordering by discarding Sapho must not erase + // the earlier Yes or strand the question queue. + game = act(game,f.owner,{type:'card',card:f.truth}); + game = act(game,f.other,{type:'card',card:f.otherTruth}); + while (game.truthtrance?.stage === 'priority') { + const seat = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(seat); + game = act(game,seat.id,{type:'truthPass'}); + } + const frame = truthOrderFrame(game), prior = structuredClone(game.turnActionPromises); + const choices = viewGame(game,f.owner).saphoTruthOrderChoices; + assert.ok(!choices.some(row => row.card === f.card && row.mode === 'first'), + 'A displayed choice must retain a completion of the earlier Yes'); + const original = structuredClone(game); + assert.throws(() => act(game,f.owner,{type:'saphoTruthOrder',event:frame.event,revision:frame.revision, + card:f.card,mode:'first'})); + assert.deepEqual(game,original,'Rejected action must not mutate the original game'); + assert.deepEqual(game.turnActionPromises,prior); + assert.ok(player(game,f.owner).hand.some(card => card.id === f.card)); + assert.deepEqual(inventory(game),f.originalCards); + + // Both holders can decline to reorder; resolve both actual queued questions + // and then complete the preserved earlier promise by transferring Sapho. + game = finishOriginalTruthQuestions(finishTruthOrder(game)); + assert.equal(game.truthtrance,null); + assert.ok(game.turnActionPromises?.some(row => row.player === f.owner && row.answer && !row.fulfilled && !row.released)); + const laterQuote = viewGame(game,f.owner).distrans; + assert.ok(laterQuote && !laterQuote.blocked); + const laterRecipient = laterQuote.choices.find(row => !row.blocked && row.cards.some(card => card.id === f.card)); + assert.ok(laterRecipient,'The preserved Sapho remains a real legal Distrans transfer'); + game = act(game,f.owner,{type:'card',card:distrans.id,target:laterRecipient.recipient,give:f.card}); + game = passFreshDiscard(game); + assert.ok(game.turnActionPromises?.some(row => row.player === f.owner && row.fulfilled && !row.released)); + assert.ok(player(game,laterRecipient.recipient).hand.some(card => card.id === f.card)); + assert.deepEqual(inventory(game),f.originalCards); +}); diff --git a/tests/semuta-engine.test.ts b/tests/semuta-engine.test.ts index 3957268e..6e7e64a9 100644 --- a/tests/semuta-engine.test.ts +++ b/tests/semuta-engine.test.ts @@ -1337,6 +1337,12 @@ void test('a queued definite Truthtrance answer offers its card before the next } const queue = declared.truthtrance!.queue.map(entry => entry.player); assert.equal(queue.length, 2); + // The possession-independent Sapho window now precedes the first question. + while (declared.truthtrance?.stage === 'order') { + const frame: NonNullable['order']> = declared.truthtrance.order!; + const holder = frame.holders.find(id => !frame.passed.includes(id))!; + declared = applyAction(declared, holder, { type: 'truthOrderPass', event: frame.event, revision: frame.revision }); + } const asked = applyAction(declared, queue[0], { type: 'truthAsk', question: { kind: 'fact', target: 'e', fact: { kind: 'hand', name: 'Shield' } }, });