Support Sapho Truth order during Richese cache auctions

This commit is contained in:
Quintine committed 2026-10-27 18:55:42 +11:00
1 parent 8e16542784
commit 6a31315c95
17 files changed
+420 -25

No files matched your search

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