From 298c8245a637ed32086331b8f0d470294cbfe2d6 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:31:15 +1100 Subject: [PATCH] Support Sapho ordering for simultaneous Truthtrances --- components/sapho-truth-order.tsx | 71 +++++++ components/truthtrance.tsx | 5 +- docs/CURRENT_STATUS.md | 13 ++ docs/IMPLEMENTATION_STATUS.md | 20 ++ docs/JUICE_OF_SAPHO_RUNTIME.md | 39 ++++ docs/RULE_DECISIONS.md | 12 ++ game/bots.ts | 3 + game/engine.ts | 105 ++++++++++ game/reference.ts | 1 + game/sapho-truth-order.ts | 138 ++++++++++++ game/truthtrance.ts | 30 ++- tests/fixture-sapho-truth-order.ts | 129 ++++++++++++ tests/sapho-truth-order-controls.test.tsx | 62 ++++++ ...o-truth-order-integrated-controls.test.tsx | 82 ++++++++ tests/sapho-truth-order-runtime.test.ts | 196 ++++++++++++++++++ tests/sapho-truth-order.test.ts | 117 +++++++++++ 16 files changed, 1020 insertions(+), 3 deletions(-) create mode 100644 components/sapho-truth-order.tsx create mode 100644 game/sapho-truth-order.ts create mode 100644 tests/fixture-sapho-truth-order.ts create mode 100644 tests/sapho-truth-order-controls.test.tsx create mode 100644 tests/sapho-truth-order-integrated-controls.test.tsx create mode 100644 tests/sapho-truth-order-runtime.test.ts create mode 100644 tests/sapho-truth-order.test.ts diff --git a/components/sapho-truth-order.tsx b/components/sapho-truth-order.tsx new file mode 100644 index 00000000..69f664d8 --- /dev/null +++ b/components/sapho-truth-order.tsx @@ -0,0 +1,71 @@ +'use client'; + +import type { Action } from '@/game/engine'; +import { Button } from './ui/button'; + +/** The ordering frame is public: eligibility depends on who queued a question, + * never on whether any player holds Juice of Sapho. */ +export type SaphoTruthOrderFrame = { + event: string; + holders: string[]; + passed: string[]; + revision: number; +}; +export type SaphoTruthOrderChoice = { card: string; mode: 'first' | 'last' }; +export type SaphoTruthOrderProps = { + frame: SaphoTruthOrderFrame; + me: string; + choices: SaphoTruthOrderChoice[]; + act: (action: Action) => void; + busy: boolean; +}; + +/** The authoritative action is revalidated by the server. These local checks + * also avoid sending a callback after its visible event or revision changed. */ +function currentFrame(props: SaphoTruthOrderProps, frame: SaphoTruthOrderFrame, + event: string, revision: number): boolean { + return !props.busy && props.frame === frame && frame.event === event && + frame.revision === revision && typeof event === 'string' && event.length > 0 && + Number.isSafeInteger(revision) && revision >= 0 && + frame.holders.includes(props.me) && !frame.passed.includes(props.me); +} + +export function SaphoTruthOrderPass(props: SaphoTruthOrderProps) { + const { frame } = props, event = frame.event, revision = frame.revision; + const canAct = () => currentFrame(props, frame, event, revision); + return ; +} + +export function SaphoTruthOrderChoiceButton(props: SaphoTruthOrderProps & { choice: SaphoTruthOrderChoice }) { + const { frame, choice } = props, event = frame.event, revision = frame.revision; + const card = choice.card, mode = choice.mode; + const canAct = () => currentFrame(props, frame, event, revision) && + typeof card === 'string' && card.length > 0 && (mode === 'first' || mode === 'last') && + choice.card === card && choice.mode === mode && + props.choices.some(item => item.card === card && item.mode === mode); + return ; +} + +/** Render the same public pass opportunity to every queued holder. The choices + * array contains only this viewer's server-quoted, privately held cards. */ +export function SaphoTruthOrder(props: SaphoTruthOrderProps) { + const { frame, me } = props; + const eligible = frame.holders.includes(me), passed = frame.passed.includes(me); + return
+

Truthtrance question order

+

Before the questions begin, each player who queued a Truthtrance may continue or use an available order change. Questions from the same player stay together.

+

{frame.passed.length} of {frame.holders.length} players have finished this choice.

+ {eligible && !passed ? <> + + {props.choices.map(choice => )} + :

{eligible ? 'You have finished this choice.' : 'Waiting for the players who queued questions.'}

} +
; +} diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 2f5147e3..ad53c2e5 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -1,4 +1,5 @@ 'use client'; +import { SaphoTruthOrder } from './sapho-truth-order'; import { RicheseBidPromiseFields, richeseBidMinimumError } from './richese-bid-promise-fields'; import { TravelClaimFields, travelClaimInputError } from './travel-claim-fields'; @@ -269,7 +270,9 @@ export function Truthtrance({ Question rules · The table resumes its previous decision after these questions.

- {w.stage === 'priority' ? ( + {w.stage === 'order' && w.order ? ( + + ) : w.stage === 'priority' ? ( <>

A Truthtrance has been declared. Declare yours now or pass; diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index df22ecf8..d36edb2f 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,19 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**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. +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. +[Scope and provisional interpretation](JUICE_OF_SAPHO_RUNTIME.md#simultaneous-truthtrance-order--27-october-2026). + **Silent automatic submission notice — 27 October.** A full hand's forced Silent submission now uses the same public text as a manual sealed bid and a neutral animation tag, instead of announcing the diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 253fa930..d10baa61 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,25 @@ # Dune implementation status +## 27 October 2026 — Sapho simultaneous Truthtrance order + +A public response window follows storm-order sorting when two or more different +players declare Truthtrance in a supported clean parent. Every queued holder may +pass regardless of hand contents. An eligible Sapho holder may discard the real +card to move their complete group first or last; invalid no-ops, duplicates and +stale revisions are rejected. Physical storm order and same-holder card order +are preserved. A Basic Semuta fresh-discard claim or pass restores the suspended +queue without replaying Sapho; bare native profiles and clean parents are the +initial scope, with auctions, battles, pending parents and broader modules open. +The FAQ establishes storm-order Truth resolution, and the inspected Sapho face +permits first/last turn-ordered actions; their precedence is a provisional +interpretation, not an explicit publisher ruling. Basic/Advanced native setup +and an earned Sapho are supplemented by labeled conserved Truth/Semuta swaps. +Eleven affected files pass 137/137, type/lint and build pass, and an isolated +server passes 55/55 integration tests. Browser, deployed, full-mode and broad +combination acceptance remain open. Private proof is +`20261027-sapho-truth-order-proof-v2/`. +[Contract](JUICE_OF_SAPHO_RUNTIME.md#simultaneous-truthtrance-order--27-october-2026). + ## 27 October 2026 — Silent automatic notice Use the same public text for forced and manual sealed submissions, and a diff --git a/docs/JUICE_OF_SAPHO_RUNTIME.md b/docs/JUICE_OF_SAPHO_RUNTIME.md index 0d5f616d..1b00c02f 100644 --- a/docs/JUICE_OF_SAPHO_RUNTIME.md +++ b/docs/JUICE_OF_SAPHO_RUNTIME.md @@ -1,5 +1,44 @@ # Juice of Sapho: finite ordering implementation +## 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) +orders simultaneous Truthtrances by storm order. The inspected printed Sapho +face permits first/last in a turn-ordered action; it does not expressly resolve +their precedence. The combined rule is therefore a provisional interpretation. + +After declarations and storm sorting, a public response window opens when at +least two distinct players queued Truth cards in a supported clean parent. The +window depends only on public declarations and configured deck, never on who +holds Sapho. Every holder may continue without changing order. A holder with an +eligible physical Sapho may instead discard it to move their entire question +group to the first or last position. No-op modes are unavailable; the opaque +public event and revision reject stale/duplicate actions. The actual physical +storm order, same-holder card order and already completed parent state remain. +An earlier Sapho first/last position is provisionally protected if Semuta +recovers the card for another unpassed holder; a compatible opposite-end move +remains available. When all holders respond, the original first question proceeds. Four minimal bot +profiles can legally pass. Existing saves already asking do not gain a new step. + +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 +acceptance remain open. + +Runtime tests use a genuine native Basic/Advanced setup and an earned Sapho, +then explicitly swap conserved dealt cards to give its holder an original Truth +card. Basic Semuta claim/pass uses a separately labeled conserved cache/hand +swap. Server-rendered actual table controls and their callbacks are exercised; +stateful browser hydration is not. Eleven affected files pass 137/137, +type/lint and build pass, and 55/55 integration tests pass against a separate +local verification server. + + Updated 26 October 2026. **Partial card implementation; full modes and publication acceptance remain gated.** The earlier source audit remains the printed-evidence baseline. The current-lot prototype below selects an explicit diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index ae287dcb..0a6cb76f 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,17 @@ # Rule decision index +## Sapho simultaneous Truthtrance order — 27 October 2026 + +The FAQ orders simultaneous Truthtrance questions by storm order, while the +inspected Juice of Sapho face permits first/last in a turn-ordered action. The +interaction has no dedicated publisher clarification. Provisionally offer each +queued holder a public response before asking, then allow the holder of Sapho +to move their whole pending group first/last without changing storm order or +splitting one holder's cards. A previously selected first/last position is +provisionally protected against a later recovered-card use. Clean parents and supported Semuta continuation +are the initial scope; auction, battle and pending-parent timing remain open. +[Contract](JUICE_OF_SAPHO_RUNTIME.md#simultaneous-truthtrance-order--27-october-2026). + ## Richese Silent bid Truthtrance — 27 October 2026 Provisionally allow an event-bound minimum for the respondent's single sealed diff --git a/game/bots.ts b/game/bots.ts index c6e9231e..462f1310 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1324,6 +1324,9 @@ function policyActions(g: GameView): Action[] { if (g.bidPromiseGuide) return [g.bidPromiseGuide.action]; if (g.truthtrance) { const w = g.truthtrance; + if (w.stage === 'order' && w.order) + return w.order.holders.includes(me.id) && !w.order.passed.includes(me.id) + ? [{type:'truthOrderPass',event:w.order.event,revision:w.order.revision}] : []; if (w.stage === 'priority') return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }]; if (w.stage === 'answer') { diff --git a/game/engine.ts b/game/engine.ts index f60ed892..7e59c1eb 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,4 @@ +import { SaphoTruthOrderError, quoteSaphoTruthOrder, reorderSaphoTruthOrder, validateSaphoTruthOrder } from './sapho-truth-order'; import { RicheseBidPromiseError, richeseBidCompletionRange, richeseBidRequired, recordRicheseBid, liveRicheseBidPromises, reconcileRicheseBidPromises, finishRicheseBidLot, validateRicheseBidPromises, type RicheseBidClaim, type RicheseBidPromise, type RicheseBidOpportunity } from './richese-bid-promises'; @@ -1617,6 +1618,17 @@ export type Game = { resume: NonNullable['resume']; stateSignature: string; } + | { + kind: 'saphoTruthOrderDiscard'; + player: string; + card: string; + event: string; + revision: number; + mode: 'first' | 'last'; + truth: TruthWindow; + resume: NonNullable['resume']; + stateSignature: string; + } | { kind: 'saphoMovementDiscard'; player: string; @@ -5888,6 +5900,14 @@ function distransDiscardSignature(g: Game, c: DistransDiscardContinuation) { recipientHand: recipient.hand.map(card => card.id), }); } +type SaphoTruthOrderDiscardContinuation = Extract< + NonNullable['continuation'], { kind: 'saphoTruthOrderDiscard' } +>; +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}); +} type SaphoMovementDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'saphoMovementDiscard' } @@ -7541,6 +7561,21 @@ function treacheryDiscardIntegrity(g: Game) { c.stateSignature === distransDiscardSignature(g, c), 'The completed private Distrans transfer no longer matches its fresh discard.', ); + } else if (continuation?.kind === 'saphoTruthOrderDiscard') { + const c = continuation, entry = batch.entries[0], truth = c.truth; + requireRule(batch.cause === 'sapho:truthOrder' && batch.entries.length === 1 && entry.publicFace && + entry.discardedBy === c.player && entry.card.id === c.card && + richeseCardDefinition(entry.card)?.card.effect === 'juiceOfSapho' && + !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && + 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)) && + (truth.stage !== 'order' || !!truth.order && truth.order.event === c.event && + truth.order.revision === c.revision + 1 && truth.order.passed.includes(c.player)) && + c.stateSignature === saphoTruthOrderDiscardSignature(g,c), + 'The used Sapho no longer matches its suspended Truthtrance order.'); + if (truth.stage === 'order') try { validateSaphoTruthOrder(truth.order!,truth.queue); } + catch (error) { if (error instanceof SaphoTruthOrderError) throw new RuleError(error.message); throw error; } } else if (continuation?.kind === 'saphoCharityDiscard') { const c=continuation,entry=batch.entries[0],frame=g.charityOrder; const latest=frame?.version===2 ? frame.uses.at(-1) : null; @@ -8739,6 +8774,7 @@ function semutaOfferSupported( continuation.kind !== 'terrorDiscard' && continuation.kind !== 'nexusChoamTrade' && continuation.kind !== 'nexusChoamBetrayal' && + continuation.kind !== 'saphoTruthOrderDiscard' && continuation.kind !== 'saphoMovementDiscard' && continuation.kind !== 'saphoRevivalDiscard' && continuation.kind !== 'saphoCharityDiscard' && @@ -9294,6 +9330,12 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; + } else if (next.kind === 'saphoTruthOrderDiscard') { + g.response = next.resume.response; g.decision = next.resume.decision; + g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; + requireRule(next.truth.queue.every(row => queuedTruthCardMatches(g.players.find(player => player.id === row.player)?.hand ?? [],row)), + 'The pending Truthtrance cards must remain held before the order resumes.'); + g.truthtrance = next.truth; } else if (next.kind === 'saphoCharityDiscard') { g.response=next.resume.response; g.decision=next.resume.decision; @@ -26379,6 +26421,67 @@ function saphoExtortionDecision(g: Game) { decision?.kind === 'moritaniExtortion' && decision.player === state.queue[state.cursor] && decision.event === extortionEvent(state); } +/** Quote the exact public parent/discard continuation on a detached copy. + * 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 { + const event = w.order.event, revision = w.order.revision; + const result = reorderSaphoTruthOrder(w.order,w.queue,{event,revision,player:p.id,mode}); + recordNativePrintedCardPlay(trial,p.id,'juiceOfSapho'); + const used = discard(trial,actor,card); + 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, + pendingKarama:trial.pendingKarama,phaseOpening:trial.phaseOpening},stateSignature:''}; + trial.truthtrance = null; trial.response = null; trial.decision = null; + trial.pendingKarama = null; trial.phaseOpening = null; + return semutaOfferSupported(trial,c,[{card:used,discardedBy:p.id,publicFace:true}]); + } catch (error) { if (error instanceof RuleError || error instanceof SaphoTruthOrderError) return false; throw error; } +} +function saphoTruthOrderChoices(g: Game,p: Player): {card:string;mode:'first'|'last'}[] { + const w = g.truthtrance; + if (w?.stage !== 'order' || !w.order || (g.semutaPreview && !bareBasicSemutaProfile(g)) || + printedRolePlayBlock(g,p.id,'juiceOfSapho')) return []; + const card = p.hand.find(row => row.id === 'richese-juice-of-sapho' && row.effect === 'juiceOfSapho'); + if (!card || !richeseCardDefinition(card) || p.hand.filter(row => row.id === card.id).length !== 1 || + transferCardBlock(g,p,card) || giftReserved(g,p.id,card.id)) return []; + try { return quoteSaphoTruthOrder(w.order,w.queue,p.id).modes + .filter(mode => saphoTruthOrderSemutaSupported(g,p,card.id,mode)).map(mode => ({card:card.id,mode})); } + catch (error) { if (error instanceof SaphoTruthOrderError) return []; throw error; } +} +function playSaphoTruthOrder(g: Game,p: Player,action: Action) { + const w = g.truthtrance; + requireRule(w?.stage === 'order' && w.order && + Object.keys(action).sort().join(',') === 'card,event,mode,revision,type' && + saphoTruthOrderChoices(g,p).some(choice => choice.card === action.card && choice.mode === action.mode), + 'This Juice of Sapho ordering choice is unavailable or has expired.'); + let result; + try { result = reorderSaphoTruthOrder(w.order,w.queue,{event:action.event as string, + revision:action.revision as number,player:p.id,mode:action.mode as 'first'|'last'}); } + catch (error) { if (error instanceof SaphoTruthOrderError) throw new RuleError(error.message); throw error; } + recordNativePrintedCardPlay(g,p.id,'juiceOfSapho'); + const used = discard(g,p,action.card as string); + w.queue = result.queue; + if (result.done) { delete w.order; w.stage = 'ask'; } else w.order = result.frame; + log(g, `${p.name} discarded Juice of Sapho to ask ${String(action.mode)} among the simultaneous Truthtrance holders.`, + {faction:p.faction,name:'Juice of Sapho'}); + 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}, + 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}]; + requireRule(semutaOfferSupported(g,c,entries), 'This Sapho discard cannot safely suspend its Semuta reaction in this window.'); + c.stateSignature = saphoTruthOrderDiscardSignature(g,c); + stageTreacheryDiscard(g,'sapho:truthOrder',entries,c); + } +} function saphoCleanWindow(g: Game) { return ( g.status === 'playing' && @@ -39931,6 +40034,7 @@ 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; } try { if ( resolveTruthAction(g, p, action, { @@ -43951,6 +44055,7 @@ export function viewGame(state: Game, id: string, now?: number) { truthMixedBattleAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'mixedBattlePlan' ? mixedBattleTruthAnswers(g, me, g.truthtrance.question) : null, mixedBattlePromises: projectedMixedBattlePromises(g, id), + saphoTruthOrderChoices: saphoTruthOrderChoices(g,me), truthBattleAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && diff --git a/game/reference.ts b/game/reference.ts index 6f674e5d..0ea6a5fb 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -502,6 +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.', '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/sapho-truth-order.ts b/game/sapho-truth-order.ts new file mode 100644 index 00000000..5819de88 --- /dev/null +++ b/game/sapho-truth-order.ts @@ -0,0 +1,138 @@ +/** Public pre-question opportunity for groups of simultaneous Truth cards. + * The engine owns the physical Sapho and each queued Truth card. */ +export type SaphoTruthOrderFrame = { + event: string; + revision: number; + holders: string[]; + passed: string[]; + /** Earlier Sapho choices remain in their declared boundary positions. */ + protectedFirst?: string; + protectedLast?: string; +}; + +export type SaphoTruthOrderEntry = { player: string }; +export type SaphoTruthOrderMode = 'first' | 'last'; +export type SaphoTruthOrderRequest = { event: string; revision: number; player: string }; +export type SaphoTruthOrderResult = { + frame: SaphoTruthOrderFrame; + queue: T[]; + done: boolean; +}; + +export class SaphoTruthOrderError extends Error { + constructor(message: string) { + super(message); + this.name = 'SaphoTruthOrderError'; + } +} + +function check(value: unknown, message: string): asserts value { + if (!value) throw new SaphoTruthOrderError(message); +} +const identifier = (value: unknown): value is string => + typeof value === 'string' && value.length > 0 && value.trim() === value; +const uniqueIdentifiers = (value: unknown): value is string[] => + Array.isArray(value) && value.every(identifier) && new Set(value).size === value.length; + +/** The queue is already sorted by the real Truth priority rule. Each holder's + * physical cards must remain one contiguous group throughout this window. */ +function holders(queue: readonly T[]): string[] { + check(Array.isArray(queue) && queue.every(row => row && identifier(row.player)), + 'Retain the original queued Truth holders.'); + const result: string[] = []; + for (const row of queue) { + if (result.at(-1) === row.player) continue; + check(!result.includes(row.player), 'A holder’s simultaneous Truth cards must remain together.'); + result.push(row.player); + } + return result; +} + +/** Creates a frame solely from public committed holders, not any hand or card + * contents. No added response stage is needed for fewer than two holders. */ +export function createSaphoTruthOrder( + queue: readonly T[], event: string, +): SaphoTruthOrderFrame | null { + check(identifier(event), 'The Truth ordering window needs an opaque event.'); + const originalHolders = holders(queue); + return originalHolders.length < 2 ? null : { event, revision: 0, holders: originalHolders, passed: [] }; +} + +export function validateSaphoTruthOrder( + frame: SaphoTruthOrderFrame, queue: readonly T[], +): void { + check(frame && typeof frame === 'object' && identifier(frame.event) && + Number.isSafeInteger(frame.revision) && frame.revision >= 0, + 'Retain the current Truth ordering event and revision.'); + check(uniqueIdentifiers(frame.holders) && frame.holders.length >= 2 && uniqueIdentifiers(frame.passed) && + frame.passed.every(player => frame.holders.includes(player)), + 'Retain the original holders and their completed ordering responses.'); + const current = holders(queue); + check(current.length === frame.holders.length && current.every(player => frame.holders.includes(player)), + 'Retain every original Truth holder in this ordering window.'); + check(frame.revision === frame.passed.length, + 'Retain one ordering revision for each completed holder response.'); + check(frame.protectedFirst === undefined || identifier(frame.protectedFirst) && + frame.passed.includes(frame.protectedFirst) && current[0] === frame.protectedFirst, + 'Retain the holder protected at the first Truth position.'); + check(frame.protectedLast === undefined || identifier(frame.protectedLast) && + frame.passed.includes(frame.protectedLast) && current.at(-1) === frame.protectedLast, + 'Retain the holder protected at the last Truth position.'); +} + +function participant( + frame: SaphoTruthOrderFrame, queue: readonly T[], request: SaphoTruthOrderRequest, +): string[] { + validateSaphoTruthOrder(frame, queue); + check(request && request.event === frame.event && request.revision === frame.revision, + 'This Truth ordering opportunity has expired.'); + check(identifier(request.player) && frame.holders.includes(request.player), + 'Only an original Truth holder may respond to this ordering window.'); + check(!frame.passed.includes(request.player), 'This holder already completed the ordering opportunity.'); + return holders(queue); +} + +export function quoteSaphoTruthOrder( + frame: SaphoTruthOrderFrame, queue: readonly T[], player: string, +): { event: string; revision: number; modes: SaphoTruthOrderMode[] } { + validateSaphoTruthOrder(frame, queue); + const {event,revision} = frame, current = participant(frame, queue, { event, revision, player }); + const modes: SaphoTruthOrderMode[] = []; + if (current[0] !== player && !frame.protectedFirst && frame.protectedLast !== player) modes.push('first'); + if (current.at(-1) !== player && !frame.protectedLast && frame.protectedFirst !== player) modes.push('last'); + return { event, revision, modes }; +} + +function complete( + frame: SaphoTruthOrderFrame, queue: readonly T[], player: string, +): SaphoTruthOrderResult { + check(frame.revision < Number.MAX_SAFE_INTEGER, 'The Truth ordering revision cannot advance safely.'); + const next = { ...frame, revision: frame.revision + 1, holders: [...frame.holders], passed: [...frame.passed, player] }; + return { frame: next, queue: [...queue], done: next.passed.length === next.holders.length }; +} + +/** Passing is available to every original holder, independently of cards held. */ +export function passSaphoTruthOrder( + frame: SaphoTruthOrderFrame, queue: readonly T[], request: SaphoTruthOrderRequest, +): SaphoTruthOrderResult { + participant(frame, queue, request); + return complete(frame, queue, request.player); +} + +/** Move the entire holder group; never sort or alter the physical card rows. */ +export function reorderSaphoTruthOrder( + frame: SaphoTruthOrderFrame, queue: readonly T[], request: SaphoTruthOrderRequest & { mode: SaphoTruthOrderMode }, +): SaphoTruthOrderResult { + const current = participant(frame, queue, request); + check(request.mode === 'first' || request.mode === 'last', 'Choose first or last.'); + check(request.mode === 'first' ? current[0] !== request.player : current.at(-1) !== request.player, + 'The holder already occupies that Truth position.'); + check(request.mode === 'first' ? !frame.protectedFirst && frame.protectedLast !== request.player + : !frame.protectedLast && frame.protectedFirst !== request.player, + 'An earlier Juice of Sapho use protects that Truth position.'); + const own = queue.filter(row => row.player === request.player), rest = queue.filter(row => row.player !== request.player); + const result = complete(frame, request.mode === 'first' ? [...own, ...rest] : [...rest, ...own], request.player); + if (request.mode === 'first') result.frame.protectedFirst = request.player; + else result.frame.protectedLast = request.player; + return result; +} diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 8fa11694..c92dd075 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1,3 +1,4 @@ +import { createSaphoTruthOrder, passSaphoTruthOrder, SaphoTruthOrderError, type SaphoTruthOrderFrame } from './sapho-truth-order'; import { parseTravelActionClaim, type TravelClaim } from './travel-expression-promises'; import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES, turnActionPromiseModeSupported, type TurnActionClaim } from './turn-action-promises'; @@ -126,7 +127,8 @@ export type TruthQueueEntry = { source?: 'shrine'; }; export type TruthWindow = { - stage: 'priority' | 'ask' | 'answer' | 'unknown'; + stage: 'priority' | 'order' | 'ask' | 'answer' | 'unknown'; + order?: SaphoTruthOrderFrame; queue: TruthQueueEntry[]; passed: string[]; question: TruthQuestion | null; @@ -901,6 +903,19 @@ export function resolveTruthAction( cardBlock: (g: Game, p: Player, card: Player['hand'][number]) => string | null; }, ): boolean { + if (g.truthtrance?.stage === 'order') { + const w = g.truthtrance; + check(w.order && a.type === 'truthOrderPass' && + Object.keys(a).sort().join(',') === 'event,revision,type', + 'Finish the current Truthtrance ordering opportunity.'); + try { + const result = passSaphoTruthOrder(w.order,w.queue,{event:a.event as string,revision:a.revision as number,player:p.id}); + w.queue = result.queue; + if (result.done) { delete w.order; w.stage = 'ask'; } + else w.order = result.frame; + } catch (error) { if (error instanceof SaphoTruthOrderError) throw new TruthError(error.message); throw error; } + return true; + } const selectedCard = a.type === 'card' && typeof a.card === 'string' ? p.hand.find((card) => card.id === a.card) @@ -1271,7 +1286,18 @@ export function resolveTruthAction( g.truthtrance.queue.sort( (a, b) => order.indexOf(a.player) - order.indexOf(b.player), ); - g.truthtrance.stage = 'ask'; + // Every committed holder gets the same public response window. The + // decision to open it never inspects who holds Juice of Sapho. + const clean = g.status === 'playing' && g.expansions.includes('choam') && !g.response && !g.decision && !g.phaseOpening && + !g.pendingKarama && !g.pendingTreacheryDiscard && !g.pendingNullentropy && !g.auction && + !g.richeseAuction && !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 && + !g.pendingArrivalOverlap && !g.pendingArrivalReaction?.length; + const frame = clean ? createSaphoTruthOrder(g.truthtrance.queue,crypto.randomUUID()) : null; + if (frame) { g.truthtrance.order = frame; g.truthtrance.stage = 'order'; } + else g.truthtrance.stage = 'ask'; } return true; } diff --git a/tests/fixture-sapho-truth-order.ts b/tests/fixture-sapho-truth-order.ts new file mode 100644 index 00000000..f28c3522 --- /dev/null +++ b/tests/fixture-sapho-truth-order.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { actSaphoStartedMovement as act, createSaphoStartedMovementFixture, + saphoStartedMovementInventory as inventory, saphoStartedMovementPlayer as player } from './fixture-sapho-started-movement'; + +export { act, inventory, player }; +export type OrderFrame = {event:string;holders:string[];passed:string[];revision:number}; +export function truthOrderFrame(game: Game): OrderFrame { + assert.equal(game.truthtrance?.stage, 'order'); + const frame = (game.truthtrance as typeof game.truthtrance & {order?:OrderFrame}).order; + assert.ok(frame); return frame; +} + +/** Original public R/A/E/G Basic or Advanced setup, selected unopened original + * Truth deals and a genuinely purchased Sapho. Controlled supplemental card + * custody: exchange one actually dealt Truth with Emperor's other physical + * starting card so the genuine Sapho owner also holds a Truthtrance. This is + * explicitly not a claimed native Truth acquisition. Every physical card, + * hand count, purse, force, turn, phase and original started parent is retained. + * The remaining Truth stays with its original recipient. */ +export function createSaphoTruthOrderFixture(options: {advanced?:boolean; mode?:'first'|'last'; withoutSapho?:boolean} = {}) { + const advanced = options.advanced ?? false, mode = options.mode ?? 'first'; + const source = createSaphoStartedMovementFixture({advanced,truthFor:['richese','guild']}); + let game = structuredClone(source.game); + const owner = source.owner; + // The original physical order is Richese, Atreides, Emperor, Guild. Taking + // Guild's Truth makes Emperor last; taking Richese's makes Emperor first. + const donor = mode === 'first' ? 'guild' : 'richese'; + const other = mode === 'first' ? 'richese' : 'guild'; + assert.ok(game.order.indexOf(other) !== game.order.indexOf(owner)); + const from = player(game,donor), receiver = player(game,owner); + const truthAt = from.hand.findIndex(card => card.effect === 'truthtrance'); assert.ok(truthAt >= 0); + const otherAt = receiver.hand.findIndex(card => card.id !== source.card && card.effect !== 'truthtrance'); assert.ok(otherAt >= 0); + const [truth] = from.hand.splice(truthAt,1), [exchange] = receiver.hand.splice(otherAt,1); + from.hand.push(exchange); receiver.hand.push(truth); + const remainingTruth = player(game,other).hand.find(card => card.effect === 'truthtrance'); assert.ok(remainingTruth); + assert.ok(receiver.hand.some(card => card.id === source.card)); + if (options.withoutSapho) { + // Controlled card custody before either declaration: exchange the genuine + // Sapho with another actual cache card. This tests creation of the public + // window without inspecting whether anybody owns the card. + const at = receiver.hand.findIndex(card => card.id === source.card), cache = game.richeseCache; + assert.ok(at >= 0 && cache?.length); + const [sapho] = receiver.hand.splice(at,1), replacement = cache.shift()!; + receiver.hand.push(replacement); cache.push(sapho); + } + assert.deepEqual(inventory(game),source.originalInventory); + const before = structuredClone(game), order = [...game.order], parent = { + phase:game.phase, turn:game.turn, active:game.active, + movementRemaining:structuredClone(game.movementRemaining), + players:game.players.map(row => ({id:row.id,spice:row.spice,reserves:row.reserves,tanks:row.tanks, + shipped:row.shipped,moved:row.moved,forces:structuredClone(row.forces)})), + }; + // Submit in reverse of physical storm order to demonstrate that network + // arrival is not the question order. Both declarations are real actions. + 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 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); + assert.deepEqual([...frame.holders].sort(),[owner,other].sort()); + assert.deepEqual(game.order,order); + assert.deepEqual(inventory(game),source.originalInventory); + const queue = game.truthtrance!.queue.map(row => row.player); + assert.equal(mode === 'first' ? queue.at(-1) : queue[0],owner, + `The actual pending group must offer a non-no-op ${mode} choice`); + return {game,source,before,advanced,mode,owner,other,donor,truth:truth.id, + otherTruth:remainingTruth.id,card:source.card,originalCards:source.originalInventory,order,parent, + disclosure:'Actual public setup, paid original Sapho acquisition and started movement; one labelled conserved card swap places an original dealt Truth in the Sapho owner hand.'}; +} +export type SaphoTruthOrderFixture = ReturnType; +export function passTruthOrder(game: Game, actor: string): Game { + const frame = truthOrderFrame(game); + return act(game,actor,{type:'truthOrderPass',event:frame.event,revision:frame.revision}); +} +export function playSaphoTruthOrder(game: Game, actor: string, card: string, mode:'first'|'last'): Game { + const frame = truthOrderFrame(game); + return act(game,actor,{type:'saphoTruthOrder',event:frame.event,revision:frame.revision,card,mode}); +} +export function finishTruthOrder(state: Game): Game { + let game = state; + while (game.truthtrance?.stage === 'order') { + const frame = truthOrderFrame(game), next = frame.holders.find(holder => !frame.passed.includes(holder)); + assert.ok(next); game = passTruthOrder(game,next); + } + assert.equal(game.truthtrance?.stage,'ask'); return game; +} +/** Public fresh-discard offers are intentionally possession-independent: even + * a table with no held Semuta must pass its neutral reaction window. */ +export function passFreshDiscard(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 new Error('The Semuta response must remain an offer.'); + const actor = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(actor); + game = act(game,actor.id,{type:'semutaPass',event:pending.batch.event}); + } + assert.ok(!game.pendingTreacheryDiscard?.reaction || game.pendingTreacheryDiscard.reaction.stage !== 'offer'); + return game; +} +export function finishOriginalTruthQuestions(state: Game): Game { + let game = state; + while (game.truthtrance) { + assert.equal(game.truthtrance.stage,'ask'); + const asker = game.truthtrance.queue[0].player; + const target = game.players.find(row => row.id !== asker)!.id; + game = act(game,asker,{type:'truthAsk',question:{kind:'fact',target, + fact:{kind:'spice',compare:'gte',value:0}}}); + assert.equal(game.truthtrance?.stage,'answer'); + game = act(game,target,{type:'truthAnswer',answer:'yes'}); + // If another player holds Semuta, the real completed Truth discard can + // open its own fresh reaction; pass it rather than bypassing custody. + game = passFreshDiscard(game); + } + return game; +} +export function rejectSaphoTruthOrder(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => act(game,actor,action)); + assert.deepEqual(game,before,'Rejected ordering action must not mutate its input'); +} +export function ownOrderChoices(game: Game, actor: string) { + return viewGame(game,actor).saphoTruthOrderChoices; +} diff --git a/tests/sapho-truth-order-controls.test.tsx b/tests/sapho-truth-order-controls.test.tsx new file mode 100644 index 00000000..07aa8612 --- /dev/null +++ b/tests/sapho-truth-order-controls.test.tsx @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import type { Action } from '../game/engine'; +import { SaphoTruthOrder, SaphoTruthOrderChoiceButton, SaphoTruthOrderPass, + type SaphoTruthOrderProps } from '../components/sapho-truth-order'; + +function props(choices: SaphoTruthOrderProps['choices'] = []): SaphoTruthOrderProps & { sent: Action[] } { + const sent: Action[] = []; + return { frame: { event: 'public-order-event', revision: 2, holders: ['one', 'two'], passed: [] }, + me: 'one', choices, busy: false, sent, act(action) { sent.push(action); } }; +} +const render = (input: SaphoTruthOrderProps) => renderToStaticMarkup(createElement(SaphoTruthOrder, input)); + +void test('queued holders receive a public continue opportunity without owning Sapho', () => { + const input = props(), html = render(input); + assert.match(html, /Truthtrance question order/); + assert.match(html, /0 of 2 players have finished this choice/); + assert.match(html, /Continue without changing question order/); + assert.doesNotMatch(html, /Use Juice of Sapho to ask/); + const button = SaphoTruthOrderPass(input); button.props.onClick(); + assert.deepEqual(input.sent, [{ type: 'truthOrderPass', event: 'public-order-event', revision: 2 }]); +}); + +void test('only private quoted choices render and dispatch exact event-bound actions', () => { + const choices = [{ card: 'private-card-id', mode: 'first' as const }, { card: 'private-card-id', mode: 'last' as const }]; + const input = props(choices), html = render(input); + assert.match(html, /Use Juice of Sapho to ask first/); + assert.match(html, /Use Juice of Sapho to ask last/); + assert.doesNotMatch(html, /private-card-id/); + for (const choice of choices) SaphoTruthOrderChoiceButton({ ...input, choice }).props.onClick(); + assert.deepEqual(input.sent, choices.map(choice => ({ type: 'saphoTruthOrder', event: 'public-order-event', revision: 2, ...choice }))); +}); + +void test('busy, passed and non-holder viewers cannot issue either action', () => { + for (const variant of ['busy', 'passed', 'other'] as const) { + const input = props([{ card: 'sapho-card', mode: 'first' }]); + if (variant === 'busy') input.busy = true; + if (variant === 'passed') input.frame.passed.push(input.me); + if (variant === 'other') input.me = 'other'; + SaphoTruthOrderPass(input).props.onClick(); + SaphoTruthOrderChoiceButton({ ...input, choice: input.choices[0] }).props.onClick(); + assert.deepEqual(input.sent, []); + if (variant !== 'busy') assert.doesNotMatch(render(input), /data-testid="sapho-truth-order-pass"/); + else assert.match(render(input), /disabled=""/); + } +}); + +void test('callbacks captured before an event, revision, pass or quoted choice changes do not send', () => { + for (const change of ['event', 'revision', 'pass', 'choice'] as const) { + const input = props([{ card: 'sapho-card', mode: 'first' }]); + const pass = SaphoTruthOrderPass(input), choice = SaphoTruthOrderChoiceButton({ ...input, choice: input.choices[0] }); + if (change === 'event') input.frame.event = 'new-event'; + if (change === 'revision') input.frame.revision++; + if (change === 'pass') input.frame.passed.push(input.me); + if (change === 'choice') input.choices.splice(0); + if (change !== 'choice') pass.props.onClick(); + choice.props.onClick(); + assert.deepEqual(input.sent, []); + } +}); diff --git a/tests/sapho-truth-order-integrated-controls.test.tsx b/tests/sapho-truth-order-integrated-controls.test.tsx new file mode 100644 index 00000000..7c83b4d0 --- /dev/null +++ b/tests/sapho-truth-order-integrated-controls.test.tsx @@ -0,0 +1,82 @@ +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, createSaphoTruthOrderFixture, 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'} queued holders see and use actual Truth-order controls`, () => { + // Native public setup, a genuinely purchased Sapho and an original started + // movement parent. One labeled, physical-card-conserving swap places an + // already-dealt Truth card into the Sapho owner's hand; see the fixture. + const f = createSaphoTruthOrderFixture({advanced,mode:'first'}); + let game = f.game; + const otherView = viewGame(game,f.other), otherFrame = otherView.truthtrance?.order; + assert.ok(otherFrame); assert.deepEqual(otherView.saphoTruthOrderChoices,[]); + assert.match(truth(otherView),/Continue without changing question order/); + assert.match(table(otherView),/Continue without changing question order/); + assert.doesNotMatch(truth(otherView),/Use Juice of Sapho to ask/); + assert.doesNotMatch(truth(otherView),new RegExp(f.card)); + + // Invoke the actual hook-free leaf callbacks rendered by Truthtrance; SSR + // does not operate a browser, hydration or interactive selectors. + const sent: Action[] = []; + const pass = SaphoTruthOrderPass({frame:otherFrame,me:f.other,choices:otherView.saphoTruthOrderChoices, + busy:false,act(action) { sent.push(action); }}); + assert.equal(pass.props.disabled,false); pass.props.onClick(); + assert.deepEqual([...sent],[{type:'truthOrderPass',event:otherFrame.event,revision:otherFrame.revision}]); + game = act(game,f.other,sent.pop()!); + + const ownerView = viewGame(game,f.owner), ownerFrame = ownerView.truthtrance?.order; + assert.ok(ownerFrame); assert.ok(ownerFrame.passed.includes(f.other)); + const choice = ownerView.saphoTruthOrderChoices.find(row => row.card === f.card && row.mode === 'first'); + assert.ok(choice); + assert.match(truth(ownerView),/Use Juice of Sapho to ask first/); + assert.match(table(ownerView),/Use Juice of Sapho to ask first/); + const button = SaphoTruthOrderChoiceButton({frame:ownerFrame,me:f.owner,choices:ownerView.saphoTruthOrderChoices, + choice,busy:false,act(action) { sent.push(action); }}); + assert.equal(button.props.disabled,false); button.props.onClick(); + assert.deepEqual([...sent],[{type:'saphoTruthOrder',event:ownerFrame.event,revision:ownerFrame.revision, + card:f.card,mode:'first'}]); + game = act(game,f.owner,sent.pop()!); + // Basic's supported Semuta profile can open a neutral reaction; actually + // pass it before observing the restored first original Truth question. + game = passFreshDiscard(game); + assert.equal(game.truthtrance?.stage,'ask'); + assert.equal(game.truthtrance.queue[0].player,f.owner); + assert.deepEqual(game.order,f.order); + assert.deepEqual(inventory(game),f.originalCards); + game = finishOriginalTruthQuestions(game); + assert.equal(game.truthtrance,null); + assert.equal(game.active,f.parent.active); + assert.equal(game.phase,f.parent.phase); + assert.deepEqual(inventory(game),f.originalCards); + }, +); diff --git a/tests/sapho-truth-order-runtime.test.ts b/tests/sapho-truth-order-runtime.test.ts new file mode 100644 index 00000000..7e4c66fb --- /dev/null +++ b/tests/sapho-truth-order-runtime.test.ts @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { handLimit, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { act, inventory, player, createSaphoTruthOrderFixture as fixture, + 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 mode of ['first','last'] as const) { + const label = `${advanced ? 'Advanced' : 'Basic'} ${mode}`; + void test(`${label} Sapho reorders the original pending Truth groups and resumes their parent`, () => { + const f = fixture({advanced,mode}), state = f.game, before = structuredClone(state); + const current = frame(state), ownerChoices = choices(state,f.owner); + assert.ok(ownerChoices.some(option => option.card === f.card && option.mode === mode)); + assert.deepEqual(choices(state,f.other), [], 'A different holder is not told which card Emperor holds'); + const unchanged = mode === 'first' ? 'last' : 'first'; + reject(state,f.owner,{type:'saphoTruthOrder',event:current.event,revision:current.revision,card:f.card,mode:unchanged}); + reject(state,f.owner,{type:'saphoTruthOrder',event:'stale',revision:current.revision,card:f.card,mode}); + reject(state,f.owner,{type:'saphoTruthOrder',event:current.event,revision:current.revision + 1,card:f.card,mode}); + reject(state,'atreides',{type:'truthOrderPass',event:current.event,revision:current.revision}); + assert.deepEqual(state,before); + let game = play(state,f.owner,f.card,mode); + // The neutral Semuta offer may suspend Truth even when nobody holds the + // card; each player explicitly passes before the original frame resumes. + game = passFreshDiscard(game); + assert.equal(player(game,f.owner).hand.some(card => card.id === f.card),false); + assert.equal(game.discard.filter(card => card.id === f.card).length,1); + assert.deepEqual(game.order,f.order,'The physical storm order never changes'); + assert.ok(frame(game).passed.includes(f.owner)); + reject(game,f.owner,{type:'saphoTruthOrder',event:current.event,revision:current.revision,card:f.card,mode}); + game = finish(game); + const queue = game.truthtrance!.queue.map(row => row.player); + assert.equal(mode === 'first' ? queue[0] : queue.at(-1),f.owner); + assert.deepEqual([...queue].sort(),[f.owner,f.other].sort()); + game = answer(game); + assert.equal(game.truthtrance,null); + assert.equal(game.discard.filter(card => [f.card,f.truth,f.otherTruth].includes(card.id)).length,3); + assert.equal(game.turn,f.parent.turn); assert.equal(game.phase,f.parent.phase); + assert.equal(game.active,f.parent.active); assert.deepEqual(game.movementRemaining,f.parent.movementRemaining); + assert.deepEqual(game.players.map(row => ({id:row.id,spice:row.spice,reserves:row.reserves,tanks:row.tanks, + shipped:row.shipped,moved:row.moved,forces:row.forces})),f.parent.players); + assert.deepEqual(inventory(game),f.originalCards); + }); +} + +for (const advanced of [false,true]) { + const label = advanced ? 'Advanced' : 'Basic'; + void test(`${label} all Truth holders can pass ordering without any Sapho being held`, () => { + // The optional cache exchange occurs before either actual declaration, + // so window creation cannot depend on whether anybody holds Sapho. + const f = fixture({advanced,mode:'first',withoutSapho:true}), state = structuredClone(f.game); + assert.ok(state.players.every(row => !row.hand.some(card => card.id === f.card))); + assert.deepEqual(inventory(state),f.originalCards); + const initialQueue = state.truthtrance!.queue.map(row => ({...row})); + const initialFrame = frame(state); + for (const holder of initialFrame.holders) assert.deepEqual(choices(state,holder),[]); + let game = pass(state,initialFrame.holders[0]); + const updated = frame(game); + reject(game,initialFrame.holders[0],{type:'truthOrderPass',event:updated.event,revision:updated.revision}); + game = finish(game); + assert.deepEqual(game.truthtrance!.queue,initialQueue); + game = answer(game); + assert.deepEqual(inventory(game),f.originalCards); + }); + + void test(`${label} each minimal bot profile can advance the public ordering window legally`, () => { + const f = fixture({advanced,mode:'first'}); + for (const difficulty of DIFFICULTIES) for (const actor of [f.owner,f.other]) { + const view = viewGame(f.game,actor); + view.players.find(row => row.id === actor)!.bot = difficulty; + const action = botActions(view)[0]; assert.ok(action,`${difficulty} ${actor}`); + assert.ok(action.type === 'truthOrderPass' || action.type === 'saphoTruthOrder', + `The ${difficulty} bot must use a legal ordering action`); + const progressed = act(structuredClone(f.game),actor,action); + assert.ok(!progressed.truthtrance || progressed.truthtrance.stage === 'order' || progressed.truthtrance.stage === 'ask'); + assert.deepEqual(inventory(progressed),f.originalCards); + } + }); +} + +for (const claim of [false,true]) void test( + `Basic labeled conserved Semuta ${claim ? 'claim' : 'pass'} restores the same Truth ordering window`, () => { + const f = fixture({advanced:false,mode:'first'}), state = structuredClone(f.game); + // Controlled physical supplement: the original cached Semuta is swapped + // with Atreides' original held card. The underlying public setup and + // actually purchased Sapho are untouched; no card/resource is created. + 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); + assert.deepEqual(inventory(state),f.originalCards); + const prior = structuredClone(frame(state)); + let game = play(state,f.owner,f.card,'first'); + assert.ok(game.pendingTreacheryDiscard?.reaction,'The actual fresh Sapho discard offers Semuta'); + assert.equal(game.pendingTreacheryDiscard.batch.entries[0].card.id,f.card); + const event = game.pendingTreacheryDiscard.batch.event; + assert.equal(viewGame(game,'atreides').semutaReaction?.canCommit,true); + if (claim) game = act(game,'atreides',{type:'semutaCommit',event}); + else while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') { + const pending: NonNullable = game.pendingTreacheryDiscard; + const reaction: NonNullable['reaction']> = pending.reaction!; + assert.equal(reaction.stage,'offer'); + if (reaction.stage !== 'offer') throw new Error('The Semuta response must remain an offer.'); + const actor = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(actor); + game = act(game,actor.id,{type:'semutaPass',event:pending.batch.event}); + } + assert.equal(game.pendingTreacheryDiscard,null); + const resumed = frame(game); + assert.equal(resumed.event,prior.event); + assert.deepEqual([...resumed.holders].sort(),[...prior.holders].sort()); + assert.ok(resumed.passed.includes(f.owner)); + assert.equal(player(game,'atreides').hand.some(card => card.id === f.card),claim); + assert.equal(game.discard.some(card => card.id === semuta.id),claim); + assert.deepEqual(inventory(game),f.originalCards); + game = answer(finish(game)); + assert.equal(game.truthtrance,null); + assert.equal(game.active,f.parent.active); assert.equal(game.phase,f.parent.phase); + assert.deepEqual(inventory(game),f.originalCards); + }, +); + +for (const claim of [false,true]) void test( + `Basic Semuta ${claim ? 'claim' : 'pass'} after the final Sapho response resumes directly to Truth questions`, () => { + const f = fixture({advanced:false,mode:'first'}), state = structuredClone(f.game); + // Controlled conserved custody, as above: swap the original cached Semuta + // into Atreides' hand before either participant responds to the order frame. + 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); + assert.deepEqual(inventory(state),f.originalCards); + let game = pass(state,f.other); + assert.deepEqual(frame(game).passed,[f.other]); + // The only remaining holder plays Sapho, so the ordering is complete but + // its fresh-discard reaction must settle before the first question opens. + game = play(game,f.owner,f.card,'first'); + assert.ok(game.pendingTreacheryDiscard?.reaction,'The actual fresh Sapho discard offers Semuta'); + assert.equal(game.pendingTreacheryDiscard.batch.entries[0].card.id,f.card); + 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); + assert.equal(player(game,'atreides').hand.some(card => card.id === f.card),claim); + assert.equal(game.discard.some(card => card.id === semuta.id),claim); + assert.deepEqual(inventory(game),f.originalCards); + game = answer(game); + assert.equal(game.truthtrance,null); + assert.equal(game.phase,f.parent.phase); assert.equal(game.active,f.parent.active); + assert.deepEqual(inventory(game),f.originalCards); + }, +); + +for (const mode of ['first','last'] as const) void test( + `Basic a second Truth holder cannot displace an established Sapho ${mode} after reclaiming it with Semuta`, () => { + const f = fixture({advanced:false,mode}), state = structuredClone(f.game); + // Controlled conserved custody: move the original cached Semuta into the + // other already-queued Truth holder's real hand, without modifying funds, + // the queue or any physical card identity. The hand has a genuine vacancy. + const other = player(state,f.other), cache = state.richeseCache; + assert.ok(cache); const semutaAt = cache.findIndex(card => card.effect === 'semutaDrug'); assert.ok(semutaAt >= 0); + assert.ok(other.hand.length < handLimit(other)); + const [semuta] = cache.splice(semutaAt,1); other.hand.push(semuta); + assert.ok(other.hand.some(card => card.id === f.otherTruth)); + assert.deepEqual(inventory(state),f.originalCards); + const before = frame(state); + let game = play(state,f.owner,f.card,mode); + assert.ok(game.pendingTreacheryDiscard?.reaction,'The physical Sapho discard offers the held Semuta'); + const event = game.pendingTreacheryDiscard.batch.event; + assert.equal(viewGame(game,f.other).semutaReaction?.canCommit,true); + game = act(game,f.other,{type:'semutaCommit',event}); + assert.equal(game.pendingTreacheryDiscard,null); + assert.ok(player(game,f.other).hand.some(card => card.id === f.card)); + assert.ok(game.discard.some(card => card.id === semuta.id)); + const after = frame(game); + assert.equal(after.event,before.event); + assert.ok(after.passed.includes(f.owner)); assert.ok(!after.passed.includes(f.other)); + assert.equal(mode === 'first' ? game.truthtrance!.queue[0].player : game.truthtrance!.queue.at(-1)!.player,f.owner); + assert.ok(!choices(game,f.other).some(choice => choice.card === f.card && choice.mode === mode), + 'The later holder cannot override the earlier protected position'); + reject(game,f.other,{type:'saphoTruthOrder',event:after.event,revision:after.revision,card:f.card,mode}); + assert.equal(mode === 'first' ? game.truthtrance!.queue[0].player : game.truthtrance!.queue.at(-1)!.player,f.owner); + game = pass(game,f.other); + assert.equal(game.truthtrance?.stage,'ask'); + assert.equal(mode === 'first' ? game.truthtrance!.queue[0].player : game.truthtrance!.queue.at(-1)!.player,f.owner); + game = answer(game); + assert.equal(game.truthtrance,null); + assert.equal(game.phase,f.parent.phase); assert.equal(game.active,f.parent.active); + assert.deepEqual(inventory(game),f.originalCards); + }, +); diff --git a/tests/sapho-truth-order.test.ts b/tests/sapho-truth-order.test.ts new file mode 100644 index 00000000..24917bd4 --- /dev/null +++ b/tests/sapho-truth-order.test.ts @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + SaphoTruthOrderError, createSaphoTruthOrder, passSaphoTruthOrder, + quoteSaphoTruthOrder, reorderSaphoTruthOrder, validateSaphoTruthOrder, +} from '../game/sapho-truth-order'; + +const original = [ + { player: 'a', card: 'a1', source: 'printed' }, + { player: 'a', card: 'a2', source: 'shrine' }, + { player: 'b', card: 'b1', source: 'printed' }, + { player: 'c', card: 'c1', source: 'printed' }, +]; +const frame = () => createSaphoTruthOrder(original, 'opaque-event')!; + +void test('the public window is created from distinct committed holders, never their hidden hands', () => { + assert.equal(createSaphoTruthOrder([], 'empty'), null); + assert.equal(createSaphoTruthOrder(original.slice(0, 2), 'one-holder'), null); + assert.deepEqual(frame(), { event: 'opaque-event', revision: 0, holders: ['a', 'b', 'c'], passed: [] }); + assert.deepEqual(quoteSaphoTruthOrder(frame(), original, 'a'), { event: 'opaque-event', revision: 0, modes: ['last'] }); + assert.deepEqual(quoteSaphoTruthOrder(frame(), original, 'b'), { event: 'opaque-event', revision: 0, modes: ['first', 'last'] }); + assert.deepEqual(quoteSaphoTruthOrder(frame(), original, 'c'), { event: 'opaque-event', revision: 0, modes: ['first'] }); +}); + +void test('first and last move the entire group, preserving every original row and internal card order', () => { + const before = structuredClone(original), start = frame(); + const first = reorderSaphoTruthOrder(start, original, { event: start.event, revision: 0, player: 'b', mode: 'first' }); + assert.deepEqual(first.queue.map(row => row.card), ['b1', 'a1', 'a2', 'c1']); + assert.deepEqual(first.frame, { event: 'opaque-event', revision: 1, holders: ['a', 'b', 'c'], passed: ['b'], protectedFirst: 'b' }); + assert.equal(first.done, false); + assert.deepEqual(original, before); + const last = reorderSaphoTruthOrder(first.frame, first.queue, { event: start.event, revision: 1, player: 'a', mode: 'last' }); + assert.deepEqual(last.queue.map(row => row.card), ['b1', 'c1', 'a1', 'a2']); + assert.deepEqual(last.queue.filter(row => row.player === 'a'), before.slice(0, 2)); + assert.deepEqual(last.frame.passed, ['b', 'a']); + assert.deepEqual(last.frame.holders, ['a', 'b', 'c']); +}); + +void test('every public holder may pass independently, and only the last response completes the window', () => { + const first = passSaphoTruthOrder(frame(), original, { event: 'opaque-event', revision: 0, player: 'c' }); + assert.equal(first.done, false); + const second = passSaphoTruthOrder(first.frame, first.queue, { event: 'opaque-event', revision: 1, player: 'a' }); + assert.equal(second.done, false); + const third = passSaphoTruthOrder(second.frame, second.queue, { event: 'opaque-event', revision: 2, player: 'b' }); + assert.equal(third.done, true); + assert.deepEqual(third.queue, original); + assert.deepEqual(third.frame.passed, ['c', 'a', 'b']); +}); + +void test('expired events, stale revisions and duplicate or outsider responses leave the input unchanged', () => { + const initial = frame(), first = passSaphoTruthOrder(initial, original, { event: 'opaque-event', revision: 0, player: 'a' }); + const before = structuredClone(first); + for (const request of [ + { event: 'other', revision: 1, player: 'b' }, + { event: 'opaque-event', revision: 0, player: 'b' }, + { event: 'opaque-event', revision: 1, player: 'a' }, + { event: 'opaque-event', revision: 1, player: 'outsider' }, + ]) assert.throws(() => passSaphoTruthOrder(first.frame, first.queue, request), SaphoTruthOrderError); + assert.deepEqual(first, before); +}); + +void test('no-op choices and malformed orders reject without consuming an opportunity', () => { + const initial = frame(), before = structuredClone(initial); + for (const request of [ + { event: initial.event, revision: 0, player: 'a', mode: 'first' as const }, + { event: initial.event, revision: 0, player: 'c', mode: 'last' as const }, + ]) assert.throws(() => reorderSaphoTruthOrder(initial, original, request), SaphoTruthOrderError); + assert.deepEqual(initial, before); + assert.throws(() => createSaphoTruthOrder([original[0], original[2], original[1]], 'event'), /together/); + assert.throws(() => validateSaphoTruthOrder({ ...initial, passed: ['a'], revision: 0 }, original), /revision/); + assert.throws(() => validateSaphoTruthOrder({ ...initial, holders: ['a', 'b', 'd'] }, original), /original Truth holder/); + assert.throws(() => validateSaphoTruthOrder({ ...initial, passed: ['outsider'], revision: 1 }, original), /original holders/); +}); + +void test('JSON round-trip preserves the event, revision and grouped ordering without private data in the frame', () => { + const initial = frame(), moved = reorderSaphoTruthOrder(initial, original, { event: initial.event, revision: 0, player: 'c', mode: 'first' }); + const restored = JSON.parse(JSON.stringify(moved)) as typeof moved; + assert.doesNotMatch(JSON.stringify(restored.frame), /a1|a2|b1|c1|shrine/); + validateSaphoTruthOrder(restored.frame, restored.queue); + assert.deepEqual(quoteSaphoTruthOrder(restored.frame, restored.queue, 'a'), { event: 'opaque-event', revision: 1, modes: ['last'] }); +}); + +void test('an earlier first choice is protected when another holder later obtains and plays Sapho', () => { + const initial = frame(); + const first = reorderSaphoTruthOrder(initial, original, { event: initial.event, revision: 0, player: 'b', mode: 'first' }); + // A second physical use is possible after another player claims the same + // discarded Sapho through Semuta. It may not displace the first holder. + assert.deepEqual(quoteSaphoTruthOrder(first.frame, first.queue, 'c').modes, []); + assert.throws(() => reorderSaphoTruthOrder(first.frame, first.queue, + { event: initial.event, revision: 1, player: 'c', mode: 'first' }), /protects/); + assert.deepEqual(quoteSaphoTruthOrder(first.frame, first.queue, 'a').modes, ['last']); + const opposite = reorderSaphoTruthOrder(first.frame, first.queue, + { event: initial.event, revision: 1, player: 'a', mode: 'last' }); + assert.deepEqual(opposite.queue.map(row => row.player), ['b','c','a','a']); + assert.equal(opposite.frame.protectedFirst, 'b'); + assert.equal(opposite.frame.protectedLast, 'a'); + assert.deepEqual(quoteSaphoTruthOrder(opposite.frame, opposite.queue, 'c').modes, []); + validateSaphoTruthOrder(opposite.frame, opposite.queue); +}); + +void test('an earlier last choice remains protected, and legacy frames need no protection fields', () => { + const initial = frame(); + const last = reorderSaphoTruthOrder(initial, original, { event: initial.event, revision: 0, player: 'b', mode: 'last' }); + assert.equal(last.frame.protectedLast, 'b'); + assert.deepEqual(last.queue.map(row => row.player), ['a','a','c','b']); + assert.deepEqual(quoteSaphoTruthOrder(last.frame,last.queue,'c').modes, ['first']); + assert.throws(() => reorderSaphoTruthOrder(last.frame,last.queue, + {event:initial.event,revision:1,player:'c',mode:'last'}), /protects/); + const first = reorderSaphoTruthOrder(last.frame,last.queue, + {event:initial.event,revision:1,player:'c',mode:'first'}); + assert.deepEqual(first.queue.map(row => row.player), ['c','a','a','b']); + assert.equal(first.frame.protectedFirst,'c'); assert.equal(first.frame.protectedLast,'b'); + assert.throws(() => validateSaphoTruthOrder(first.frame,[...first.queue].reverse()), /together|protected/); + const legacy = JSON.parse(JSON.stringify(initial)) as typeof initial; + assert.equal(legacy.protectedFirst,undefined); assert.equal(legacy.protectedLast,undefined); + validateSaphoTruthOrder(legacy,original); +});