Preserve Sapho Truth order during ordinary auctions
This commit is contained in:
1 parent
298c8245a6
commit
8e16542784
13 files changed
+534
-12
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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' } },
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user