Preserve Sapho Truth order during ordinary auctions

This commit is contained in:
Quintine committed 2026-10-27 18:46:15 +11:00
1 parent 298c8245a6
commit 8e16542784
13 files changed
+534 -12

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import { type Game } from '../game/engine';
import { createSaphoStartedMovementFixture, advanceSaphoStartedMovement,
saphoStartedMovementClean } from './fixture-sapho-started-movement';
import { act, inventory, player, truthOrderFrame } from './fixture-sapho-truth-order';
export { act, inventory, player };
/** Genuine public R/A/E/G setup and original cache1 Sapho purchase. Continue
* that acquired snapshot through native actions to a quiet original ordinary
* auction. Controlled supplemental custody swaps one actually dealt Truth
* with Emperor's other held original card so the Sapho owner is a holder.
* No purse, force, phase, auction, receipt or physical card is installed. */
export function createSaphoTruthAuctionFixture(options: {
advanced?:boolean; standingBid?:boolean; mode?:'first'|'last';
} = {}) {
const advanced = options.advanced ?? false, standingBid = options.standingBid ?? false, mode = options.mode ?? 'first';
const source = createSaphoStartedMovementFixture({advanced,truthFor:['richese','guild']});
let game = advanceSaphoStartedMovement(structuredClone(source.acquired), state =>
state.phase === 3 && !!state.auction && saphoStartedMovementClean(state));
assert.ok(game.auction); assert.equal(game.auction.bid,0); assert.equal(game.auction.bidder,null);
if (standingBid) {
const actor = game.auction.active;
game = act(game,actor,{type:'bid',amount:1,allyPayment:0});
assert.equal(game.auction?.bid,1); assert.equal(game.auction?.bidder,actor);
assert.ok(saphoStartedMovementClean(game));
}
const owner = source.owner;
const possible = (['richese','guild'] as const).filter(id => mode === 'first'
? game.order.indexOf(id) < game.order.indexOf(owner)
: game.order.indexOf(id) > game.order.indexOf(owner));
assert.ok(possible.length,`Original public storm order needs a holder on the ${mode} side of Emperor`);
const other = possible[0], donor = other === 'richese' ? 'guild' : 'richese';
const from = player(game,donor), receiver = player(game,owner);
const truthAt = from.hand.findIndex(card => card.effect === 'truthtrance'); assert.ok(truthAt >= 0);
const exchangeAt = receiver.hand.findIndex(card => card.id !== source.card && card.effect !== 'truthtrance'); assert.ok(exchangeAt >= 0);
const [truth] = from.hand.splice(truthAt,1), [exchange] = receiver.hand.splice(exchangeAt,1);
from.hand.push(exchange); receiver.hand.push(truth);
const otherTruth = player(game,other).hand.find(card => card.effect === 'truthtrance'); assert.ok(otherTruth);
assert.ok(receiver.hand.some(card => card.id === source.card));
assert.deepEqual(inventory(game),source.originalInventory);
const parent = {auction:structuredClone(game.auction), aid:structuredClone(game.aid),
turn:game.turn, phase:game.phase, active:game.active, order:[...game.order],
players:game.players.map(row => ({id:row.id,spice:row.spice,reserves:row.reserves,tanks:row.tanks}))};
const beforeTruth = structuredClone(game);
for (const actor of [owner,other]) {
const card = player(game,actor).hand.find(row => row.effect === 'truthtrance'); assert.ok(card);
game = act(game,actor,{type:'card',card:card.id});
}
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(actor);
game = act(game,actor.id,{type:'truthPass'});
}
const frame = truthOrderFrame(game);
assert.deepEqual([...frame.holders].sort(),[owner,other].sort());
assert.deepEqual(game.auction,parent.auction); assert.deepEqual(game.aid,parent.aid);
const queue = game.truthtrance!.queue.map(row => row.player);
assert.equal(mode === 'first' ? queue.at(-1) : queue[0],owner);
assert.deepEqual(inventory(game),source.originalInventory);
return {game,source,beforeTruth,advanced,standingBid,mode,owner,other,donor,card:source.card,
truth:truth.id,otherTruth:otherTruth.id,parent,originalCards:source.originalInventory,
disclosure:'Original public setup, real cache Sapho purchase and ordinary lot; one labelled original dealt Truth↔held card swap.'};
}
export type SaphoTruthAuctionFixture = ReturnType<typeof createSaphoTruthAuctionFixture>;
/** Keep the actual ordinary lot and winning invoice intact while the overlay is live. */
export function assertAuctionParent(game: Game, parent: SaphoTruthAuctionFixture['parent']) {
assert.equal(game.turn,parent.turn); assert.equal(game.phase,parent.phase); assert.equal(game.active,parent.active);
assert.deepEqual(game.order,parent.order); assert.deepEqual(game.auction,parent.auction); assert.deepEqual(game.aid,parent.aid);
assert.deepEqual(game.players.map(row => ({id:row.id,spice:row.spice,reserves:row.reserves,tanks:row.tanks})),parent.players);
}
+93
View File
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { advanceAuctionClock, hasAuctionClockOpportunity, synchronizeAuctionClock, type Game } from '../game/engine';
import { createSaphoTruthAuctionFixture as fixture, act, inventory, player, assertAuctionParent } from './fixture-sapho-truth-auction';
import { truthOrderFrame as frame, playSaphoTruthOrder as play, passFreshDiscard,
finishTruthOrder as finish, finishOriginalTruthQuestions as answer } from './fixture-sapho-truth-order';
const epoch = '0:0';
/** Replays the original starting deal and earned cache Sapho fixture into its
* original ordinary lot. A Truth↔hand exchange is already labeled there; for
* Basic Semuta cases, additionally exchange the actual cached Semuta with an
* Atreides hand card, preserving physical inventory and the original purse.
* Trusted clock times below are supplied directly, not read from wall time. */
function openTimedTruth(advanced: boolean, semuta: boolean) {
const f = fixture({advanced,standingBid:true,mode:'first'});
let game: Game = structuredClone(f.beforeTruth);
if (semuta) {
const cache = game.richeseCache, holder = player(game,'atreides'); assert.ok(cache);
const at = cache.findIndex(card => card.effect === 'semutaDrug'); assert.ok(at >= 0 && holder.hand.length > 0);
const [card] = cache.splice(at,1), [exchange] = holder.hand.splice(0,1);
holder.hand.push(card); cache.push(exchange);
}
assertAuctionParent(game,f.parent); assert.deepEqual(inventory(game),f.originalCards);
assert.equal(game.auction?.clockVersion,1); assert.equal(hasAuctionClockOpportunity(game),true);
game = synchronizeAuctionClock(game,1_000,epoch);
assert.ok(game.auctionClock); assert.equal(game.auctionClock.remainingMs,10_000);
assert.equal(game.auctionClock.startedAt,1_000); assert.equal(game.auctionClock.player,game.auction!.active);
const initial = structuredClone(game.auctionClock);
// A real Truth declaration interrupts the bidder after two elapsed seconds.
game = act(game,f.owner,{type:'card',card:f.truth});
game = synchronizeAuctionClock(game,3_000,epoch);
assert.equal(game.auctionClock?.event,initial.event); assert.equal(game.auctionClock?.remainingMs,8_000);
assert.equal(game.auctionClock?.startedAt,null);
game = act(game,f.other,{type:'card',card:f.otherTruth});
game = synchronizeAuctionClock(game,10_000,epoch);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(actor);
game = act(game,actor.id,{type:'truthPass'});
game = synchronizeAuctionClock(game,20_000,epoch);
}
assert.equal(game.truthtrance?.stage,'order');
assert.equal(game.auctionClock?.event,initial.event); assert.equal(game.auctionClock?.remainingMs,8_000);
assert.equal(game.auctionClock?.startedAt,null); assertAuctionParent(game,f.parent);
return {f,game,initial};
}
for (const scenario of [
{label:'Basic Semuta pass',advanced:false,semuta:true,claim:false},
{label:'Basic Semuta claim',advanced:false,semuta:true,claim:true},
{label:'Advanced without Semuta preview',advanced:true,semuta:false,claim:false},
]) void test(`${scenario.label}: the native bidder clock pauses through Truth order and Sapho disposal`, () => {
const {f,game:opened,initial} = openTimedTruth(scenario.advanced,scenario.semuta);
let game = opened;
const event = game.auctionClock!.event, playerId = game.auctionClock!.player;
assert.equal(event,initial.event);
assert.equal(game.auctionClock!.remainingMs,8_000); assert.equal(game.auctionClock!.startedAt,null);
const beforeLot = structuredClone(game.auction), orderFrame = frame(game);
game = play(game,f.owner,f.card,'first');
game = synchronizeAuctionClock(game,70_000,epoch);
assert.equal(game.auctionClock?.event,event); assert.equal(game.auctionClock?.player,playerId);
assert.equal(game.auctionClock?.remainingMs,8_000); assert.equal(game.auctionClock?.startedAt,null);
assert.deepEqual(game.auction,beforeLot);
if (scenario.semuta) {
assert.ok(game.pendingTreacheryDiscard?.reaction);
const pendingEvent = game.pendingTreacheryDiscard.batch.event;
const held = advanceAuctionClock(game,100_000,epoch);
assert.equal(held.auctionClock?.event,event); assert.equal(held.auctionClock?.remainingMs,8_000);
assert.equal(held.auctionClock?.startedAt,null); assert.deepEqual(held.auction,beforeLot);
game = held;
if (scenario.claim) game = act(game,'atreides',{type:'semutaCommit',event:pendingEvent});
else game = passFreshDiscard(game);
game = synchronizeAuctionClock(game,120_000,epoch);
assert.equal(game.pendingTreacheryDiscard,null);
assert.equal(frame(game).event,orderFrame.event);
}
assert.equal(game.auctionClock?.event,event); assert.equal(game.auctionClock?.remainingMs,8_000);
assert.equal(game.auctionClock?.startedAt,null);
game = finish(game); game = synchronizeAuctionClock(game,150_000,epoch);
assert.equal(game.truthtrance?.stage,'ask'); assert.equal(game.auctionClock?.startedAt,null);
game = answer(game); assert.equal(game.truthtrance,null);
game = synchronizeAuctionClock(game,200_000,epoch);
assert.equal(game.auctionClock?.event,event); assert.equal(game.auctionClock?.player,playerId);
assert.equal(game.auctionClock?.remainingMs,8_000); assert.equal(game.auctionClock?.startedAt,200_000);
assert.deepEqual(game.auction,beforeLot); assert.deepEqual(inventory(game),f.originalCards);
const stillLive = advanceAuctionClock(game,207_999,epoch);
assert.equal(stillLive.auctionClock?.event,event); assert.deepEqual(stillLive.auction,beforeLot);
const expired = advanceAuctionClock(stillLive,208_000,epoch);
assert.ok(expired.auction?.passed.includes(playerId) || expired.auction?.index !== beforeLot!.index,
'The original bidder receives the native timeout only after the retained budget expires');
assert.deepEqual(inventory(expired),f.originalCards);
assert.ok(initial.event,'A real clock event was created');
});
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame, type Action, type GameView } from '../game/engine';
import { SaphoTruthOrderPass, SaphoTruthOrderChoiceButton } from '../components/sapho-truth-order';
import { act, inventory, createSaphoTruthAuctionFixture, assertAuctionParent } from './fixture-sapho-truth-auction';
import { passFreshDiscard, finishOriginalTruthQuestions } from './fixture-sapho-truth-order';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return {format:'module',source:`export default ${JSON.stringify(classes)}`,shortCircuit:true};
},
});
const {GameTable} = await import('../components/game-table');
const {Truthtrance} = await import('../components/truthtrance');
aliases.deregister();
const table = (game: GameView) => renderToStaticMarkup(createElement(GameTable,
{game,busy:false,send:async()=>{},onExit() {}}));
const truth = (game: GameView) => renderToStaticMarkup(createElement(Truthtrance,
{game,busy:false,act() {}}));
for (const advanced of [false,true]) void test(
`${advanced ? 'Advanced' : 'Basic'} actual Truth-order controls preserve a standing ordinary bid`, () => {
// Original public setup, genuinely purchased Sapho, actual ordinary lot
// and standing bid. A labeled conserved card swap gives its holder an
// original Truth; this does not claim a native earned Truth acquisition.
const f = createSaphoTruthAuctionFixture({advanced,standingBid:true,mode:'first'});
let game = f.game;
assertAuctionParent(game,f.parent);
const other = viewGame(game,f.other), frame = other.truthtrance?.order;
assert.ok(frame); assert.deepEqual(other.saphoTruthOrderChoices,[]);
assert.match(truth(other),/Continue without changing question order/);
assert.match(table(other),/Continue without changing question order/);
assert.doesNotMatch(truth(other),/Use Juice of Sapho to ask/);
// Invoke the actual rendered leaf callbacks. Server rendering does not
// exercise a stateful browser, selectors or hydration.
const sent:Action[] = [];
const pass = SaphoTruthOrderPass({frame,me:f.other,choices:other.saphoTruthOrderChoices,
busy:false,act(action) { sent.push(action); }});
assert.equal(pass.props.disabled,false); pass.props.onClick();
assert.deepEqual([...sent],[{type:'truthOrderPass',event:frame.event,revision:frame.revision}]);
game = act(game,f.other,sent.pop()!); assertAuctionParent(game,f.parent);
const owner = viewGame(game,f.owner), order = owner.truthtrance?.order;
assert.ok(order); assert.ok(order.passed.includes(f.other));
const choice = owner.saphoTruthOrderChoices.find(row => row.card === f.card && row.mode === 'first');
assert.ok(choice); assert.match(truth(owner),/Use Juice of Sapho to ask first/);
assert.match(table(owner),/Use Juice of Sapho to ask first/);
const use = SaphoTruthOrderChoiceButton({frame:order,me:f.owner,choices:owner.saphoTruthOrderChoices,
choice,busy:false,act(action) { sent.push(action); }});
assert.equal(use.props.disabled,false); use.props.onClick();
assert.deepEqual([...sent],[{type:'saphoTruthOrder',event:order.event,revision:order.revision,
card:f.card,mode:'first'}]);
game = act(game,f.owner,sent.pop()!); assertAuctionParent(game,f.parent);
// Basic can open the neutral Semuta discard response. Finish real passes
// before resuming the original questions; the invoice remains committed.
game = passFreshDiscard(game); assertAuctionParent(game,f.parent);
assert.equal(game.truthtrance?.stage,'ask');
assert.equal(game.truthtrance.queue[0].player,f.owner);
game = finishOriginalTruthQuestions(game);
assert.equal(game.truthtrance,null); assertAuctionParent(game,f.parent);
assert.deepEqual(inventory(game),f.originalCards);
const actor = game.auction!.active, original = structuredClone(game.auction!);
game = act(game,actor,{type:'passBid'});
assert.ok(game.auction); assert.equal(game.auction.index,original.index);
assert.equal(game.auction.cards[original.index].id,original.cards[original.index].id);
assert.equal(game.auction.bid,original.bid); assert.equal(game.auction.bidder,original.bidder);
assert.ok(game.auction.passed.includes(actor));
assert.deepEqual(inventory(game),f.originalCards);
},
);
+78
View File
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { createSaphoTruthAuctionFixture as fixture, assertAuctionParent, act, inventory, player } from './fixture-sapho-truth-auction';
import { truthOrderFrame as frame, passTruthOrder as pass, playSaphoTruthOrder as play,
finishTruthOrder as finish, finishOriginalTruthQuestions as answer, passFreshDiscard,
rejectSaphoTruthOrder as reject, ownOrderChoices as choices } from './fixture-sapho-truth-order';
for (const advanced of [false,true]) for (const standingBid of [false,true]) for (const mode of ['first','last'] as const) {
const label = `${advanced ? 'Advanced' : 'Basic'} ${standingBid ? 'standing bid' : 'unbid'} ${mode}`;
void test(`${label} Sapho orders the original Truth questions without altering the ordinary auction`, () => {
const f = fixture({advanced,standingBid,mode}), state = f.game, current = frame(state);
assertAuctionParent(state,f.parent);
assert.ok(choices(state,f.owner).some(row => row.card === f.card && row.mode === mode));
const noOp = mode === 'first' ? 'last' : 'first';
reject(state,f.owner,{type:'saphoTruthOrder',event:current.event,revision:current.revision,card:f.card,mode:noOp});
reject(state,f.owner,{type:'saphoTruthOrder',event:'stale-auction-order',revision:current.revision,card:f.card,mode});
reject(state,f.owner,{type:'saphoTruthOrder',event:current.event,revision:current.revision+1,card:f.card,mode});
assertAuctionParent(state,f.parent);
let game = play(state,f.owner,f.card,mode);
assertAuctionParent(game,f.parent);
game = passFreshDiscard(game);
assertAuctionParent(game,f.parent);
assert.ok(frame(game).passed.includes(f.owner));
game = finish(game);
assert.equal(mode === 'first' ? game.truthtrance!.queue[0].player : game.truthtrance!.queue.at(-1)!.player,f.owner);
assertAuctionParent(game,f.parent);
game = answer(game);
assert.equal(game.truthtrance,null); assertAuctionParent(game,f.parent);
assert.deepEqual(inventory(game),f.originalCards);
// Continue the real original bidder: a normal pass cannot recreate the lot,
// discard its actual card, replay the standing bid or spend the invoice.
const actor = game.auction!.active, previous = structuredClone(game.auction!);
game = act(game,actor,{type:'passBid'});
assert.ok(game.auction); assert.equal(game.auction.index,previous.index);
assert.equal(game.auction.cards[previous.index].id,previous.cards[previous.index].id);
assert.equal(game.auction.bid,previous.bid); assert.equal(game.auction.bidder,previous.bidder);
assert.ok(game.auction.passed.includes(actor));
assert.deepEqual(inventory(game),f.originalCards);
});
}
for (const claim of [false,true]) void test(
`Basic ordinary standing bid survives a Sapho Truth-order Semuta ${claim ? 'claim' : 'pass'}`, () => {
const f = fixture({advanced:false,standingBid:true,mode:'first'}), state = structuredClone(f.game);
// Controlled conserved physical supplement: swap the actual cached Semuta
// with an existing Atreides hand card. The public game, funded standing
// invoice, original lot and previously queued Truths are not manufactured.
const claimant = player(state,'atreides'), cache = state.richeseCache;
assert.ok(cache); const semutaAt = cache.findIndex(card => card.effect === 'semutaDrug'); assert.ok(semutaAt >= 0);
assert.ok(claimant.hand.length > 0);
const [semuta] = cache.splice(semutaAt,1), [exchange] = claimant.hand.splice(0,1);
claimant.hand.push(semuta); cache.push(exchange);
assertAuctionParent(state,f.parent); assert.deepEqual(inventory(state),f.originalCards);
// The other holder passes first, so the Sapho use finishes the public
// ordering but its real fresh-discard Semuta window must settle first.
let game = pass(state,f.other);
assertAuctionParent(game,f.parent);
game = play(game,f.owner,f.card,'first');
assert.ok(game.pendingTreacheryDiscard?.reaction);
assert.equal(game.pendingTreacheryDiscard.batch.entries[0].card.id,f.card);
assert.equal(viewGame(game,'atreides').semutaReaction?.canCommit,true);
assertAuctionParent(game,f.parent);
const event = game.pendingTreacheryDiscard.batch.event;
if (claim) game = act(game,'atreides',{type:'semutaCommit',event});
else game = passFreshDiscard(game);
assert.equal(game.pendingTreacheryDiscard,null); assert.equal(game.truthtrance?.stage,'ask');
assert.equal(game.truthtrance?.queue[0].player,f.owner); assertAuctionParent(game,f.parent);
assert.equal(player(game,'atreides').hand.some(card => card.id === f.card),claim);
assert.equal(game.discard.some(card => card.id === semuta.id),claim);
game = answer(game); assert.equal(game.truthtrance,null); assertAuctionParent(game,f.parent);
assert.deepEqual(inventory(game),f.originalCards);
const actor = game.auction!.active;
game = act(game,actor,{type:'passBid'});
assert.equal(game.auction?.bid,1); assert.equal(game.auction?.bidder,f.parent.auction!.bidder);
assert.deepEqual(inventory(game),f.originalCards);
},
);
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { act, inventory, player, createSaphoTruthOrderFixture,
passFreshDiscard, truthOrderFrame, finishTruthOrder, finishOriginalTruthQuestions } from './fixture-sapho-truth-order';
/** Declare one original Truth, allowing every other player to pass the original
* neutral priority. The caller chooses the actual question afterwards. */
function declare(game: Game, actor: string, card: string) {
game = act(game,actor,{type:'card',card});
while (game.truthtrance?.stage === 'priority') {
const seat = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(seat);
game = act(game,seat.id,{type:'truthPass'});
}
assert.equal(game.truthtrance?.stage,'ask');
return game;
}
void test('Sapho Truth-order disposal cannot consume the last card needed by an earlier Distrans Yes', () => {
const f = createSaphoTruthOrderFixture({advanced:true,mode:'first'});
let game = structuredClone(f.before);
const owner = player(game,f.owner), cache = game.richeseCache;
assert.ok(cache);
// Controlled, conserved custody supplement on top of the original public
// setup and earned Sapho: give the owner the physical cached Distrans, and
// put any other unneeded held cards in the original deck. The owner retains
// exactly their real Sapho, original dealt Truth and Distrans. No resources,
// phases, promises, auction records or extra cards are manufactured.
const at = cache.findIndex(card => card.effect === 'distrans'); assert.ok(at >= 0);
const [distrans] = cache.splice(at,1);
const retained = owner.hand.filter(card => card.id === f.card || card.id === f.truth);
const returned = owner.hand.filter(card => card.id !== f.card && card.id !== f.truth);
owner.hand = [...retained,distrans]; game.deck.push(...returned);
assert.equal(owner.hand.length,3);
assert.deepEqual(inventory(game),f.originalCards);
// Ask the earlier obligation through the real Truthtrance engine. At this
// moment the owner's other physical Truth card can legally be transferred;
// Sapho is another real alternative, so a Yes has a native completion.
game = declare(game,f.other,f.otherTruth);
game = act(game,f.other,{type:'truthAsk',question:{kind:'turnActions',target:f.owner,turn:game.turn,
mixed:{kind:'action',action:{kind:'cardPlay',effect:'distrans'}}}});
assert.ok(viewGame(game,f.owner).truthTurnActionAnswers?.includes('yes'));
game = act(game,f.owner,{type:'truthAnswer',answer:'yes'});
game = passFreshDiscard(game);
assert.ok(game.turnActionPromises?.some(row => row.player === f.owner && row.answer && !row.fulfilled && !row.released));
assert.deepEqual(inventory(game),f.originalCards);
const quote = viewGame(game,f.owner).distrans;
assert.ok(quote && !quote.blocked);
const recipient = quote.choices.find(row => !row.blocked && row.cards.some(card => card.id === f.card));
assert.ok(recipient,'The original physical Sapho is a legal Distrans transfer');
let completed = act(structuredClone(game),f.owner,{type:'card',card:distrans.id,
target:recipient.recipient,give:f.card});
completed = passFreshDiscard(completed);
assert.ok(completed.turnActionPromises?.some(row => row.player === f.owner && row.fulfilled),
'The earlier Yes has an actual legal physical transfer route before reordering');
assert.deepEqual(inventory(completed),f.originalCards);
// The previously played original Truth is moved from the discard back to
// its original holder solely to construct two simultaneous questions.
// This is a labeled controlled card-custody setup, not a claimed native
// recovery; physical inventory and the already-bound answer are preserved.
const atDiscard = game.discard.findIndex(card => card.id === f.otherTruth); assert.ok(atDiscard >= 0);
const [priorTruth] = game.discard.splice(atDiscard,1);
player(game,f.other).hand.push(priorTruth);
assert.deepEqual(inventory(game),f.originalCards);
// Both original Truth cards now become committed physical questions. The
// owner's Truth is reserved, leaving Sapho as the only card Distrans can
// transfer after this window. Reordering by discarding Sapho must not erase
// the earlier Yes or strand the question queue.
game = act(game,f.owner,{type:'card',card:f.truth});
game = act(game,f.other,{type:'card',card:f.otherTruth});
while (game.truthtrance?.stage === 'priority') {
const seat = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(seat);
game = act(game,seat.id,{type:'truthPass'});
}
const frame = truthOrderFrame(game), prior = structuredClone(game.turnActionPromises);
const choices = viewGame(game,f.owner).saphoTruthOrderChoices;
assert.ok(!choices.some(row => row.card === f.card && row.mode === 'first'),
'A displayed choice must retain a completion of the earlier Yes');
const original = structuredClone(game);
assert.throws(() => act(game,f.owner,{type:'saphoTruthOrder',event:frame.event,revision:frame.revision,
card:f.card,mode:'first'}));
assert.deepEqual(game,original,'Rejected action must not mutate the original game');
assert.deepEqual(game.turnActionPromises,prior);
assert.ok(player(game,f.owner).hand.some(card => card.id === f.card));
assert.deepEqual(inventory(game),f.originalCards);
// Both holders can decline to reorder; resolve both actual queued questions
// and then complete the preserved earlier promise by transferring Sapho.
game = finishOriginalTruthQuestions(finishTruthOrder(game));
assert.equal(game.truthtrance,null);
assert.ok(game.turnActionPromises?.some(row => row.player === f.owner && row.answer && !row.fulfilled && !row.released));
const laterQuote = viewGame(game,f.owner).distrans;
assert.ok(laterQuote && !laterQuote.blocked);
const laterRecipient = laterQuote.choices.find(row => !row.blocked && row.cards.some(card => card.id === f.card));
assert.ok(laterRecipient,'The preserved Sapho remains a real legal Distrans transfer');
game = act(game,f.owner,{type:'card',card:distrans.id,target:laterRecipient.recipient,give:f.card});
game = passFreshDiscard(game);
assert.ok(game.turnActionPromises?.some(row => row.player === f.owner && row.fulfilled && !row.released));
assert.ok(player(game,laterRecipient.recipient).hand.some(card => card.id === f.card));
assert.deepEqual(inventory(game),f.originalCards);
});
+6
View File
@@ -1337,6 +1337,12 @@ void test('a queued definite Truthtrance answer offers its card before the next
}
const queue = declared.truthtrance!.queue.map(entry => entry.player);
assert.equal(queue.length, 2);
// The possession-independent Sapho window now precedes the first question.
while (declared.truthtrance?.stage === 'order') {
const frame: NonNullable<NonNullable<Game['truthtrance']>['order']> = declared.truthtrance.order!;
const holder = frame.holders.find(id => !frame.passed.includes(id))!;
declared = applyAction(declared, holder, { type: 'truthOrderPass', event: frame.event, revision: frame.revision });
}
const asked = applyAction(declared, queue[0], { type: 'truthAsk',
question: { kind: 'fact', target: 'e', fact: { kind: 'hand', name: 'Shield' } },
});