Bind printed transfer and order plays to Truthtrance

This commit is contained in:
Quintine committed 2026-10-27 08:00:08 +11:00
1 parent 3e959504ef
commit 4c4bebe85e
25 files changed
+1245 -57

No files matched your search

+13 -8
View File
@@ -4,7 +4,7 @@ import { handLimit, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
actDistransPrebid, buyDistransPrebidLot, createDistransPrebidFixture,
actDistransPrebid, advanceDistransPrebid, buyDistransPrebidLot, createDistransPrebidFixture,
distransPrebidClean, distransPrebidInventory, distransPrebidPlayer as player,
distransPrebidPosition, distransPrebidUntouched, finishDistransPrebidLot,
playDistransPrebid, type DistransPrebidFixture, type DistransPrebidStep,
@@ -51,7 +51,7 @@ function source(fixture: DistransPrebidFixture) {
player(purchase.before, purchase.winner).spice - 1);
}
}
function transferred(f: DistransPrebidFixture, before: Game, after: Game) {
function transferred(f: DistransPrebidFixture, before: Game, after: Game, actions: DistransPrebidStep[] = []) {
assert.deepEqual(distransPrebidPosition(after), distransPrebidPosition(before),
'Transfer retains the original physical pool, index, opener, active owner, order and every wallet');
assert.deepEqual(player(after, f.holder).hand,
@@ -63,7 +63,13 @@ function transferred(f: DistransPrebidFixture, before: Game, after: Game) {
assert.equal(player(after, f.recipient).hand.length, player(before, f.recipient).hand.length + 1);
assert.equal(after.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(distransPrebidInventory(after), f.originalInventory);
const from = actions.length;
after = advanceDistransPrebid(after, distransPrebidClean, actions);
assert.ok(actions.slice(from).every(step => step.action.type === 'semutaPass'),
'Complete only the actual neutral fresh-discard controls before resuming this lot');
assert.deepEqual(distransPrebidPosition(after), distransPrebidPosition(before));
assert.ok(distransPrebidUntouched(after), 'The transfer does not count as the first bid or pass');
return after;
}
function completeBid(game: Game, bidder: string, actions: DistransPrebidStep[] = []) {
assert.ok(game.auction);
@@ -89,7 +95,7 @@ for (const advanced of [false, true]) {
const before = structuredClone(f.game), actions: DistransPrebidStep[] = [];
let game = playDistransPrebid(f, f.game, actions);
assert.deepEqual(f.game, before);
transferred(f, before, game);
game = transferred(f, before, game, actions);
rejected(game, f.holder, f.action);
const bidder = game.auction!.active;
game = actDistransPrebid(game, bidder, { type: 'bid', amount: 1, allyPayment: 0 }, actions);
@@ -124,7 +130,7 @@ for (const advanced of [false, true]) {
assert.equal(viewGame(game, f.holder).distrans!.blocked, null);
const before = structuredClone(game);
game = playDistransPrebid(f, game);
transferred(f, before, game);
game = transferred(f, before, game);
rejected(game, f.holder, f.action);
assert.deepEqual(distransPrebidInventory(game), f.originalInventory);
});
@@ -138,7 +144,7 @@ for (const advanced of [false, true]) {
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
const before = structuredClone(f.game);
let game = playDistransPrebid(f);
transferred(f, before, game);
game = transferred(f, before, game);
assert.equal(player(game, f.holder).hand.length, 2);
const purchased = buyDistransPrebidLot(game, f.holder);
game = purchased.game;
@@ -154,8 +160,7 @@ for (const advanced of [false, true]) {
assert.equal(f.game.auction!.index, 2);
assert.equal(f.game.auction!.active, f.recipient);
const before = structuredClone(f.game);
const filled = playDistransPrebid(f);
transferred(f, before, filled);
const filled = transferred(f, before, playDistransPrebid(f));
assert.equal(filled.auction!.active, f.recipient);
assert.equal(filled.active, f.recipient);
assert.equal(player(filled, f.recipient).hand.length, handLimit(player(filled, f.recipient)));
@@ -206,7 +211,7 @@ for (const advanced of [false, true]) {
rejected(f.game, f.holder, { ...f.action, give: f.card });
rejected(f.game, f.holder, { ...f.action, target: 'absent-seat' });
rejected(f.game, f.holder, { ...f.action, give: player(f.game, f.recipient).hand[0].id });
const game = playDistransPrebid(f);
const game = transferred(f, f.game, playDistransPrebid(f));
rejected(game, f.holder, f.action);
assert.deepEqual(distransPrebidInventory(game), f.originalInventory);
});
+8 -2
View File
@@ -8,7 +8,8 @@ import { nextSemutaGuildIncomeNativeStep } from './fixture-semuta-guild-income';
export const DISTRANS_PREBID_CARD = 'richese-distrans';
export type DistransPrebidStep = { actor: string; action: Action };
export type DistransPrebidCapacity = 'ordinary' | 'donorFull' | 'recipientLastSlot' | 'recipientFull';
export type DistransPrebidOptions = { advanced?: boolean; capacity?: DistransPrebidCapacity };
export type DistransPrebidOptions = { advanced?: boolean; capacity?: DistransPrebidCapacity;
truthFor?: readonly ('r' | 'a' | 'e')[] };
export function distransPrebidPlayer(game: Game, id: string) {
const player = game.players.find(player => player.id === id);
@@ -128,10 +129,14 @@ export function createDistransPrebidFixture(options: DistransPrebidOptions = {})
const setup = structuredClone(game);
assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => player.hand.length === 0));
const truthFor = options.truthFor ?? [];
assert.ok(truthFor.length <= 2 && new Set(truthFor).size === truthFor.length,
'Select at most the two original unopened Truthtrance cards');
const startingDealSelection: { actor: string; position: number; from: number; card: string }[] = [];
for (const [actor, kind] of [['r', 'projectile'], ['g', 'shield'], ['a', 'poison'], ['e', 'snooper']] as const) {
const position = game.players.findIndex(player => player.id === actor);
const from = game.deck.findIndex((card, index) => index >= position && card.kind === kind);
const truth = truthFor.some(id => id === actor);
const from = game.deck.findIndex((card, index) => index >= position && (truth ? card.effect === 'truthtrance' : card.kind === kind));
assert.ok(from >= position, `The original ${actor} starting card remains unopened`);
const card = game.deck[from].id;
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
@@ -182,6 +187,7 @@ export function createDistransPrebidFixture(options: DistransPrebidOptions = {})
const give = startingDealSelection.find(row => row.actor === 'g')!.card;
const action: Action = { type: 'card', card: DISTRANS_PREBID_CARD, target: recipient, give };
return { game, advanced, capacity, holder: 'g', donor: 'g', recipient, card: DISTRANS_PREBID_CARD, give, action,
startingTruthSelection: startingDealSelection.filter(row => truthFor.some(id => id === row.actor)),
initial, setup, beforeStartingDeal, startingDealSelection, afterSetup, beforeStorm,
acquisition, acquired, firstLot, purchases, originalInventory, actions, sourceActionCount: actions.length,
staging: ['Only unopened starting Treachery/Spice positions and scalar lotteries are selected. All cache/ordinary purchases, hands, prices, clocks and continuations are native public actions.'] };
+22 -2
View File
@@ -18,6 +18,16 @@ export function actSaphoLate(game: Game, actor: string, action: Action, actions:
actions.push({ actor, action: structuredClone(action) }); return next;
}
export function nextSaphoLateStep(game: Game, stone = false): SaphoLateStep | null {
const pending = game.pendingTreacheryDiscard;
if (pending) {
const reaction = pending.reaction;
if (reaction?.stage === 'offer') {
const actor = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(actor);
return { actor: actor.id, action: { type: 'semutaPass', event: pending.batch.event } };
}
if (pending.reaction) return null; // A committed private choice needs its owner.
return { actor: game.players[0].id, action: { type: 'advanceBots' } };
}
if (!game.response && !game.phaseOpening && game.decision) {
const decision = game.decision;
if (decision.kind === 'stoneAllocation' || decision.kind === 'battleLosses') return null;
@@ -48,6 +58,11 @@ export function advanceSaphoLate(game: Game, until: (state: Game) => boolean,
}
throw Error('The original late Sapho programme did not reach its finite boundary');
}
/** Resolve the actual fresh cost without acknowledging or advancing the
* original battle opportunity that resumes after its neutral Semuta passes. */
export function settleSaphoLateDiscard(game: Game, actions: SaphoLateStep[] = []): Game {
return advanceSaphoLate(game, state => !state.pendingTreacheryDiscard, actions);
}
/** Original public host, paid cache Sapho and paid opposing Arrakeen forces.
* Stone adds a genuine first-turn paid Stone and next-turn paid Sapho, with
@@ -138,7 +153,7 @@ export function prepareSaphoLateOutcome(fixture: SaphoLateAggressorFixture, opti
} = {}) {
const useAt = options.useAt ?? 'none';
let game = structuredClone(fixture.game), used: Action | null = null, afterUse: Game | null = null;
let beforeUse: Game | null = null, acknowledged: Game | null = null;
let beforeUse: Game | null = null, afterDiscard: Game | null = null, acknowledged: Game | null = null;
const actions: SaphoLateStep[] = [];
const act = (actor: string, action: Action) => { game = actSaphoLate(game, actor, action, actions); };
act(fixture.plans[0].actor, fixture.plans[0].action);
@@ -146,6 +161,7 @@ export function prepareSaphoLateOutcome(fixture: SaphoLateAggressorFixture, opti
if (useAt === 'onePlan') {
beforeUse = structuredClone(game); used = saphoLateAggressorAction(game, fixture.owner);
act(fixture.owner, used); afterUse = structuredClone(game);
game = settleSaphoLateDiscard(game, actions); afterDiscard = structuredClone(game);
}
act(fixture.plans[1].actor, fixture.plans[1].action);
const revealed = structuredClone(game);
@@ -157,8 +173,9 @@ export function prepareSaphoLateOutcome(fixture: SaphoLateAggressorFixture, opti
}
beforeUse = structuredClone(game); used = saphoLateAggressorAction(game, fixture.owner);
act(fixture.owner, used); afterUse = structuredClone(game);
game = settleSaphoLateDiscard(game, actions); afterDiscard = structuredClone(game);
}
return { game, onePlan, revealed, opened, beforeUse, used, afterUse, acknowledged, useAt, actions };
return { game, onePlan, revealed, opened, beforeUse, used, afterUse, afterDiscard, acknowledged, useAt, actions };
}
/** Readies and later Stone/typed losses are chosen solely from actual owned
@@ -171,6 +188,9 @@ export function completeSaphoLateAggressor(fixture: SaphoLateAggressorFixture,
}[] = [];
for (let count = 0; count < 700; count++) {
if (!game.battle && saphoLateClean(game)) return { game, actions, readies, allocations };
if (game.pendingTreacheryDiscard) {
game = settleSaphoLateDiscard(game, actions); continue;
}
const decision = !game.response && !game.phaseOpening ? game.decision : null;
const window = game.battle?.outcomeWindow;
let step: SaphoLateStep | null;
+16 -2
View File
@@ -40,7 +40,8 @@ export function advanceSaphoStartedMovement(game: Game, until: (state: Game) =>
/** Original public Basic or Advanced setup, actual cache1 purchase and positive
* shipment. Only unopened original deal/Spice ordering and original scalar
* lotteries are selected; no played custody, resources, phase or queue is set. */
export function createSaphoStartedMovementFixture(options: { advanced?: boolean; guildTake?: boolean } = {}) {
export function createSaphoStartedMovementFixture(options: { advanced?: boolean; guildTake?: boolean;
truthFor?: readonly ('richese' | 'guild')[] } = {}) {
const advanced = options.advanced ?? false, guildTake = options.guildTake ?? false;
assert.ok(!guildTake || advanced, 'Flexible Guild TAKE is an original Advanced rule');
const owner = 'emperor', guild = 'guild', canceler = 'atreides';
@@ -59,10 +60,23 @@ export function createSaphoStartedMovementFixture(options: { advanced?: boolean;
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const counter = game.deck[index].id;
[game.deck[1], game.deck[index]] = [game.deck[index], game.deck[1]];
const truthFor = options.truthFor ?? [];
assert.ok(truthFor.length <= 2 && new Set(truthFor).size === truthFor.length,
'Select at most the two original unopened Truthtrance cards');
const startingTruthSelection = truthFor.map(actor => {
const position = game.players.findIndex(player => player.id === actor);
const from = game.deck.findIndex((card, index) => index >= position && card.effect === 'truthtrance');
assert.ok(position >= 0 && from >= position, 'The selected original Truth must remain in the unopened starting deal');
const card = game.deck[from].id;
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { actor, position, from, card };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
assert.ok(saphoStartedMovementPlayer(game, canceler).hand.some(card => card.id === counter));
for (const row of startingTruthSelection)
assert.ok(saphoStartedMovementPlayer(game, row.actor).hand.some(card => card.id === row.card));
const spiceInventory = game.spiceDeck.map(card => JSON.stringify(card)).sort();
for (let count = 0; count < (advanced ? 2 : 1); count++) {
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
@@ -114,7 +128,7 @@ export function createSaphoStartedMovementFixture(options: { advanced?: boolean;
const source = structuredClone(game);
return { game, source, advanced, guildTake, owner, guild, canceler, counter, karama: counter,
card: STARTED_MOVEMENT_SAPHO, current, actor: current, beforeShipment, shipment,
lobby, setup, beforeStartingDeal, afterSetup, acquired, acquisition, timing,
lobby, setup, beforeStartingDeal, startingTruthSelection, afterSetup, acquired, acquisition, timing,
originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Actual public ready/start (Advanced uses the original preview acknowledgement), original setup and paid cache1 purchase give Emperor the physical Sapho. Only an unopened original Atreides Karama deal position, one Basic/two Advanced original land Spice positions and scoped scalar lotteries are selected.',
+254
View File
@@ -0,0 +1,254 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
import { buyDistransPrebidLot, createDistransPrebidFixture, distransPrebidInventory, distransPrebidUntouched, nextDistransPrebidStep,
type DistransPrebidCapacity } from './fixture-distrans-prebid';
import { actSaphoStartedMovement, createSaphoStartedMovementFixture, nextSaphoStartedMovementStep } from './fixture-sapho-started-movement';
import { truthTurnThumperSpice, truthTurnThumperTraitors } from './fixture-truth-turn-thumper';
export type TruthTransferOrderEffect = 'distrans' | 'juiceOfSapho';
export type TruthTransferOrderLeaf = { kind: 'cardPlay'; effect: TruthTransferOrderEffect } |
{ kind: 'payment'; recipient: string; minimum: number } | { kind: 'ship'; territory: string; minimum: number };
export type TruthTransferOrderExpression = MixedActionExpression<TruthTransferOrderLeaf>;
export type TruthTransferOrderStep = { actor: string; action: Action };
export const actTruthTransferOrderPlay = actSaphoStartedMovement;
export const truthTransferOrderPlayInventory = distransPrebidInventory;
export const truthTransferOrderPlaySpice = truthTurnThumperSpice;
export const truthTransferOrderPlayTraitors = truthTurnThumperTraitors;
export const truthTransferOrderPlayPlayer = (game: Game, actor: string) => {
const player = game.players.find(candidate => candidate.id === actor); assert.ok(player); return player;
};
export const truthTransferOrderPlayClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingNullentropy && !game.pendingShipment && !game.currentAuctionSale;
export const truthTransferOrderPlayUse = (effect: TruthTransferOrderEffect): TruthTransferOrderExpression =>
({ kind: 'action', action: { kind: 'cardPlay', effect } });
export const truthTransferOrderPlayFact = (fact: TruthFact): TruthTransferOrderExpression => ({ kind: 'fact', fact });
export const truthTransferOrderPlayPayment = (recipient: string, minimum = 2): TruthTransferOrderExpression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
export const truthTransferOrderPlayShip = (territory = 'polar_sink', minimum = 1): TruthTransferOrderExpression =>
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
export const truthTransferOrderPlayGroup = (kind: 'and' | 'or', ...terms: TruthTransferOrderExpression[]): TruthTransferOrderExpression =>
({ kind, terms });
export function nextTruthTransferOrderPlayNative(game: Game): TruthTransferOrderStep | null {
if (game.truthtrance || game.pendingNullentropy) return null;
return game.players.some(player => player.id === 'r') ? nextDistransPrebidStep(game) : nextSaphoStartedMovementStep(game);
}
export function nextTruthTransferOrderPlay(game: Game): TruthTransferOrderStep | null {
if (game.truthtrance || game.pendingNullentropy) return null;
for (const player of game.players) {
const guide = viewGame(game, player.id).turnActionGuide;
if (guide) return { actor: player.id, action: guide.action };
}
return nextTruthTransferOrderPlayNative(game);
}
export function advanceTruthTransferOrderPlay(game: Game, until: (state: Game) => boolean,
actions: TruthTransferOrderStep[] = [], nativeOnly = false): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original transfer/order source ended before its requested boundary');
const step = nativeOnly ? nextTruthTransferOrderPlayNative(game) : nextTruthTransferOrderPlay(game);
assert.ok(step, 'Supply the original Truthtrance, paid private selection or sealed native plan');
game = actTruthTransferOrderPlay(game, step.actor, step.action, actions);
}
throw Error('The original transfer/order source did not reach its finite boundary');
}
export function settleTruthTransferOrderPlay(game: Game, actions: TruthTransferOrderStep[] = []): Game {
return advanceTruthTransferOrderPlay(game, truthTransferOrderPlayClean, actions, true);
}
export function finishTruthTransferOrderPlay(game: Game, actions: TruthTransferOrderStep[] = []): Game {
const turn = game.turn;
return advanceTruthTransferOrderPlay(game, state => state.turn === turn + 1, actions, true);
}
/** Real public cache purchases plus original unopened Truth deals. The card
* question does not select a transfer recipient/card or an order option. */
export function createTruthTransferOrderPlayFixture(options: {
source?: 'distrans' | 'sapho'; advanced?: boolean; capacity?: DistransPrebidCapacity; guildTake?: boolean; recoveredDistrans?: boolean;
} = {}) {
const sourceKind = options.source ?? 'distrans', advanced = options.advanced ?? false;
const distrans = sourceKind === 'distrans' ? createDistransPrebidFixture({ advanced, capacity: options.capacity, truthFor: ['r', 'a'] }) : null;
const sapho = sourceKind === 'sapho' ? createSaphoStartedMovementFixture({ advanced, guildTake: options.guildTake,
truthFor: ['richese', 'guild'] }) : null;
const base = distrans ?? sapho!;
let game = structuredClone(base.game); const actions = structuredClone(base.actions);
const target = distrans ? distrans.holder : sapho!.owner, asker = distrans ? 'a' : 'richese', recipient = asker;
const selected = base.startingTruthSelection;
const ordered = [...selected].sort((left, right) => Number(right.actor === asker) - Number(left.actor === asker));
const truths = ordered.map(row => row.card), truthHolders = ordered.map(row => row.actor);
assert.equal(truths.length, 2); assert.equal(truthHolders[0], asker);
const effect: TruthTransferOrderEffect = distrans ? 'distrans' : 'juiceOfSapho', card = base.card;
const originalCards = truthTransferOrderPlayInventory(base.setup), originalSpice = truthTransferOrderPlaySpice(base.setup);
const originalTraitors = truthTransferOrderPlayTraitors(base.setup);
let recovery: { before: Game; firstPlay: TruthTransferOrderStep; box: string; acquisition: TruthTransferOrderStep[];
beforeRecovery: Game; paid: Game; afterRecovery: Game; outgoingPurchase: ReturnType<typeof buyDistransPrebidLot>['purchase']; after: Game } | null = null;
if (options.recoveredDistrans) {
assert.ok(distrans && distrans.capacity === 'ordinary', 'Use the original ordinary Distrans source for this one real replay');
const before = structuredClone(game), firstPlay: TruthTransferOrderStep = { actor: target, action: distrans.action };
game = settleTruthTransferOrderPlay(actTruthTransferOrderPlay(game, target, firstPlay.action, actions), actions);
const turn = game.turn;
game = advanceTruthTransferOrderPlay(game, state => state.turn === turn + 1 && !state.phaseOpening && !state.response &&
state.decision?.kind === 'richeseCache', actions, true);
const box = game.richeseCache?.find(held => held.effect === 'nullentropyBox'); assert.ok(box);
const from = actions.length;
game = actTruthTransferOrderPlay(game, game.decision!.player, { type: 'decision', event: game.richeseBidding!.event,
card: box.id, method: 'onceAround', direction: 'counterclockwise' }, actions);
for (let count = 0; count < 200 && !(truthTransferOrderPlayPlayer(game, target).hand.some(held => held.id === box.id) && distransPrebidUntouched(game)); count++) {
const lot = game.richeseAuction;
const step = truthTransferOrderPlayClean(game) && lot?.cardId === box.id && !lot.outcome && lot.active === target && lot.bid === 0
? { actor: target, action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } satisfies Action }
: nextTruthTransferOrderPlayNative(game);
assert.ok(step); game = actTruthTransferOrderPlay(game, step.actor, step.action, actions);
}
assert.ok(distransPrebidUntouched(game)); assert.ok(truthTransferOrderPlayPlayer(game, target).hand.some(held => held.id === box.id));
const acquisition = structuredClone(actions.slice(from)), beforeRecovery = structuredClone(game);
assert.deepEqual(game.discard.map(held => held.id), [card]);
game = actTruthTransferOrderPlay(game, target, { type: 'card', card: box.id }, actions); const paid = structuredClone(game);
game = settleTruthTransferOrderPlay(game, actions); const afterRecovery = structuredClone(game);
assert.ok(truthTransferOrderPlayPlayer(game, target).hand.some(held => held.id === card));
assert.equal(truthTransferOrderPlayPlayer(beforeRecovery, target).spice - truthTransferOrderPlayPlayer(game, target).spice, 2);
const bought = buyDistransPrebidLot(game, target, actions); game = bought.game;
recovery = { before, firstPlay, box: box.id, acquisition, beforeRecovery, paid, afterRecovery,
outgoingPurchase: bought.purchase, after: structuredClone(game) };
}
for (const [index, truth] of truths.entries()) assert.ok(truthTransferOrderPlayPlayer(game, truthHolders[index]).hand.some(held => held.id === truth));
assert.ok(truthTransferOrderPlayPlayer(game, target).hand.some(held => held.id === card));
assert.ok(truthTransferOrderPlayClean(game)); assert.equal(!!game.semutaPreview, !advanced);
assert.equal(game.turnActionPromises?.length ?? 0, 0); assert.deepEqual(truthTransferOrderPlayInventory(game), originalCards);
assert.deepEqual(truthTransferOrderPlaySpice(game), originalSpice); assert.deepEqual(truthTransferOrderPlayTraitors(game), originalTraitors);
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTransferOrderPlayPlayer(game, target).spice };
const mixed = truthTransferOrderPlayGroup('and', truthTransferOrderPlayUse(effect), truthTransferOrderPlayPayment(recipient));
const question: TruthQuestion = { kind: 'turnActions', target, turn: game.turn, mixed };
return { game, source: structuredClone(game), sourceKind, effect, advanced, base, distrans, sapho,
target, asker, recipient, truths, truthHolders, truth: truths[0], card, spiceFact, mixed, question,
give: recovery?.outgoingPurchase.card ?? distrans?.give ?? null, current: sapho?.current ?? null, recovery,
setup: base.setup, afterSetup: base.afterSetup, beforeStartingDeal: base.beforeStartingDeal,
originalCards, originalSpice, originalTraitors,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { inherited: base.actions.length, nativeContinuation: actions.length - base.actions.length, selected: actions.length,
nativeCacheBids: actions.filter(step => step.actor === target && step.action.type === 'richeseBid' && step.action.amount === 1).length },
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
shipment: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
sourceDisclosure: base.staging.join(' ') + ' Optional original unopened starting deals supply the two physical Truths; ' +
'default base-fixture deals remain unchanged when those selectors are absent. Basic keeps its original Semuta marker and real discard acknowledgements. ' +
(distrans ? 'Guild actually purchases Distrans for1 and retains its original other card to transfer; no recipient consent or hidden recipient card is supplied. '
: 'Emperor actually purchases Sapho for1; the current foreign actor has already paid for one physical Polar Sink shipment and keeps its original ground move/end. ') +
(recovery ? 'The optional single replay source actually transfers/discards Distrans, buys the remaining original Box cache1 on turn2, pays its2-spice sole-card recovery, then buys one ordinary card1 as its new outgoing card. Both original Truths remain held until the later question. ' : '') +
'The future printed-play clause names no recipient, outgoing card, mode or order result. Only actual native accepted use counts. ' +
'No dealt hand, purse, army, phase, queue, receipt or marker is installed. Counts and inventories derive from actual arrays.',
};
}
export type TruthTransferOrderPlayFixture = ReturnType<typeof createTruthTransferOrderPlayFixture>;
export function declareTruthTransferOrderPlay(f: TruthTransferOrderPlayFixture, source = f.game, copy: 0 | 1 = 0,
actions: TruthTransferOrderStep[] = []) {
const asker = f.truthHolders[copy]; let game = actTruthTransferOrderPlay(source, asker, { type: 'card', card: f.truths[copy] }, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthTransferOrderPlay(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask'); return { game, asker, actions };
}
export function askTruthTransferOrderPlay(game: Game, asker: string, question: TruthQuestion, actions: TruthTransferOrderStep[] = []): Game {
return actTruthTransferOrderPlay(game, asker, { type: 'truthAsk', question: structuredClone(question) }, actions);
}
export function answerTruthTransferOrderPlay(game: Game, target: string, answer: 'yes' | 'no', actions: TruthTransferOrderStep[] = []): Game {
return actTruthTransferOrderPlay(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthTransferOrderPlay(f: TruthTransferOrderPlayFixture, answer: 'yes' | 'no' = 'yes', question: TruthQuestion = f.question,
source = f.game, copy: 0 | 1 = 0, actions: TruthTransferOrderStep[] = []) {
const declared = declareTruthTransferOrderPlay(f, source, copy, actions), asked = askTruthTransferOrderPlay(declared.game, declared.asker, question, actions);
return { game: answerTruthTransferOrderPlay(asked, f.target, answer, actions), declared, asked, actions };
}
export function truthTransferOrderPlayPromise(game: Game, target = game.turnActionPromises?.[0]?.player ?? game.players[0].id) {
const row = viewGame(game, target).turnActionPromises.findLast(promise => promise.player === target); assert.ok(row); return row;
}
export function truthTransferOrderDistransAction(game: Game, f: TruthTransferOrderPlayFixture, recipient = f.recipient, give = f.give): Action {
assert.equal(f.effect, 'distrans'); const quote = viewGame(game, f.target).distrans;
assert.ok(quote && !quote.blocked); const choice = quote.choices.find(choice => choice.recipient === recipient && !choice.blocked);
assert.ok(choice && choice.cards.some(card => card.id === give));
return { type: 'card', card: f.card, target: recipient, give: give! };
}
export function truthTransferOrderSaphoAction(game: Game, f: TruthTransferOrderPlayFixture, mode: 'first' | 'last'): Action {
assert.equal(f.effect, 'juiceOfSapho'); const option = viewGame(game, f.target).saphoOptions.find(option => option.scope === 'movement' && option.mode === mode);
assert.ok(option, 'Use one exact currently legal native Sapho option');
return { type: 'card', card: f.card, scope: option.scope, event: option.event, mode: option.mode };
}
/** One genuine Semuta cost composition. Original Distrans is actually spent
* before turn2; its owner then buys the still-original cached Semuta for1.
* Both unopened-deal Truths remain held for a later normal-lot answer. */
export function createTruthSemutaPrebidFixture(options: { firstBid?: boolean } = {}) {
const f = createTruthTransferOrderPlayFixture(), actions = structuredClone(f.actions), inherited = actions.length;
let game = settleTruthTransferOrderPlay(actTruthTransferOrderPlay(f.game, f.target,
truthTransferOrderDistransAction(f.game, f), actions), actions);
const earlierDistrans = structuredClone(game), turn = game.turn;
game = advanceTruthTransferOrderPlay(game, state => state.turn === turn + 1 && !state.phaseOpening && !state.response &&
state.decision?.kind === 'richeseCache', actions, true);
const semuta = game.richeseCache?.find(card => card.effect === 'semutaDrug'); assert.ok(semuta);
const acquisitionStart = actions.length, beforePurchase = structuredClone(game);
game = actTruthTransferOrderPlay(game, game.decision!.player, { type: 'decision', event: game.richeseBidding!.event,
card: semuta.id, method: 'onceAround', direction: 'counterclockwise' }, actions);
for (let count = 0; count < 200 && !(truthTransferOrderPlayPlayer(game, f.target).hand.some(card => card.id === semuta.id) && distransPrebidUntouched(game)); count++) {
const lot = game.richeseAuction;
const step = truthTransferOrderPlayClean(game) && lot?.cardId === semuta.id && !lot.outcome && lot.active === f.target && lot.bid === 0
? { actor: f.target, action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } satisfies Action }
: nextTruthTransferOrderPlayNative(game);
assert.ok(step); game = actTruthTransferOrderPlay(game, step.actor, step.action, actions);
}
assert.ok(distransPrebidUntouched(game)); assert.ok(truthTransferOrderPlayPlayer(game, f.target).hand.some(card => card.id === semuta.id));
assert.equal(truthTransferOrderPlayPlayer(beforePurchase, f.target).spice - truthTransferOrderPlayPlayer(game, f.target).spice, 1);
const acquisition = { before: beforePurchase, steps: structuredClone(actions.slice(acquisitionStart)), after: structuredClone(game) };
let bid: TruthTransferOrderStep | null = null;
if (options.firstBid) {
bid = { actor: game.auction!.active, action: { type: 'bid', amount: 1, allyPayment: 0 } };
game = actTruthTransferOrderPlay(game, bid.actor, bid.action, actions);
assert.equal(game.auction!.bid, 1); assert.equal(game.auction!.bidder, bid.actor);
}
assert.ok(truthTransferOrderPlayClean(game)); assert.equal(game.semutaPreview, true);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.ok(!truthTransferOrderPlayPlayer(game, f.target).hand.some(card => card.id === f.card));
for (const [index, truth] of f.truths.entries()) assert.ok(truthTransferOrderPlayPlayer(game, f.truthHolders[index]).hand.some(card => card.id === truth));
assert.deepEqual(truthTransferOrderPlayInventory(game), f.originalCards);
assert.deepEqual(truthTransferOrderPlaySpice(game), f.originalSpice); assert.deepEqual(truthTransferOrderPlayTraitors(game), f.originalTraitors);
const mixed = truthTransferOrderPlayUse('distrans');
const question: TruthQuestion = { kind: 'turnActions', target: f.target, turn: game.turn, mixed };
return { ...f, game, source: structuredClone(game), mixed, question, semuta: semuta.id, earlierDistrans, acquisition, bid,
spiceFact: { kind: 'spice', compare: 'eq', value: truthTransferOrderPlayPlayer(game, f.target).spice } satisfies TruthFact,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { inherited, nativeContinuation: actions.length - inherited, selected: actions.length,
nativeCacheBids: actions.filter(step => step.actor === f.target && step.action.type === 'richeseBid' && step.action.amount === 1).length },
sourceDisclosure: f.base.staging.join(' ') + ' Original unopened Richese/Atreides Truth deals remain held. ' +
'Guild actually transfers its original shield with Distrans, completes its native fresh-discard passes, then reaches the next original cache declaration. ' +
'Guild buys the original Semuta for1 and stops at the original normal lot, optionally after one actual first bid1. Distrans remains physically spent. ' +
'The later question concerns only future Distrans use; no future Semuta clause is introduced. No hand, purse, auction, phase, receipt or marker is installed. Counts derive from recorded actions.',
};
}
/** Earlier real Basic boundary, rather than an installed phase or queue.
* Retain only the exact acquisition prefix of the existing native source. */
export function createTruthEarlySaphoFixture() {
const f = createTruthTransferOrderPlayFixture({ source: 'sapho' }); assert.ok(f.sapho);
const bid = f.sapho.acquisition.bid;
const bidAt = f.actions.findIndex(step => step.actor === bid.actor && step.action.type === 'richeseBid' &&
step.action.event === bid.action.event && step.action.amount === bid.action.amount);
assert.ok(bidAt >= 0); const inherited = bidAt + f.sapho.acquisition.steps.length;
assert.deepEqual(f.actions.slice(bidAt, inherited), f.sapho.acquisition.steps);
const actions = structuredClone(f.actions.slice(0, inherited));
let game = structuredClone(f.sapho.acquired);
game = advanceTruthTransferOrderPlay(game, state => state.phase === 4 && truthTransferOrderPlayClean(state), actions, true);
assert.equal(game.advanced, false); assert.equal(game.semutaPreview, true);
assert.ok(truthTransferOrderPlayPlayer(game, f.target).hand.some(card => card.id === f.card));
assert.equal(truthTransferOrderPlayPlayer(game, f.target).shipped, false); assert.equal(truthTransferOrderPlayPlayer(game, f.target).moved, 0);
assert.ok(!actions.some(step => step.action.type === 'ship')); assert.deepEqual(truthTransferOrderPlayInventory(game), f.originalCards);
assert.deepEqual(truthTransferOrderPlaySpice(game), f.originalSpice); assert.deepEqual(truthTransferOrderPlayTraitors(game), f.originalTraitors);
return { ...f, game, source: structuredClone(game), current: null, early: true,
spiceFact: { kind: 'spice', compare: 'eq', value: truthTransferOrderPlayPlayer(game, f.target).spice } satisfies TruthFact,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { inherited, nativeContinuation: actions.length - inherited, selected: actions.length,
nativeCacheBids: actions.filter(step => step.actor === f.target && step.action.type === 'richeseBid' && step.action.amount === 1).length },
sourceDisclosure: f.sapho.staging[0] + ' Select the two original unopened Richese/Guild Truth deals and use the exact actual acquired-Sapho snapshot. ' +
'Its prefix is acquisition bid index plus actual acquisition steps; subsequent native auction controls reach quiet Basic Revival before any shipment. ' +
'The owned future movement opportunity uses its ordinary public order, with no future income, played card, phase or queue installed. Counts derive from the exact prefix and native continuation.',
};
}
+26 -1
View File
@@ -9,7 +9,7 @@ import {
} from '../game/printed-card-play';
void test('Box accepted activation counts extend the shared roles without changing earlier Hajr or Ghola baselines', () => {
assert.deepEqual(PRINTED_CARD_EFFECTS, ['hajr', 'ghola', 'nullentropyBox']);
assert.deepEqual(PRINTED_CARD_EFFECTS, ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho']);
assert.equal(printedCardPlayCount(undefined, 'richese', 3, 'nullentropyBox'), 0);
const ghola = recordPrintedCardPlay(undefined, 'richese', 3, 'ghola');
const first = recordPrintedCardPlay(ghola, 'richese', 3, 'nullentropyBox');
@@ -33,6 +33,31 @@ void test('Box activation counts retain safe overflow and strict per-player role
'richese', 4, 'nullentropyBox'), PrintedCardPlayError);
});
void test('Distrans and Sapho count separate accepted printed uses and replay without recording native choices', () => {
const original = recordPrintedCardPlay(undefined, 'richese', 2, 'nullentropyBox');
let counts = original;
const before = structuredClone(original);
for (const effect of ['distrans', 'juiceOfSapho'] as const) {
assert.equal(printedCardPlayCount(counts, 'richese', 2, effect), 0);
counts = recordPrintedCardPlay(counts, 'richese', 2, effect);
counts = recordPrintedCardPlay(counts, 'richese', 2, effect);
assert.equal(printedCardPlayCount(counts, 'richese', 2, effect), 2);
assert.equal(printedCardPlayCount(counts, 'other', 2, effect), 0);
assert.equal(printedCardPlayCount(counts, 'richese', 3, effect), 0);
const full: PrintedCardPlayCounts = { turn: 2, plays: [{ player: 'richese', effect, count: Number.MAX_SAFE_INTEGER }] };
assert.throws(() => recordPrintedCardPlay(full, 'richese', 2, effect), PrintedCardPlayError);
for (const choice of [{ recipient: 'other' }, { give: 'private-given-card' }, { scope: 'movement' }, { mode: 'first' }]) {
const invalid = { turn: 2, plays: [{ player: 'richese', effect, count: 1, ...choice }] } as PrintedCardPlayCounts;
assert.throws(() => printedCardPlayCount(invalid, 'richese', 3, effect), PrintedCardPlayError);
}
}
assert.deepEqual(before, { turn: 2, plays: [{ player: 'richese', effect: 'nullentropyBox', count: 1 }] });
assert.deepEqual(original, before);
assert.equal(printedCardPlayCount(counts, 'richese', 2, 'nullentropyBox'), 1);
assert.deepEqual(recordPrintedCardPlay(counts, 'richese', 3, 'distrans'),
{ turn: 3, plays: [{ player: 'richese', effect: 'distrans', count: 1 }] });
});
void test('missing legacy counts and other turn/player/effect baselines read as zero', () => {
assert.equal(printedCardPlayCount(undefined, 'atreides', 1, 'hajr'), 0);
const counts = recordPrintedCardPlay(undefined, 'atreides', 1, 'hajr');
+3 -1
View File
@@ -50,7 +50,9 @@ void test('genuine Basic and Advanced late Sapho changes an ordinary revealed ti
assert.equal(late.afterUse!.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(late.afterUse!).tanks, player(f.game).tanks);
const stale = saphoLateOutcomeReady(late.beforeUse!, f.opponent);
assert.throws(() => actSaphoLate(late.afterUse!, f.opponent, stale), /event|stale|window|opportunity/i);
assert.ok(!late.game.pendingTreacheryDiscard, 'The actual fresh Sapho cost finishes before testing the resumed event');
assert.equal(late.game.battle!.outcomeWindow!.event, late.afterUse!.battle!.outcomeWindow!.event);
assert.throws(() => actSaphoLate(late.game, f.opponent, stale), /event|stale|window|opportunity/i);
const changed = completeSaphoLateAggressor(f, late);
assert.equal(changed.readies.length, 2, 'Both participants must acknowledge the revised public opportunity');
assert.equal(changed.game.lastBattleContext!.winner, f.owner);
@@ -0,0 +1,308 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { treacheryDeck } from '../game/cards';
import { viewGame, type Action, type Game } from '../game/engine';
import { richeseCards } from '../game/richese-cards';
import { truthFactAnswer, type TruthQuestion } from '../game/truthtrance';
import {
actTruthTransferOrderPlay as act, advanceTruthTransferOrderPlay as advance,
answerTruthTransferOrderPlay as answer, askTruthTransferOrderPlay as ask, bindTruthTransferOrderPlay as bind,
createTruthTransferOrderPlayFixture as fixture, createTruthSemutaPrebidFixture as semutaFixture,
createTruthEarlySaphoFixture as earlySaphoFixture, declareTruthTransferOrderPlay as declare,
finishTruthTransferOrderPlay as finish, nextTruthTransferOrderPlayNative as nativeStep,
settleTruthTransferOrderPlay as settle, truthTransferOrderDistransAction as transfer,
truthTransferOrderSaphoAction as order, truthTransferOrderPlayClean as clean,
truthTransferOrderPlayFact as fact, truthTransferOrderPlayGroup as group,
truthTransferOrderPlayInventory as inventory, truthTransferOrderPlayPayment as payment,
truthTransferOrderPlayPlayer as player, truthTransferOrderPlayPromise as promise,
truthTransferOrderPlayShip as ship, truthTransferOrderPlaySpice as spice,
truthTransferOrderPlayTraitors as traitors, truthTransferOrderPlayUse as use,
type TruthTransferOrderExpression, type TruthTransferOrderPlayFixture, type TruthTransferOrderStep,
} from './fixture-truth-transfer-order-play';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function physical(game: Game, f: TruthTransferOrderPlayFixture) {
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(spice(game), f.originalSpice);
assert.deepEqual(traitors(game), f.originalTraitors);
}
const raw = (f: TruthTransferOrderPlayFixture, mixed: TruthTransferOrderExpression): TruthQuestion =>
({ kind: 'turnActions', target: f.target, turn: f.game.turn, mixed });
const pay = (game: Game, f: TruthTransferOrderPlayFixture, amount: number, actions: TruthTransferOrderStep[] = []) =>
act(game, f.target, { type: 'bribe', target: f.recipient, amount }, actions);
const stage = (game: Game, f: TruthTransferOrderPlayFixture, index = 0) => game.turnActionPromises![index].cardPlays?.[f.effect];
const nativeCard = (game: Game, f: TruthTransferOrderPlayFixture): Action => f.effect === 'distrans'
? transfer(game, f) : order(game, f, 'first');
void test('transfer and order sources retain actual cache purchases, unopened Truth deals and all original native inventories', () => {
for (const source of ['distrans', 'sapho'] as const) for (const advanced of [false, true]) {
const f = fixture({ source, advanced }); assert.equal(f.setup.status, 'setup');
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.deepEqual(f.originalCards, [...treacheryDeck(['choam']), ...richeseCards()].map(card => card.id).sort());
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.sourceCounts.selected, f.actions.length);
assert.equal(f.sourceCounts.inherited + f.sourceCounts.nativeContinuation, f.actions.length); assert.equal(f.sourceCounts.nativeCacheBids, 1);
assert.equal(!!f.game.semutaPreview, !advanced); assert.equal(player(f.game, f.target).spice, source === 'distrans' ? 4 : 9);
for (const [index, truth] of f.truths.entries()) assert.ok(player(f.game, f.truthHolders[index]).hand.some(card => card.id === truth));
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.card)); assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
if (f.sapho) {
assert.equal(f.game.active, f.sapho.current); assert.equal(player(f.game, f.sapho.current).shipped, true);
assert.equal(player(f.game, f.sapho.current).moved, 0); assert.equal(player(f.game, f.target).shipped, false);
assert.ok(player(f.game, f.sapho.canceler).hand.some(card => card.id === f.sapho!.counter));
}
physical(f.game, f);
}
});
void test('Basic Distrans AND frozen fact and payment spends no transfer fee and preserves the original untouched ordinary lot', () => {
const f = fixture(), actions: TruthTransferOrderStep[] = [];
let game = bind(f, 'yes', raw(f, group('and', fact(f.spiceFact), use(f.effect), payment(f.recipient, 4))), f.game, 0, actions).game;
game = settle(game, actions); assert.ok(actions.some(step => step.action.type === 'semutaPass'));
const lot = structuredClone(game.auction), donorForces = structuredClone(player(game, f.target).forces);
game = pay(game, f, 4, actions); assert.equal(player(game, f.target).spice, 0);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
const action = transfer(game, f); game = settle(act(game, f.target, action, actions), actions);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 4);
assert.ok(player(game, f.recipient).hand.some(card => card.id === f.give));
assert.ok(!player(game, f.target).hand.some(card => card.id === f.give || card.id === f.card));
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); assert.deepEqual(game.auction, lot);
assert.deepEqual(player(game, f.target).forces, donorForces); assert.equal(stage(game, f)?.played, true);
assert.equal(promise(game).fulfilled, true); assert.equal(game.semutaPreview, true); physical(game, f);
});
void test('Distrans printed use names no recipient and can choose another public-capacity route when the original target is genuinely full', () => {
const f = fixture({ capacity: 'recipientFull' }); assert.ok(f.distrans);
const fullRecipient = f.distrans.recipient, fullHandCount = player(f.game, fullRecipient).hand.length;
const copy = f.truthHolders.findIndex(holder => holder !== fullRecipient); assert.ok(copy === 0 || copy === 1);
let game = settle(bind(f, 'yes', raw(f, use(f.effect)), f.game, copy).game);
const quoted = viewGame(game, f.target).distrans; assert.ok(quoted);
assert.ok(quoted.choices.find(choice => choice.recipient === fullRecipient)?.blocked);
rejected(game, f.target, { type: 'card', card: f.card, target: fullRecipient, give: f.give! });
rejected(game, f.target, { type: 'card', card: f.card, target: f.asker, give: f.card });
const alternative = quoted.choices.find(choice => !choice.blocked && choice.cards.some(card => card.id === f.give)); assert.ok(alternative);
const cash = player(game, f.target).spice;
game = settle(act(game, f.target, transfer(game, f, alternative.recipient)));
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).spice, cash);
assert.equal(player(game, fullRecipient).hand.length, fullHandCount);
assert.ok(player(game, alternative.recipient).hand.some(card => card.id === f.give)); assert.ok(clean(game)); physical(game, f);
});
void test('free Distrans, a later Guild shipment and payments share the same four owned spice', () => {
const f = fixture({ advanced: true });
const unfunded = raw(f, group('and', use(f.effect), payment(f.recipient, 4), ship()));
const asked = ask(declare(f).game, f.asker, unfunded);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const funded = raw(f, group('and', use(f.effect), payment(f.recipient, 3), ship()));
let game = settle(bind(f, 'yes', funded).game); game = pay(game, f, 3);
game = settle(act(game, f.target, transfer(game, f))); assert.equal(stage(game, f)?.played, true);
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), [], true);
game = settle(act(game, f.target, f.shipment)); assert.equal(player(game, f.target).spice, 0);
assert.equal(player(game, f.target).forces['polar_sink:0'], 1); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.recipient).bribes, 3); assert.equal(player(game, f.target).spice, 0); physical(game, f);
});
void test('Sapho AND frozen fact and payment accepts either real queued mode while preserving the current completed shipment and unsplit turn', () => {
const cases = [{ advanced: false, guildTake: false, mode: 'first' }, { advanced: false, guildTake: false, mode: 'last' },
{ advanced: true, guildTake: false, mode: 'first' }, { advanced: true, guildTake: false, mode: 'last' },
{ advanced: true, guildTake: true, mode: 'first' }] as const;
for (const { advanced, guildTake, mode } of cases) {
const f = fixture({ source: 'sapho', advanced, guildTake }); assert.ok(f.sapho);
const actions: TruthTransferOrderStep[] = [];
let game = settle(bind(f, 'yes', raw(f, group('and', fact(f.spiceFact), use(f.effect), payment(f.recipient))), f.game, 0, actions).game, actions);
game = pay(game, f, 2, actions); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
const current = f.sapho.current, beforeCurrent = structuredClone(player(game, current)), nativeOrder = [...game.order];
const native = viewGame(game, f.target).saphoOptions.find(option => option.scope === 'movement' && option.mode === mode); assert.ok(native);
assert.equal((native as typeof native & { afterCurrent?: string }).afterCurrent, current);
if (guildTake) {
assert.equal(current, f.sapho.guild);
assert.equal(viewGame(game, f.target).saphoOptions.some(option => option.scope === 'movement' && option.mode === 'last'), false);
rejected(game, f.target, { ...order(game, f, 'first'), mode: 'last' });
assert.deepEqual(player(game, current), beforeCurrent); assert.equal(stage(game, f)?.played, false);
}
game = settle(act(game, f.target, order(game, f, mode), actions), actions);
assert.equal(game.active, current); assert.deepEqual(player(game, current), beforeCurrent);
assert.deepEqual(game.order, nativeOrder); assert.equal(player(game, f.target).spice, 7);
assert.equal(stage(game, f)?.played, true); assert.equal(promise(game).fulfilled, true);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
const from = actions.length; game = advance(game, state => state.phase !== 5, actions, true);
const ended = actions.slice(from).filter(step => step.action.type === 'endMovement').map(step => step.actor);
assert.equal(ended[0], current); assert.equal(ended.filter(actor => actor === f.target).length, 1);
assert.equal(new Set(ended).size, ended.length); assert.equal(stage(game, f)?.played, true); physical(game, f);
}
});
void test('a genuinely purchased Semuta claims the original Truth cost before or after the first normal bid without changing its standing auction', () => {
for (const firstBid of [false, true]) {
const f = semutaFixture({ firstBid });
assert.ok(f.acquisition.steps.some(step => step.actor === f.target && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.semuta));
assert.equal(f.game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(f.game.auction!.bid, firstBid ? 1 : 0);
const position = (game: Game) => structuredClone({ status: game.status, turn: game.turn, phase: game.phase,
active: game.active, order: game.order, ready: game.ready, auction: game.auction,
richeseAuction: game.richeseAuction, richeseBidding: game.richeseBidding, richeseFunding: game.richeseFunding,
currentAuctionSale: game.currentAuctionSale, deck: game.deck,
players: game.players.map(candidate => ({ ...candidate, hand: undefined })) });
const standing = position(f.game), declared = declare(f), asked = ask(declared.game, declared.asker, f.question);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
let game = answer(asked, f.target, 'no');
const pending = game.pendingTreacheryDiscard; assert.ok(pending); assert.equal(pending.reaction?.stage, 'offer');
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, discardedBy: entry.discardedBy })),
[{ card: f.truth, discardedBy: f.asker }]);
assert.equal(viewGame(game, f.target).semutaReaction?.canCommit, true); assert.deepEqual(position(game), standing);
const event = pending.batch.event; game = act(game, f.target, { type: 'semutaCommit', event });
assert.ok(!game.pendingTreacheryDiscard); assert.deepEqual(position(game), standing);
assert.ok(player(game, f.target).hand.some(card => card.id === f.truth));
assert.ok(!player(game, f.target).hand.some(card => card.id === f.semuta));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); assert.equal(game.discard.filter(card => card.id === f.truth).length, 0);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); assert.equal(stage(game, f)?.played, false);
assert.equal(game.resolvedTreacheryDiscardSequence, pending.sequence); assert.equal(promise(game).answer, false);
rejected(game, f.target, { type: 'semutaCommit', event }); physical(game, f);
}
});
void test('genuine early Basic Sapho can promise its free upcoming native movement play after paying all current cash', () => {
const f = earlySaphoFixture(); assert.equal(f.game.phase, 4); assert.equal(f.game.advanced, false);
assert.ok(!f.actions.some(step => step.action.type === 'ship')); assert.equal(f.sourceCounts.selected, f.actions.length);
assert.equal(f.sourceCounts.inherited + f.sourceCounts.nativeContinuation, f.actions.length);
const cash = player(f.game, f.target).spice;
const question = raw(f, group('and', fact(f.spiceFact), use(f.effect), payment(f.recipient, cash)));
const declared = declare(f), asked = ask(declared.game, declared.asker, question);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['yes', 'no']);
let game = settle(answer(asked, f.target, 'yes')); game = pay(game, f, cash);
assert.equal(player(game, f.target).spice, 0); assert.equal(stage(game, f)?.played, false);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
const earlyGuide = viewGame(game, f.target).turnActionGuide;
assert.ok(!earlyGuide || earlyGuide.action.type !== 'card' || earlyGuide.action.card !== f.card,
'The future movement option is not a currently executable Basic Revival card packet');
game = advance(game, state => state.phase === 5 && clean(state) && viewGame(state, f.target).saphoOptions.some(option => option.scope === 'movement'), [], true);
const before = game.players.map(candidate => ({ id: candidate.id, spice: candidate.spice, forces: structuredClone(candidate.forces),
reserves: candidate.reserves, tanks: candidate.tanks, shipped: candidate.shipped, moved: candidate.moved }));
const view = viewGame(game, f.target), guide = view.turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card);
assert.ok(view.saphoOptions.some(option => option.scope === guide.action.scope && option.event === guide.action.event && option.mode === guide.action.mode));
game = settle(act(game, f.target, guide.action));
assert.deepEqual(game.players.map(candidate => ({ id: candidate.id, spice: candidate.spice, forces: candidate.forces,
reserves: candidate.reserves, tanks: candidate.tanks, shipped: candidate.shipped, moved: candidate.moved })), before);
assert.equal(stage(game, f)?.played, true); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, cash); physical(game, f);
});
void test('whole OR keeps both manual printed use and late ordinary payment routes for transfer and order', () => {
for (const source of ['distrans', 'sapho'] as const) {
const f = fixture({ source, advanced: true }), question = raw(f, group('or', use(f.effect), payment(f.recipient)));
const bound = settle(bind(f, 'yes', question).game);
const played = settle(act(bound, f.target, nativeCard(bound, f)));
assert.equal(promise(played).fulfilled, true); assert.equal(player(played, f.recipient).bribes, 0);
let paid = advance(bound, state => state.phase === 8 && clean(state), [], true);
assert.equal(stage(paid, f)?.played, false); assert.ok(!promise(paid).released && !promise(paid).fulfilled);
paid = pay(paid, f, 2); assert.equal(promise(paid).fulfilled, true);
assert.ok(player(paid, f.target).hand.some(card => card.id === f.card)); physical(played, f); physical(paid, f);
}
});
void test('whole AND No allows one printed branch or its payment but cannot complete both conditions', () => {
for (const source of ['distrans', 'sapho'] as const) {
const f = fixture({ source, advanced: true }), bound = settle(bind(f, 'no').game), packet = nativeCard(bound, f);
let played = settle(act(bound, f.target, packet)); assert.equal(stage(played, f)?.played, true);
played = pay(played, f, 1); rejected(played, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
let paid = pay(bound, f, 2); rejected(paid, f.target, packet); assert.equal(stage(paid, f)?.played, false);
played = finish(played); paid = finish(paid); assert.equal(promise(played).fulfilled, true); assert.equal(promise(paid).fulfilled, true);
assert.ok(!promise(played).released && !promise(paid).released); physical(played, f); physical(paid, f);
}
});
void test('whole OR No preserves cumulative caps and rejects both printed roles until the actual turn end', () => {
for (const source of ['distrans', 'sapho'] as const) {
const f = fixture({ source, advanced: true }), originalPacket = nativeCard(f.game, f);
let game = settle(bind(f, 'no', raw(f, group('or', use(f.effect), payment(f.recipient)))).game);
const forbidden = viewGame(game, f.target);
if (source === 'distrans') assert.match(forbidden.distrans?.blocked ?? '', /Truthtrance/);
else assert.deepEqual(forbidden.saphoOptions, []);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.target); view.players.find(player => player.id === f.target)!.bot = difficulty;
assert.ok(!botActions(view).some(action => action.type === 'card' && action.card === f.card));
}
rejected(game, f.target, originalPacket); game = pay(game, f, 1);
game = advance(game, state => state.phase === 8 && clean(state), [], true);
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
assert.ok(!promise(game).released); game = finish(game);
assert.equal(promise(game).fulfilled, true); assert.equal(stage(game, f)?.played, false); assert.equal(stage(game, f)?.closed, true);
assert.ok(player(game, f.target).hand.some(card => card.id === f.card)); physical(game, f);
}
});
void test('earlier native Distrans and Sapho use without an owned recovery route leaves only No for a new future interval', () => {
for (const source of ['distrans', 'sapho'] as const) {
const f = fixture({ source, advanced: true }); const earlier = settle(act(f.game, f.target, nativeCard(f.game, f)));
assert.equal(earlier.discard.filter(card => card.id === f.card).length, 1);
const declared = declare(f, earlier), asked = ask(declared.game, declared.asker, raw(f, use(f.effect)));
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const game = settle(answer(asked, f.target, 'no')); assert.equal(stage(game, f)?.played, false); physical(game, f);
}
});
void test('an actual turn2 Box recovery alone is not a Distrans play but a later native replay with an earned outgoing card fulfills', () => {
const f = fixture({ recoveredDistrans: true }); assert.ok(f.recovery);
assert.equal(f.game.turn, f.recovery.before.turn + 1);
assert.ok(player(f.recovery.afterRecovery, f.target).hand.some(card => card.id === f.card));
assert.equal(f.recovery.afterRecovery.discard.filter(card => card.id === f.recovery!.box).length, 1);
assert.ok(f.recovery.acquisition.some(step => step.actor === f.target && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.equal(f.recovery.outgoingPurchase.card, f.give); assert.equal(f.recovery.outgoingPurchase.amount, 1);
const cash = player(f.game, f.target).spice; let game = settle(bind(f, 'yes', raw(f, use(f.effect))).game);
assert.equal(stage(game, f)?.played, false); assert.ok(!promise(game).fulfilled);
game = settle(act(game, f.target, transfer(game, f)));
assert.equal(stage(game, f)?.played, true); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, cash); assert.ok(player(game, f.recipient).hand.some(card => card.id === f.give));
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
});
void test('exact transfer/order guide packets preserve native choices and reject stale owner, promise and Sapho event through JSON', () => {
for (const source of ['distrans', 'sapho'] as const) {
const f = fixture({ source, advanced: true }); let game = settle(bind(f, 'yes', raw(f, use(f.effect))).game);
const view = viewGame(game, f.target), guide = view.turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'card');
assert.equal(guide.action.card, f.card); assert.equal(guide.event, promise(game).event);
assert.equal(guide.action.turnActionPromiseEvent, promise(game).event); assert.equal(guide.action.turnActionPromisePhase, game.phase);
if (source === 'distrans') {
const native = view.distrans?.choices.find(choice => choice.recipient === guide.action.target && !choice.blocked); assert.ok(native);
assert.ok(native.cards.some(card => card.id === guide.action.give)); assert.notEqual(guide.action.give, f.card);
} else {
assert.ok(view.saphoOptions.some(option => option.scope === guide.action.scope && option.event === guide.action.event && option.mode === guide.action.mode));
rejected(game, f.target, { ...guide.action, event: 'stale-native-sapho-event' });
}
rejected(game, f.asker, guide.action); rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-printed-role-event' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 8 });
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, packet));
assert.equal(promise(game).fulfilled, true); assert.equal(stage(game, f)?.played, true); rejected(game, f.target, packet); physical(game, f);
}
});
void test('all four minimal profiles use real transfer/order guides and native Basic Semuta continuation without extra costs', () => {
for (const source of ['distrans', 'sapho'] as const) for (const advanced of [false, true]) {
const f = fixture({ source, advanced }), declared = declare(f), asked = ask(declared.game, declared.asker, f.question);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthTransferOrderStep[] = [];
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, `${source}/${advanced}/${difficulty}`);
assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
for (let count = 0; count < 100 && (!promise(game).fulfilled || !clean(game)); count++) {
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
if (view.turnActionGuide) {
const guide = view.turnActionGuide;
const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.action.turnActionPromiseEvent);
assert.ok(action, `${source}/${advanced}/${difficulty}`); assert.deepEqual(action, guide.action);
game = act(game, f.target, action, actions);
} else {
const step = nativeStep(game); assert.ok(step); game = act(game, step.actor, step.action, actions);
}
}
assert.equal(promise(game).fulfilled, true); assert.ok(clean(game)); assert.equal(stage(game, f)?.played, true);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.card).length, 1);
assert.equal(player(game, f.target).spice, player(f.game, f.target).spice - 2); assert.equal(player(game, f.recipient).bribes, 2);
if (!advanced) assert.ok(actions.some(step => step.action.type === 'semutaPass'));
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
}
}
});
+145 -5
View File
@@ -7,6 +7,7 @@ import { TurnActionAnswers, TurnActionGuideButton, TurnActionPromises } from '..
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { BattleClaimFields } from '../components/battle-promises';
import { baseDeck, ixSpecialCards } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView, type Plan } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { MixedBattleActionClaim } from '../game/mixed-battle-question';
@@ -31,7 +32,7 @@ const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum =
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
const finalAlliance = (partner = 'r'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
const printedCard = (effect: 'hajr' | 'ghola' | 'nullentropyBox' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
const printedCard = (effect: 'hajr' | 'ghola' | 'nullentropyBox' | 'distrans' | 'juiceOfSapho' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
const shipmentPriceSource = (recipient = 'p', cunning?: string) => JSON.stringify(['karamaShipment', 2, recipient,
...(cunning ? ['cunning', cunning] : ['original'])]);
const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow'
@@ -149,6 +150,25 @@ function boxPlay(phase = 7): GameView {
game.turnActionPromises[0].mixed = { kind: 'and', terms: [printedCard('nullentropyBox'), payment()] };
return game;
}
function distransPlay(): GameView {
const card = richeseCards().find(card => card.effect === 'distrans'), given = baseDeck().find(card => card.name === 'Shield');
assert.ok(card); assert.ok(given);
const game = projected(2, { type: 'card', card: card.id, target: 'r', give: given.id });
const me = game.players.find(player => player.id === 'p')!; me.hand = [card, given]; me.handLimit = 4;
const recipient = game.players.find(player => player.id === 'r')!; recipient.handCount = 1; recipient.handLimit = 4;
game.active = 'a'; game.distrans = { card, blocked: null, choices: [{ recipient: 'r', blocked: null, cards: [given], unavailable: [] }] };
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard('distrans'), payment()] };
return game;
}
function saphoPlay(phase = 5, option: NonNullable<GameView['saphoOptions']>[number] =
{ scope: 'movement', event: 'controlled-movement-order', mode: 'last', afterCurrent: 'a' }): GameView {
const card = richeseCards().find(card => card.effect === 'juiceOfSapho'); assert.ok(card);
const game = projected(phase, { type: 'card', card: card.id, scope: option.scope, event: option.event, mode: option.mode });
const me = game.players.find(player => player.id === 'p')!; me.hand = [card]; me.handLimit = 4;
game.active = 'a'; game.saphoOptions = [option];
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard('juiceOfSapho'), payment()] };
return game;
}
function nexus(next: Action = { type: 'alliance', target: 'r' }): GameView {
const game = projected(1, next);
game.nexus = true; game.spiceWindow = null; game.active = 'a';
@@ -1261,14 +1281,21 @@ void test('turn-action printed-role editor uses public names without holdings or
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} authoring inspection`); }, configurable: true });
Object.defineProperty(game, 'ghola', { get() { throw Error('No private Ghola authoring inspection'); }, configurable: true });
Object.defineProperty(game, 'nullentropy', { get() { throw Error('No private Box authoring inspection'); }, configurable: true });
Object.defineProperty(game, 'distrans', { get() { throw Error('No private Distrans authoring inspection'); }, configurable: true });
Object.defineProperty(game, 'saphoOptions', { get() { throw Error('No private Sapho authoring inspection'); }, configurable: true });
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private card-play stage'); } });
const e = editor(physical(), false, game); select(e.tree(), '1', 'action', 'cardPlay');
assert.deepEqual(e.value, printedCard()); assert.equal(e.error(), null);
const choices = elements(find(e.tree(), 'id', 'truth-turn-action-1-effect')).filter(element => element.type === 'option');
assert.deepEqual(choices.map(choice => choice.props.value), ['hajr', 'ghola', 'nullentropyBox']);
assert.deepEqual(choices.map(choice => choice.props.value), ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho']);
select(e.tree(), '1', 'effect', 'ghola'); assert.deepEqual(e.value, printedCard('ghola')); assert.equal(e.error(), null);
select(e.tree(), '1', 'effect', 'karama'); assert.deepEqual(e.value, printedCard('ghola'));
select(e.tree(), '1', 'effect', 'nullentropyBox'); assert.deepEqual(e.value, printedCard('nullentropyBox')); assert.equal(e.error(), null);
for (const effect of ['distrans', 'juiceOfSapho'] as const) {
select(e.tree(), '1', 'effect', effect); assert.deepEqual(e.value, printedCard(effect)); assert.equal(e.error(), null);
for (const extra of [{ target: 'r' }, { give: 'private-card' }, { scope: 'movement' }, { event: 'private-source' }, { mode: 'first' }])
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect, ...extra } }, game, 'p'));
}
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'ghola', card: 'private-card-id' } }, game, 'p'));
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'karama' } }, game, 'p'));
for (const extra of [{ card: 'private-box-id' }, { selected: 'private-recovery' }, { amount: 2 }])
@@ -1280,12 +1307,15 @@ void test('turn-action printed-role editor uses public names without holdings or
assert.match(html, /Play Tleilaxu Ghola in its printed role after this answer this turn/);
assert.match(html, /Activate Nullentropy Box and pay 2 spice after this answer this turn/);
assert.match(html, /The selected recovered card stays private and is not promised/);
assert.match(html, /Play Distrans in its printed role after this answer this turn/);
assert.match(html, /Play Juice of Sapho in its printed role after this answer this turn/);
assert.match(html, /not a particular recipient, transferred card or order outcome/);
assert.match(html, /Hajr counts when played even if its extra movement is unused/);
assert.match(html, /an actual later replay after Box recovery does/); assert.doesNotMatch(html, /private-card-id|controlled-ghola|controlled-hajr/);
const full = editor({ kind: 'or', terms: [printedCard(), printedCard('nullentropyBox')] }, false, game);
for (let count = 3; count <= 16; count++) {
click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'cardPlay');
select(full.tree(), `1.${count}`, 'effect', 'nullentropyBox');
select(full.tree(), `1.${count}`, 'effect', count % 2 ? 'distrans' : 'juiceOfSapho');
}
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
assert.equal(control(full.tree(), 'add-action-1').props.disabled, true);
@@ -1428,17 +1458,127 @@ void test('printed Box activation accepts no private selection or extra native p
assert.equal(guide({ game, busy: false, act() { assert.fail('A turn-end Box activation requires its matching raw role'); } }).props.disabled, true);
});
void test('printed Distrans guide forwards its exact owned transfer quote without inspecting foreign hands', () => {
const game = distransPlay(), sent: Action[] = [];
for (const player of game.players.filter(player => player.id !== 'p'))
Object.defineProperty(player, 'hand', { get() { throw Error('Distrans uses the native capacity quote, not foreign hands'); }, configurable: true });
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Distrans receipt inspection'); } });
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
assert.deepEqual(sent[0], { type: 'card', card: game.distrans!.card.id, target: 'r', give: game.distrans!.choices[0].cards[0].id,
turnActionPromiseEvent: event, turnActionPromisePhase: 2 });
assert.match(markup(game), /Give Shield to Recipient with Distrans/);
});
void test('printed Distrans callbacks reject stale availability, physical cards, capacity and raw source', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.distrans = null; },
game => { game.distrans = { ...game.distrans! }; },
game => { game.distrans!.blocked = 'Finish the current native interaction.'; },
game => { game.distrans!.choices[0].blocked = 'The recipient is full.'; },
game => { game.distrans!.choices[0].cards = []; },
game => { game.players.find(player => player.id === 'p')!.hand!.pop(); },
game => { game.players.find(player => player.id === 'r')!.handCount = 4; },
game => { game.players.find(player => player.id === 'r')!.handLimit = 3; },
game => { game.turnActionPromises[0].mixed = payment(); },
game => { game.phase = 3; },
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
game => { game.turnActionGuide!.action.give = 'different-held-card'; },
];
for (const change of changes) {
const game = distransPlay(), sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
}
for (const change of [
(game: GameView) => { game.turnActionGuide!.action.target = 'p'; },
(game: GameView) => { game.turnActionGuide!.action.target = 'a'; },
(game: GameView) => { game.turnActionGuide!.action.give = game.distrans!.card.id; },
(game: GameView) => { game.turnActionGuide!.action.give = 'unquoted-card'; },
]) {
const game = distransPlay(); change(game);
const button = guide({ game, busy: false, act() { assert.fail('Distrans must use its current quoted recipient and other held card'); } });
assert.equal(button.props.disabled, true); press(button);
}
});
void test('printed Sapho guides forward every quoted native scope and retain display metadata outside the action', () => {
const options: [number, NonNullable<GameView['saphoOptions']>[number]][] = [
[3, { scope: 'onceAround', event: 'controlled-once-around', mode: 'first' }],
[3, { scope: 'cyclicAuction', event: 'controlled-cyclic', mode: 'last' }],
[4, { scope: 'revival', event: 'controlled-revival', mode: 'last', afterCurrent: 'a' }],
[5, { scope: 'movement', event: 'controlled-movement', mode: 'first', afterCurrent: 'a' }],
[6, { scope: 'battleOrder', event: 'controlled-battle-order', mode: 'last' }],
[6, { scope: 'battleAggressor', event: 'controlled-aggressor', mode: 'aggressor' }],
[8, { scope: 'extortion', event: 'controlled-extortion-order', mode: 'first' }],
];
for (const [phase, option] of options) {
const game = saphoPlay(phase, option), sent: Action[] = [];
if (option.scope === 'extortion') game.decision = { kind: 'moritaniExtortion', player: 'p', event: 'controlled-native-extortion' };
for (const player of game.players.filter(player => player.id !== 'p'))
Object.defineProperty(player, 'hand', { get() { throw Error('Sapho uses the native option, not foreign hands'); }, configurable: true });
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Sapho receipt inspection'); } });
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
assert.deepEqual(sent[0], { type: 'card', card: 'richese-juice-of-sapho', scope: option.scope, event: option.event, mode: option.mode,
turnActionPromiseEvent: event, turnActionPromisePhase: phase });
assert.equal(Object.hasOwn(sent[0], 'afterCurrent'), false);
assert.match(markup(game), /Play Juice of Sapho:/);
}
});
void test('printed Sapho callbacks and both role guides reject stale quotes or modified packets without generic fallback', () => {
for (const change of [
(game: GameView) => { game.saphoOptions = []; },
(game: GameView) => { game.saphoOptions = [...game.saphoOptions]; },
(game: GameView) => { game.saphoOptions[0].event = 'different-native-source'; },
(game: GameView) => { game.saphoOptions[0].mode = 'first'; },
(game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
(game: GameView) => { game.players.find(player => player.id === 'p')!.handLimit = 5; },
(game: GameView) => { game.turnActionPromises[0].mixed = payment(); },
(game: GameView) => { game.phaseOpening = { passed: [] }; },
(game: GameView) => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
]) {
const game = saphoPlay(), sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
}
for (const change of [
(game: GameView) => {
const current = game.decision; assert.ok(current?.kind === 'moritaniExtortion');
game.decision = { ...current, event: 'next-native-extortion' };
},
(game: GameView) => { game.decision = null; },
]) {
const game = saphoPlay(8, { scope: 'extortion', event: 'controlled-extortion-order', mode: 'first' });
game.decision = { kind: 'moritaniExtortion', player: 'p', event: 'controlled-native-extortion' };
const saved = guide({ game, busy: false, act() { assert.fail('A changed Extortion source cannot submit an earlier Sapho option'); } });
assert.equal(saved.props.disabled, false); change(game); press(saved);
}
for (const create of [distransPlay, () => saphoPlay(4, { scope: 'revival', event: 'controlled-revival', mode: 'last' })]) {
for (const extra of [{ amount: 1 }, { mode: 'shipment' }, { target: 'a' }, { give: 'unquoted-card' },
{ scope: 'unsupported' }, { event: 'different-source' }, { afterCurrent: 'a' }]) {
const game = create(); Object.assign(game.turnActionGuide!.action, extra);
const button = guide({ game, busy: false, act() { assert.fail('Printed-role guides forward only the exact native action fields'); } });
assert.equal(button.props.disabled, true); press(button);
}
const game = create(), sent: Action[] = [], props = { game, busy: false, act(action: Action) { sent.push(action); } }, button = guide(props);
assert.equal(button.props.disabled, false); props.busy = true; press(button); assert.deepEqual(sent, []);
}
});
void test('printed-card history retains only raw aggregate conditions and their distinct native deadlines', () => {
const game = projected(8); game.turnActionGuide = null;
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard(), { kind: 'and', terms: [fact(), printedCard('ghola'), printedCard('nullentropyBox')] }] };
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard(), { kind: 'and', terms: [fact(), printedCard('ghola'), printedCard('nullentropyBox'), printedCard('distrans'), printedCard('juiceOfSapho')] }] };
game.turnActionPromises[0].answer = false;
for (const key of ['cardPlays', 'compiled', 'combat', 'preparation'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
const html = markup(game);
assert.match(html, /Hajr/); assert.match(html, /Tleilaxu Ghola/); assert.match(html, /You answered Yes/);
assert.match(html, /Hajr/); assert.match(html, /Tleilaxu Ghola/); assert.match(html, /You answered No/);
assert.match(html, /Hajr counts even if its extra movement is unused/);
assert.match(html, /Earlier plays, discards, sales, Karama roles and recovery alone do not count/);
assert.match(html, /actual later Box replay does/); assert.match(html, /Ghola is separate from ordinary Revival/);
assert.match(html, /you activate Nullentropy Box/); assert.match(html, /Box conditions close at the actual turn end/);
assert.match(html, /Distrans/); assert.match(html, /Juice of Sapho/);
assert.match(html, /The deadline is the actual turn end, including for a whole No answer/);
assert.match(html, /remains open until the actual turn ends/); assert.doesNotMatch(html, /cardPlays|compiled|controlled-hajr|controlled-ghola/);
});
+21
View File
@@ -55,6 +55,27 @@ void test('Box raw question parsing needs no hidden possession and eagerly rejec
}
});
void test('Distrans and Sapho questions name only actual printed use this turn without transfer or order choices', () => {
const game = fixture();
for (const effect of ['distrans', 'juiceOfSapho'] as const) {
assert.ok(!game.players.find(player => player.id === 'e')!.hand.some(card => card.effect === effect));
const action = { kind: 'cardPlay' as const, effect };
const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2,
mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
{ kind: 'action', action }] } };
assert.doesNotThrow(() => validateTruthQuestionReceipt(game, 'a', question));
const text = truthQuestionText(question, id => id, id => id);
assert.match(text, effect === 'distrans' ? /complete a printed Distrans transfer and discard Distrans after this answer this turn/ :
/play Juice of Sapho in its printed role after this answer this turn/);
assert.doesNotMatch(text, /Ghola|first|last|aggressor|private-card|recipient/);
for (const extra of [{ recipient: 'h' }, { give: 'private-card' }, { scope: 'movement' }, { mode: 'first' }, { outcome: true }]) {
const invalid = { ...question, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
{ kind: 'action', action: { ...action, ...extra } }] } } as TruthQuestion;
assert.throws(() => validateTruthQuestionReceipt(game, 'a', invalid));
}
}
});
function fixture() {
const g = createGame('TRUTH22', newPlayer('a', 'Atreides', 'atreides'));
g.players.push(
+75 -3
View File
@@ -103,8 +103,9 @@ function cards(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActi
hajr: { played: false, closed: false }, ghola: { played: false, closed: false },
} };
const visit = (node: Expression): void => {
if (node.kind === 'action' && node.action.kind === 'cardPlay' && node.action.effect === 'nullentropyBox')
row.cardPlays!.nullentropyBox = { played: false, closed: false };
if (node.kind === 'action' && node.action.kind === 'cardPlay' &&
node.action.effect !== 'hajr' && node.action.effect !== 'ghola')
row.cardPlays![node.action.effect] = { played: false, closed: false };
else if (node.kind !== 'action' && node.kind !== 'fact') node.terms.forEach(visit);
};
visit(mixed);
@@ -936,7 +937,7 @@ void test('printed card-play leaves contain only the printed effect and every ra
assert.throws(() => turnActionValue(promise(group('or', fact(), card(effect)))), TurnActionError,
'Compiler folding never removes the original raw interval requirement');
}
for (const effect of ['', 'Hajr', 'karama', 'thumper', null])
for (const effect of ['', 'Hajr', 'karama', 'thumper', 'semutaDrug', null])
assert.throws(() => parseTurnActionLeaf({ kind: 'cardPlay', effect }), TurnActionError);
validateTurnActionPromises([cards(group('and', card(), card('ghola')))], seats);
});
@@ -1689,3 +1690,74 @@ void test('optional Box stage preserves legacy bytes and strict raw folded metad
for (const extra of [{ selected: 'Shield' }, { card: 'physical-box' }, { minimum: 2 }, { recipient: 'other' }])
assert.throws(() => parseTurnActionLeaf({ kind: 'cardPlay', effect: 'nullentropyBox', ...extra }), TurnActionError);
});
void test('each optional printed stage subset preserves legacy and Box bytes while rejecting unknown choice metadata', () => {
const base = cards(card('ghola'), false);
const optional = ['nullentropyBox', 'distrans', 'juiceOfSapho'] as const;
for (let mask = 0; mask < 8; mask++) {
const cardPlays = { ...base.cardPlays! };
optional.forEach((effect, index) => { if (mask & (1 << index)) cardPlays[effect] = null; });
const row = { ...base, cardPlays }, before = JSON.stringify(row);
validateTurnActionPromises([row], seats);
assert.equal(recordTurnActionCardPlay([row], 'respondent', 1, 'distrans')[0], row);
assert.equal(JSON.stringify(row), before);
const closed = finishTurnActionTurn([row], 'respondent', 1)[0];
assert.deepEqual(Object.keys(closed.cardPlays!).sort(), Object.keys(cardPlays).sort());
}
for (const effect of ['distrans', 'juiceOfSapho'] as const) {
const row = cards(card(effect)), stage = row.cardPlays![effect]!;
for (const extra of [{ recipient: 'other' }, { give: 'private-card' }, { scope: 'movement' }, { mode: 'last' }, { outcome: true }]) {
assert.throws(() => parseTurnActionLeaf({ kind: 'cardPlay', effect, ...extra }), TurnActionError);
const invalid = { ...row, cardPlays: { ...row.cardPlays!, [effect]: { ...stage, ...extra } } };
assert.throws(() => turnActionValue(invalid), TurnActionError);
}
}
const unsupported = { ...base, cardPlays: { ...base.cardPlays!, semutaDrug: null } } as TurnActionPromise;
assert.throws(() => turnActionValue(unsupported), TurnActionError);
});
void test('Distrans and Sapho whole alternatives share only actual later native receipts without selecting a transfer or mode', () => {
const joint = cards(group('and', card('distrans'), card('juiceOfSapho'), pay()));
const alternative = cards(group('or', card('distrans'), card('juiceOfSapho')), true, 'turn-action:2');
assert.deepEqual(branches(joint), [[{ kind: 'cardPlay', effect: 'distrans', answer: true },
{ kind: 'cardPlay', effect: 'juiceOfSapho', answer: true }, signedPay()]]);
assert.equal(recordTurnActionCardPlay([joint], 'other', 1, 'distrans')[0], joint);
assert.equal(recordTurnActionCardPlay([joint], 'respondent', 2, 'distrans')[0], joint);
const transferred = recordTurnActionCardPlay([joint, alternative], 'respondent', 1, 'distrans');
assert.equal(transferred[1].fulfilled, true);
assert.deepEqual(branches(transferred[0]), [[{ kind: 'cardPlay', effect: 'juiceOfSapho', answer: true }, signedPay()]]);
const used = recordTurnActionCardPlay(transferred, 'respondent', 1, 'juiceOfSapho');
assert.deepEqual(branches(used[0]), [[signedPay()]]);
assert.equal(recordTurnActionPayment(used, 'respondent', 1, payment())[0].fulfilled, true);
const fresh = cards(card('distrans'), true, 'turn-action:3');
assert.equal(fresh.cardPlays!.distrans!.played, false);
const replay = recordTurnActionCardPlay([used[0], fresh], 'respondent', 1, 'distrans');
assert.equal(replay[0], used[0]);
assert.equal(replay[1].fulfilled, true);
assert.equal(joint.cardPlays!.distrans!.played, false);
validateTurnActionPromises(used, seats);
});
void test('Distrans and Sapho No retain turn deadlines through Movement and payment closure and fail atomically on actual use', () => {
for (const effect of ['distrans', 'juiceOfSapho'] as const) {
const yes = cards(group('and', card(effect), pay()));
const no = cards(card(effect), false, 'turn-action:2');
const before = structuredClone([yes, no]);
assert.throws(() => recordTurnActionCardPlay([yes, no], 'respondent', 1, effect), TurnActionError);
assert.deepEqual([yes, no], before);
const transport = closeTurnActionTransport([no], 'respondent', 1);
const closedPayments = closeTurnActionPayments(transport, 'respondent', 1);
assert.deepEqual(closedPayments[0].cardPlays![effect], { played: false, closed: false });
assert.equal(closedPayments[0].fulfilled, false);
const expired = finishTurnActionTurn(closedPayments, 'respondent', 1)[0];
assert.equal(expired.fulfilled, true);
assert.deepEqual(expired.cardPlays![effect], { played: false, closed: true });
assert.equal(recordTurnActionCardPlay([expired], 'respondent', 1, effect)[0], expired);
assert.equal(finishTurnActionTurn([yes], 'respondent', 1)[0].released, true);
const wholeNo = cards(group('and', card(effect), pay()), false, 'turn-action:3');
const used = recordTurnActionCardPlay([wholeNo], 'respondent', 1, effect);
assert.throws(() => recordTurnActionPayment(used, 'respondent', 1, payment()), TurnActionError);
assert.deepEqual(used[0].payments, []);
validateTurnActionPromises([expired], seats);
}
});