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

+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 &&