Bind printed transfer and order plays to Truthtrance
This commit is contained in:
1 parent
3e959504ef
commit
4c4bebe85e
25 files changed
+1245
-57
No files matched your search
@@ -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,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.'] };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user