Bind Thumper promises before the original hidden Spice draw
This commit is contained in:
1 parent
fd38c51ffd
commit
00741a37d0
19 files changed
+985
-34
No files matched your search
@@ -0,0 +1,211 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { type SpiceCard, treacheryDeck } from '../game/cards';
|
||||
import { stormSectorAfter } from '../game/disaster-rules';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
|
||||
import { windowCardOpportunity } from '../game/window-card-play';
|
||||
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { truthTraitorDecisionTraitors } from './fixture-truth-traitor-decision';
|
||||
import { actTruthTurnAction, truthTurnActionInventory, truthTurnActionPlayer,
|
||||
type TruthTurnActionStep } from './fixture-truth-turn-action';
|
||||
import type { TruthTurnKaramaShipmentLeaf } from './fixture-truth-turn-karama-shipment';
|
||||
|
||||
export type TruthTurnThumperLeaf = TruthTurnKaramaShipmentLeaf |
|
||||
{ kind: 'windowCardPlay'; effect: 'thumper'; source: string };
|
||||
export type TruthTurnThumperExpression = MixedActionExpression<TruthTurnThumperLeaf>;
|
||||
export type TruthTurnThumperStep = TruthTurnActionStep;
|
||||
export const actTruthTurnThumper = actTruthTurnAction;
|
||||
export const truthTurnThumperPlayer = truthTurnActionPlayer;
|
||||
export const truthTurnThumperInventory = truthTurnActionInventory;
|
||||
export const truthTurnThumperTraitors = truthTraitorDecisionTraitors;
|
||||
export const truthTurnThumperClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival && !game.pendingShipment;
|
||||
export const truthTurnThumperFact = (fact: TruthFact): TruthTurnThumperExpression => ({ kind: 'fact', fact });
|
||||
export const truthTurnThumperPlay = (source: string): TruthTurnThumperExpression =>
|
||||
({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'thumper', source } });
|
||||
export const truthTurnThumperPayment = (recipient: string, minimum = 2): TruthTurnThumperExpression =>
|
||||
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
|
||||
export const truthTurnThumperMove = (territory = 'polar_sink', minimum = 2): TruthTurnThumperExpression =>
|
||||
({ kind: 'action', action: { kind: 'move', territory, minimum } });
|
||||
export const truthTurnThumperGroup = (kind: 'and' | 'or', ...terms: TruthTurnThumperExpression[]): TruthTurnThumperExpression =>
|
||||
({ kind, terms });
|
||||
|
||||
/** Sandtrout's waiting flag holds its original one physical card. Generated
|
||||
* Thumper worms are encounters, and never join the physical Spice inventory. */
|
||||
export function truthTurnThumperSpice(game: Game) {
|
||||
const physical: SpiceCard[] = [...game.spiceDeck, ...game.spiceDiscard.flat(), ...(game.spiceSequence?.skipped ?? [])]
|
||||
.filter(card => !('worm' in card && card.thumper));
|
||||
if (game.sandtrout) physical.push({ sandtrout: true });
|
||||
return physical.map(card => 'territory' in card
|
||||
? JSON.stringify({ territory: card.territory, amount: card.amount, sector: card.sector })
|
||||
: 'sandtrout' in card ? 'sandtrout' : 'worm').sort();
|
||||
}
|
||||
/** Native Basic dials are zero in the original first Storm, then one each in
|
||||
* the later Storm. Its real two-sector traversal misses the earned bodies. */
|
||||
export function nextTruthTurnThumperNative(game: Game): TruthTurnThumperStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
if (!game.response && !game.phaseOpening && game.decision &&
|
||||
['wormRide', 'wormPlacement'].includes(game.decision.kind))
|
||||
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function nextTruthTurnThumper(game: Game): TruthTurnThumperStep | null {
|
||||
if (game.truthtrance) 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 nextTruthTurnThumperNative(game);
|
||||
}
|
||||
export function advanceTruthTurnThumper(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthTurnThumperStep[] = [], nativeOnly = false): Game {
|
||||
for (let count = 0; count < 1900; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The genuine Thumper source ended before its requested native boundary');
|
||||
const step = nativeOnly ? nextTruthTurnThumperNative(game) : nextTruthTurnThumper(game);
|
||||
assert.ok(step, 'Supply the original Truthtrance question or actual private Battle Plan');
|
||||
game = actTruthTurnThumper(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Thumper chronology did not reach its finite boundary');
|
||||
}
|
||||
export function settleTruthTurnThumper(game: Game, actions: TruthTurnThumperStep[] = []): Game {
|
||||
return advanceTruthTurnThumper(game, truthTurnThumperClean, actions, true);
|
||||
}
|
||||
|
||||
/** Genuine public selected-Ix H/A/E. H really receives both original Truths,
|
||||
* A Baliset and E printed Thumper from the unopened original deal. The later
|
||||
* Basic source earns its selected potential casualties by shipment/collection
|
||||
* and two actual one-sector later Storm dials; no played state is installed. */
|
||||
export function createTruthTurnThumperFixture(options: {
|
||||
advanced?: boolean; turn?: 1 | 2; waitingSandtrout?: boolean; bodyCount?: 2 | 3;
|
||||
} = {}) {
|
||||
const advanced = options.advanced ?? false, turn = options.turn ?? 1, waitingSandtrout = options.waitingSandtrout ?? false;
|
||||
const bodyCount = options.bodyCount ?? 2;
|
||||
assert.ok(turn === 1 || !advanced, 'The bounded earned-body second-turn source uses actual Basic second Storm dials');
|
||||
assert.ok(!waitingSandtrout || turn === 2, 'Waiting Sandtrout is earned during the original preceding turn');
|
||||
const asker = 'harkonnen', observer = 'atreides', target = 'emperor', recipient = asker;
|
||||
let game = createGame('TRUTHTURNTHUMPER', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced, ['ix']);
|
||||
joinGame(game, newPlayer(observer, 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer(target, 'Emperor', 'emperor'));
|
||||
const initial = structuredClone(game), actions: TruthTurnThumperStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actTruthTurnThumper(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceTruthTurnThumper(game, until, actions, true); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game); assert.equal(game.status, 'setup');
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const originalCards = truthTurnThumperInventory(game), originalTraitors = truthTurnThumperTraitors(game);
|
||||
assert.deepEqual(originalCards, treacheryDeck(['ix']).map(card => card.id).sort());
|
||||
assert.deepEqual(originalTraitors, traitorDeck(game.players, true).sort());
|
||||
const originalSpice = truthTurnThumperSpice(game);
|
||||
const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
|
||||
const thumper = game.deck.find(card => card.effect === 'thumper'), filler = game.deck.find(card => card.name === 'Baliset');
|
||||
assert.ok(thumper && filler);
|
||||
const deal = [...truths.map(card => card.id), filler.id, thumper.id];
|
||||
const dealOrder = deal.map((id, position) => {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(truthTurnThumperPlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id));
|
||||
assert.deepEqual(truthTurnThumperPlayer(game, target).hand.map(card => card.id), [thumper.id]);
|
||||
reach(state => state.phase === 1 && !!state.phaseOpening);
|
||||
const firstOpening = structuredClone(game); let sourceOpening = firstOpening, openingAt = actions.length;
|
||||
reach(beforeIxSpiceDraw); const firstBeforeReveal = structuredClone(game), firstRevealAt = actions.length;
|
||||
let priorOrder: ReturnType<typeof orderIxSpiceCards> = [];
|
||||
let priorShipment: { before: Game; steps: TruthTurnThumperStep[]; after: Game } | null = null;
|
||||
let secondStormSteps: TruthTurnThumperStep[] = [];
|
||||
const previousTerritory = 'hagga_basin', previousSector = 12, previousLocation = `${previousTerritory}:${previousSector}`;
|
||||
let priorBlow: Game['spiceWindow'] = null;
|
||||
if (turn === 2) {
|
||||
priorOrder = orderIxSpiceCards(game, [...(waitingSandtrout ? ['sandtrout' as const] : []), { territory: previousTerritory }]);
|
||||
reach(state => !!state.spiceWindow && truthTurnThumperClean(state)); priorBlow = structuredClone(game.spiceWindow);
|
||||
assert.equal(priorBlow?.territory, previousTerritory); assert.equal(priorBlow?.sector, previousSector);
|
||||
reach(state => state.phase === 5 && state.active === target && truthTurnThumperClean(state));
|
||||
const before = structuredClone(game), from = actions.length;
|
||||
act(target, { type: 'ship', territory: previousTerritory, sector: previousSector, amount: bodyCount, elite: 0, allyPayment: 0 });
|
||||
reach(truthTurnThumperClean);
|
||||
priorShipment = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
|
||||
assert.equal(truthTurnThumperPlayer(before, target).spice - truthTurnThumperPlayer(game, target).spice, bodyCount * 2);
|
||||
assert.equal(truthTurnThumperPlayer(game, target).forces[previousLocation], bodyCount);
|
||||
act(target, { type: 'endMovement' });
|
||||
reach(state => state.turn === 2 && state.phase === 1 && !!state.phaseOpening);
|
||||
sourceOpening = structuredClone(game); openingAt = actions.length; reach(beforeIxSpiceDraw);
|
||||
secondStormSteps = structuredClone(actions.slice(firstRevealAt).filter(step => step.action.type === 'stormDial'));
|
||||
assert.deepEqual(secondStormSteps.map(step => step.action.amount), [1, 1]);
|
||||
assert.equal(game.storm, stormSectorAfter(firstBeforeReveal.storm, 2));
|
||||
assert.equal(truthTurnThumperPlayer(game, target).forces[previousLocation], bodyCount);
|
||||
assert.equal(truthTurnThumperPlayer(game, target).tanks, 0);
|
||||
assert.equal(game.sandtrout, waitingSandtrout);
|
||||
}
|
||||
const sourceOpeningSteps = structuredClone(actions.slice(openingAt));
|
||||
const revealOrder = orderIxSpiceCards(game, [{ territory: turn === 2 ? 'broken_land' : previousTerritory },
|
||||
...(advanced ? [{ territory: 'broken_land' }] : [])]);
|
||||
const opportunity = windowCardOpportunity(game, 'thumper'); assert.ok(opportunity);
|
||||
assert.equal(opportunity.source, `first-spice-reveal:${turn}`); assert.equal(game.turn, turn);
|
||||
assert.equal(game.thumperWindowOpen, true); assert.deepEqual(game.ready, []);
|
||||
assert.equal(viewGame(game, target).beforeSpiceDraw, true);
|
||||
assert.ok(truthTurnThumperPlayer(game, target).hand.some(card => card.id === thumper.id));
|
||||
assert.deepEqual(truthTurnThumperInventory(game), originalCards); assert.deepEqual(truthTurnThumperTraitors(game), originalTraitors);
|
||||
assert.deepEqual(truthTurnThumperSpice(game), originalSpice);
|
||||
const source = opportunity.source, mixed = truthTurnThumperGroup('and', truthTurnThumperPlay(source), truthTurnThumperPayment(recipient));
|
||||
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
|
||||
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTurnThumperPlayer(game, target).spice };
|
||||
return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, firstOpening, sourceOpening,
|
||||
sourceOpeningSteps, firstBeforeReveal, asker, target, recipient, observer, advanced, turn, waitingSandtrout, bodyCount,
|
||||
truths: truths.map(card => card.id), truth: truths[0].id, card: thumper.id, thumper: thumper.id,
|
||||
effect: 'thumper' as const, source, opportunity, mixed, question, spiceFact,
|
||||
nativeAction: { type: 'card', card: thumper.id } satisfies Action,
|
||||
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
|
||||
priorOrder, priorShipment, priorBlow, secondStormSteps, previousTerritory, previousSector, previousLocation, revealOrder, dealOrder,
|
||||
originalCards, originalTraitors, originalSpice, actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { selected: actions.length, sourceOpening: sourceOpeningSteps.length,
|
||||
priorShipment: priorShipment?.steps.length ?? 0, secondStormDials: secondStormSteps.length },
|
||||
sourceDisclosure: 'Actual public selected-Ix Harkonnen/Atreides/Emperor setup and original card inventories, including the original Cheap Hero Traitor. ' +
|
||||
'Only the original unopened four-card deal, specified unopened Spice prefixes and scalar lotteries are selected. All native Amal phase-opening passes execute. ' +
|
||||
(turn === 2 ? `Actually reveal original Hagga Basin Spice, pay ${bodyCount * 2} spice to ship ${bodyCount} original Emperor reserves there, collect ${bodyCount * 2} spice through the real first turn and submit two real second-turn one-sector Storm dials. The actual two-sector traversal leaves all ${bodyCount} counters alive. ` +
|
||||
(waitingSandtrout ? 'Original first-turn Sandtrout really enters its waiting state before that original land blow. ' : '')
|
||||
: 'Stop before the actual first-turn first reveal; existing provisional first-turn ignoring of the Thumper encounter remains unchanged. ') +
|
||||
'No hand, purse, force, phase, worm, waiting flag, casualty, revealed card, printed play or promise is installed. Action counts derive from actual recorded arrays.',
|
||||
};
|
||||
}
|
||||
export type TruthTurnThumperFixture = ReturnType<typeof createTruthTurnThumperFixture>;
|
||||
export function declareTruthTurnThumper(f: TruthTurnThumperFixture, source = f.game, copy: 0 | 1 = 0,
|
||||
actions: TruthTurnThumperStep[] = []) {
|
||||
let game = actTruthTurnThumper(source, f.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 = actTruthTurnThumper(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask'); return { game, actions };
|
||||
}
|
||||
export function askTruthTurnThumper(game: Game, asker: string, target: string, mixed: TruthTurnThumperExpression,
|
||||
actions: TruthTurnThumperStep[] = []): Game {
|
||||
return actTruthTurnThumper(game, asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
|
||||
} }, actions);
|
||||
}
|
||||
export function answerTruthTurnThumper(game: Game, target: string, answer: 'yes' | 'no' | 'unknown',
|
||||
actions: TruthTurnThumperStep[] = []): Game {
|
||||
return actTruthTurnThumper(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthTurnThumper(f: TruthTurnThumperFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
|
||||
source = f.game, copy: 0 | 1 = 0, actions: TruthTurnThumperStep[] = []) {
|
||||
const declared = declareTruthTurnThumper(f, source, copy, actions), asked = askTruthTurnThumper(declared.game, f.asker, f.target, mixed, actions);
|
||||
return { game: answerTruthTurnThumper(asked, f.target, answer, actions), declared, asked, actions };
|
||||
}
|
||||
export function truthTurnThumperPromise(game: Game, target = game.turnActionPromises![0].player) {
|
||||
const row = viewGame(game, target).turnActionPromises.find(candidate => candidate.player === target); assert.ok(row); return row;
|
||||
}
|
||||
export function closeTruthTurnThumper(game: Game, f: TruthTurnThumperFixture, actions: TruthTurnThumperStep[] = []): Game {
|
||||
return advanceTruthTurnThumper(game, state => truthTurnThumperClean(state) && !windowCardOpportunity(state, 'thumper', f.source), actions, true);
|
||||
}
|
||||
export function finishTruthTurnThumper(game: Game, actions: TruthTurnThumperStep[] = []): Game {
|
||||
const turn = game.turn;
|
||||
return advanceTruthTurnThumper(game, state => state.turn === turn + 1 && state.phase === 0 && truthTurnThumperClean(state), actions);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { TurnActionFields, turnActionInputError } from '../components/turn-actio
|
||||
import { TurnActionAnswers, TurnActionGuideButton, TurnActionPromises } from '../components/turn-action-promises';
|
||||
import { TruthFactFields } from '../components/mixed-shipment-fields';
|
||||
import { BattleClaimFields } from '../components/battle-promises';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { baseDeck, ixSpecialCards } from '../game/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';
|
||||
@@ -35,7 +35,8 @@ const printedCard = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind:
|
||||
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'
|
||||
: effect === 'karamaAuction' ? 'ordinary-auction:2:2' : effect === 'karamaShipment' ? shipmentPriceSource() : 'storm:2'): Expression =>
|
||||
: effect === 'karamaAuction' ? 'ordinary-auction:2:2' : effect === 'karamaShipment' ? shipmentPriceSource()
|
||||
: effect === 'thumper' ? 'first-spice-reveal:2' : 'storm:2'): Expression =>
|
||||
({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
|
||||
const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 3 }): Expression =>
|
||||
({ kind: 'action', action: { kind: 'battlePlan', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
|
||||
@@ -85,9 +86,9 @@ function projected(phase = 5, next: Action = { type: 'move', forces: { 'arrakeen
|
||||
game.turnActionGuide = { event, action: { ...next, turnActionPromiseEvent: event, turnActionPromisePhase: phase } };
|
||||
game.truthTurnActionAnswers = null;
|
||||
if (next.type === 'card' && typeof next.card === 'string') {
|
||||
const cardId = next.card, effect = (['hajr', 'ghola', 'weather', 'atomics', 'harvester'] as const)
|
||||
const cardId = next.card, effect = (['hajr', 'ghola', 'weather', 'atomics', 'harvester', 'thumper'] as const)
|
||||
.find(effect => cardId.includes(effect)) ?? 'karama';
|
||||
const face = baseDeck().find(card => card.effect === effect); assert.ok(face);
|
||||
const face = [...baseDeck(), ...ixSpecialCards()].find(card => card.effect === effect); assert.ok(face);
|
||||
const me = game.players.find(player => player.id === 'p')!;
|
||||
me.hand = [{ ...face, id: next.card }]; me.hajrAvailable = effect === 'hajr';
|
||||
if (effect === 'ghola') {
|
||||
@@ -124,6 +125,12 @@ function shipmentKarama(recipient = 'p', cunning?: string): GameView {
|
||||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('karamaShipment', shipmentPriceSource(recipient, cunning)), payment()] };
|
||||
return game;
|
||||
}
|
||||
function firstReveal(next: Action = { type: 'card', card: 'controlled-thumper' }): GameView {
|
||||
const game = projected(1, next); game.thumperWindowOpen = true; game.thumperReady = true; game.spiceWindow = null; game.nexus = false;
|
||||
game.thumperCards = ['controlled-thumper']; game.active = 'a';
|
||||
game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('thumper'), 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';
|
||||
@@ -1020,6 +1027,114 @@ void test('a current shipment-price question retains a quoted Box route for an o
|
||||
}
|
||||
});
|
||||
|
||||
void test('Thumper authoring uses the original public first-reveal source and preserves repairable shared logical drafts', () => {
|
||||
const game = firstReveal(), e = editor(physical(), false, game);
|
||||
for (const player of game.players) for (const key of ['hand', 'spice', 'forces', 'leaders', 'traitors'])
|
||||
Object.defineProperty(player, key, { get() { throw Error(`No private Thumper authoring ${key} read`); }, configurable: true });
|
||||
for (const key of ['thumperCards', 'spiceSequence', 'spiceResolution'])
|
||||
Object.defineProperty(game, key, { get() { throw Error(`No native Thumper authoring ${key} read`); }, configurable: true });
|
||||
select(e.tree(), '1', 'action', 'windowCardPlay'); assert.deepEqual(e.value, windowClaim('thumper')); assert.equal(e.error(), null);
|
||||
assert.match(e.html(), /Before the original first spice reveal/);
|
||||
game.nexus = true; game.response = { kind: 'voice', owner: 'a', passed: [] };
|
||||
assert.equal(e.error(), null, 'A temporary native control does not change the original public first-reveal source.');
|
||||
game.nexus = false; game.response = null;
|
||||
select(e.tree(), '1', 'window-source', ''); assert.ok(e.error());
|
||||
select(e.tree(), '1', 'window-source', 'first-spice-reveal:2'); assert.equal(e.error(), null);
|
||||
game.thumperWindowOpen = false; assert.ok(e.error()); assert.match(e.html(), /no current original open native window/);
|
||||
game.thumperWindowOpen = true;
|
||||
for (const extra of [{ amount: 0 }, { mode: 'shipment' }, { target: 'p' }])
|
||||
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'thumper', source: 'first-spice-reveal:2', ...extra } }, game, 'p'));
|
||||
const fullGame = firstReveal(), full = editor({ kind: 'or', terms: [windowClaim('thumper'), fact()] }, false, fullGame);
|
||||
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'windowCardPlay'); }
|
||||
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
|
||||
const deep = editor({ kind: 'and', terms: [fact(), windowClaim('thumper')] }, false, fullGame);
|
||||
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
|
||||
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||||
});
|
||||
|
||||
void test('Thumper play and same-source Ready guides forward exact native packets while first-turn history retains its printed warning', () => {
|
||||
const played = firstReveal(), sent: Action[] = [];
|
||||
Object.defineProperty(played.turnActionPromises[0], 'windowCards', { get() { throw Error('No private Thumper receipt read'); } });
|
||||
const button = guide({ game: played, busy: false, act(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], played.turnActionGuide!.action);
|
||||
assert.equal(sent[0].type, 'card'); assert.equal(sent[0].card, 'controlled-thumper'); assert.equal(sent[0].turnActionPromisePhase, 1);
|
||||
const alreadyReady = firstReveal(); alreadyReady.ready.push('p'); alreadyReady.thumperReady = false;
|
||||
let latePlay = 0; const lateThumper = guide({ game: alreadyReady, busy: false, act() { latePlay++; } });
|
||||
assert.equal(lateThumper.props.disabled, false); press(lateThumper); assert.equal(latePlay, 1, 'The native card quote can permit a held Thumper after own readiness.');
|
||||
const keeper = firstReveal({ type: 'ready' }), readied: Action[] = [];
|
||||
keeper.thumperCards = []; keeper.players.find(player => player.id === 'p')!.hand = [];
|
||||
keeper.turnActionPromises[0].answer = false; keeper.turnActionPromises[0].mixed = windowClaim('thumper');
|
||||
const ready = guide({ game: keeper, busy: false, act(action) { readied.push(action); } });
|
||||
assert.equal(ready.props.disabled, false); press(ready); assert.equal(readied[0], keeper.turnActionGuide!.action);
|
||||
assert.match(renderToStaticMarkup(ready), /Ready for the original first spice reveal/);
|
||||
keeper.ready.push('p'); press(ready); assert.equal(readied.length, 1);
|
||||
const summoned = firstReveal({ type: 'ready' }); summoned.nexus = true; summoned.thumperCards = [];
|
||||
Object.defineProperty(summoned, 'summonedBeforeBlow', { get() { throw Error('Use the native public Ready quote, not private summoned timing'); } });
|
||||
let summonedReady = 0; const quotedReady = guide({ game: summoned, busy: false, act() { summonedReady++; } });
|
||||
assert.equal(quotedReady.props.disabled, false); press(quotedReady); assert.equal(summonedReady, 1);
|
||||
const first = firstReveal(); first.turn = 1; first.turnActionGuide = null;
|
||||
first.turnActionPromises[0].turn = 1; first.turnActionPromises[0].mixed = windowClaim('thumper', 'first-spice-reveal:1');
|
||||
const html = markup(first); assert.match(html, /On turn 1, playing Thumper still discards the card/);
|
||||
assert.match(html, /worm encounter is ignored and the normal Spice Card draw continues/);
|
||||
assert.match(html, /closes when the actual first spice draw begins/); assert.doesNotMatch(html, /windowCards|controlled-thumper/);
|
||||
assert.match(editor(windowClaim('thumper', 'first-spice-reveal:1'), false, first).html(), /On turn 1, playing Thumper still discards the card/);
|
||||
});
|
||||
|
||||
void test('saved Thumper play and Ready callbacks reject original-source expiry and native modal changes', () => {
|
||||
for (const action of [{ type: 'card', card: 'controlled-thumper' }, { type: 'ready' }]) for (const change of [
|
||||
(game: GameView) => { game.thumperWindowOpen = false; },
|
||||
(game: GameView) => { game.thumperReady = false; },
|
||||
(game: GameView) => { game.nexus = true; },
|
||||
(game: GameView) => { game.spiceWindow = { event: 'later-native-blow', territory: 'carthag', sector: 11, amount: 6, harvested: false }; },
|
||||
(game: GameView) => { game.phaseOpening = { passed: [] }; },
|
||||
(game: GameView) => { game.response = { kind: 'voice', owner: 'a', passed: [] }; },
|
||||
(game: GameView) => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||||
(game: GameView) => { game.turnActionPromises[0].mixed = windowClaim('thumper', 'first-spice-reveal:1'); },
|
||||
]) {
|
||||
const game = firstReveal(action), saved = guide({ game, busy: false, act() { assert.fail('A stale first-reveal native action cannot send'); } });
|
||||
assert.equal(saved.props.disabled, false); change(game); press(saved);
|
||||
}
|
||||
for (const change of [
|
||||
(game: GameView) => { game.thumperCards = []; },
|
||||
(game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
|
||||
(game: GameView) => { const card = game.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card); card.effect = 'ghola'; },
|
||||
]) {
|
||||
const game = firstReveal(), saved = guide({ game, busy: false, act() { assert.fail('A changed Thumper quote or physical card cannot send'); } });
|
||||
assert.equal(saved.props.disabled, false); change(game); press(saved);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Thumper guides reject unsupported native packets, unavailable quotes and generic preparation fallback', () => {
|
||||
for (const extra of [{ amount: 0 }, { mode: 'shipment' }, { target: 'p' }, { leader: 'foreign-leader' }]) {
|
||||
const game = firstReveal(); Object.assign(game.turnActionGuide!.action, extra);
|
||||
const button = guide({ game, busy: false, act() { assert.fail('Thumper accepts only its exact native printed-play packet'); } });
|
||||
assert.equal(button.props.disabled, true); press(button);
|
||||
}
|
||||
for (const phase of [0, 2, 4, 5, 6, 8]) {
|
||||
const game = firstReveal(); game.phase = phase; game.active = 'p'; game.turnActionGuide!.action.turnActionPromisePhase = phase;
|
||||
const button = guide({ game, busy: false, act() { assert.fail('A held Thumper cannot become generic native preparation'); } });
|
||||
assert.equal(button.props.disabled, true); press(button);
|
||||
}
|
||||
const unquoted = firstReveal(); unquoted.thumperCards = [];
|
||||
assert.equal(guide({ game: unquoted, busy: false, act() { assert.fail('The root native quote must make Thumper available'); } }).props.disabled, true);
|
||||
const unrelated = firstReveal(); unrelated.turnActionPromises[0].mixed = payment();
|
||||
assert.equal(guide({ game: unrelated, busy: false, act() { assert.fail('Thumper requires its original own raw first-reveal source'); } }).props.disabled, true);
|
||||
const extraReady = firstReveal({ type: 'ready', amount: 0 });
|
||||
assert.equal(guide({ game: extraReady, busy: false, act() { assert.fail('First-reveal Ready accepts no extra native fields'); } }).props.disabled, true);
|
||||
const unavailableReady = firstReveal({ type: 'ready' }); unavailableReady.thumperReady = false;
|
||||
assert.equal(guide({ game: unavailableReady, busy: false, act() { assert.fail('An open source alone cannot invent a native Ready stage'); } }).props.disabled, true);
|
||||
});
|
||||
|
||||
void test('Thumper first-reveal questions retain the quoted phase-one Box route before the actual first draw', () => {
|
||||
const game = firstReveal(), me = game.players.find(player => player.id === 'p')!;
|
||||
const box = { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' };
|
||||
me.hand = [box]; game.nullentropy = { card: box, blocked: null, search: null };
|
||||
game.turnActionGuide!.action = { type: 'card', card: box.id, turnActionPromiseEvent: event, turnActionPromisePhase: 1 };
|
||||
const sent: Action[] = [], 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);
|
||||
game.thumperWindowOpen = false; press(button); assert.equal(sent.length, 1);
|
||||
});
|
||||
|
||||
void test('turn-action printed-role editor uses public names without holdings or private stages and retains the shared logical budget', () => {
|
||||
const game = projected();
|
||||
for (const player of game.players) for (const key of ['hand', 'spice', 'traitors', 'faceDancers', 'tanks', 'leaders', 'hajrAvailable'])
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { spiceDeck, treacheryDeck } from '../game/cards';
|
||||
import { stormSectorAfter } from '../game/disaster-rules';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { CHEAP_HERO_TRAITOR, traitorDeck } from '../game/traitors';
|
||||
import { truthFactAnswer } from '../game/truthtrance';
|
||||
import { windowCardOpportunity } from '../game/window-card-play';
|
||||
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
|
||||
import {
|
||||
actTruthTurnThumper as act, advanceTruthTurnThumper as advance, answerTruthTurnThumper as answer,
|
||||
askTruthTurnThumper as ask, bindTruthTurnThumper as bind, closeTruthTurnThumper as close,
|
||||
createTruthTurnThumperFixture as fixture, declareTruthTurnThumper as declare, finishTruthTurnThumper as finish,
|
||||
nextTruthTurnThumperNative as nativeStep, settleTruthTurnThumper as settle,
|
||||
truthTurnThumperClean as clean, truthTurnThumperFact as fact, truthTurnThumperGroup as group,
|
||||
truthTurnThumperInventory as inventory, truthTurnThumperMove as move, truthTurnThumperPayment as payment,
|
||||
truthTurnThumperPlay as play, truthTurnThumperPlayer as player, truthTurnThumperPromise as promise,
|
||||
truthTurnThumperSpice as spice, truthTurnThumperTraitors as traitors,
|
||||
type TruthTurnThumperFixture, type TruthTurnThumperStep,
|
||||
} from './fixture-truth-turn-thumper';
|
||||
|
||||
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: TruthTurnThumperFixture, copies: 1 | 2 = 1) {
|
||||
for (const truth of f.truths.slice(0, copies)) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors);
|
||||
assert.deepEqual(spice(game), f.originalSpice);
|
||||
}
|
||||
function receipt(game: Game, index = 0) {
|
||||
const stage = game.turnActionPromises![index].windowCards?.thumper; assert.ok(stage); return stage;
|
||||
}
|
||||
|
||||
void test('genuine Thumper sources keep the complete selected-Ix inventories and actual opening or earned-body chronology', () => {
|
||||
for (const options of [{}, { advanced: true }, { turn: 2 as const }, { turn: 2 as const, waitingSandtrout: true }]) {
|
||||
const f = fixture(options); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
||||
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
||||
assert.deepEqual(f.originalCards, treacheryDeck(['ix']).map(card => card.id).sort());
|
||||
assert.deepEqual(f.originalTraitors, traitorDeck(f.setup.players, true).sort()); assert.ok(f.originalTraitors.includes(CHEAP_HERO_TRAITOR));
|
||||
assert.equal(f.originalCards.length, 47); assert.equal(f.originalSpice.length, spiceDeck(true).length);
|
||||
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
|
||||
assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), [f.thumper]);
|
||||
assert.equal(f.game.phase, 1); assert.equal(f.game.turn, f.turn); assert.equal(f.game.thumperWindowOpen, true);
|
||||
assert.deepEqual(f.game.ready, []); assert.equal(viewGame(f.game, f.target).beforeSpiceDraw, true);
|
||||
assert.deepEqual(windowCardOpportunity(f.game, 'thumper'), f.opportunity);
|
||||
assert.equal(f.source, `first-spice-reveal:${f.turn}`); assert.ok(f.sourceOpening.phaseOpening);
|
||||
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.selected, f.sourceActions.length);
|
||||
assert.equal(f.sourceCounts.sourceOpening, f.sourceOpeningSteps.length);
|
||||
assert.ok(f.sourceOpeningSteps.filter(step => step.action.type === 'ready').length >= f.game.players.length);
|
||||
assert.deepEqual(viewGame(f.game, f.target).thumperCards, [f.thumper]);
|
||||
assert.deepEqual(viewGame(f.game, f.asker).thumperCards, []);
|
||||
if (f.turn === 2) {
|
||||
assert.ok(f.priorShipment && f.priorBlow); assert.equal(f.sourceCounts.priorShipment, f.priorShipment.steps.length);
|
||||
assert.equal(player(f.game, f.target).forces[f.previousLocation], 2); assert.equal(player(f.game, f.target).tanks, 0);
|
||||
assert.equal(player(f.game, f.target).spice, 10, 'Real shipment4 and native collection4 retain the original ten');
|
||||
assert.deepEqual(f.secondStormSteps.map(step => step.action.amount), [1, 1]);
|
||||
assert.equal(f.sourceCounts.secondStormDials, f.secondStormSteps.length);
|
||||
assert.equal(f.game.storm, stormSectorAfter(f.firstBeforeReveal.storm, 2));
|
||||
assert.equal(f.game.sandtrout, f.waitingSandtrout);
|
||||
}
|
||||
assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
|
||||
assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
|
||||
assert.deepEqual(spice(f.game), f.originalSpice);
|
||||
}
|
||||
});
|
||||
|
||||
void test('printed first-turn Thumper AND payment freezes facts and counts the real cost even when the encounter is ignored', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = fixture({ advanced }), mixed = group('and', fact(f.spiceFact), play(f.source), payment(f.recipient));
|
||||
let game = bind(f, 'yes', mixed).game; assert.deepEqual(receipt(game), { source: f.source, played: false, closed: false });
|
||||
game = act(game, f.target, f.payment); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
|
||||
game = settle(act(game, f.target, f.nativeAction));
|
||||
assert.equal(game.discard.filter(card => card.id === f.thumper).length, 1);
|
||||
assert.equal(receipt(game).played, true); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.equal(player(game, f.target).spice, 8); assert.equal(player(game, f.recipient).bribes, 2);
|
||||
assert.equal(game.nexus, false); assert.equal(player(game, f.target).tanks, 0); assert.equal(game.thumperWindowOpen, false);
|
||||
assert.ok(game.log.some(entry => /first-turn worm rule ignores this Thumper/.test(entry.text)));
|
||||
assert.equal(game.spiceDiscard.flat().some(card => 'worm' in card && card.thumper), false);
|
||||
assert.equal(game.spiceWindow?.territory, f.previousTerritory); assert.equal(game.spiceWindow?.amount, 6);
|
||||
physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a real later Thumper kills the two genuinely shipped previous-pile counters before the next original land blow', () => {
|
||||
const f = fixture({ turn: 2 }); let game = bind(f).game;
|
||||
game = act(game, f.target, f.payment); const reserves = player(game, f.target).reserves;
|
||||
game = settle(act(game, f.target, f.nativeAction));
|
||||
assert.equal(player(game, f.target).forces[f.previousLocation] ?? 0, 0); assert.equal(player(game, f.target).tanks, 2);
|
||||
assert.equal(player(game, f.target).reserves, reserves); assert.equal(game.spice[f.previousLocation] ?? 0, 0);
|
||||
assert.equal(game.nexus, true); assert.equal(game.spiceWindow?.territory, 'broken_land'); assert.equal(game.spiceWindow?.amount, 8);
|
||||
assert.equal(receipt(game).played, true); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
});
|
||||
|
||||
void test('earned waiting Sandtrout can suppress the real later Thumper while its printed cost still fulfills', () => {
|
||||
const f = fixture({ turn: 2, waitingSandtrout: true }); assert.equal(f.game.sandtrout, true);
|
||||
let game = bind(f, 'yes', play(f.source)).game; game = settle(act(game, f.target, f.nativeAction));
|
||||
assert.equal(game.sandtrout, false); assert.equal(game.nexus, false);
|
||||
assert.equal(player(game, f.target).forces[f.previousLocation], 2); assert.equal(player(game, f.target).tanks, 0);
|
||||
assert.equal(game.spiceWindow?.territory, 'broken_land'); assert.equal(game.spiceWindow?.amount, 16);
|
||||
assert.equal(receipt(game).played, true); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.equal(game.discard.filter(card => card.id === f.thumper).length, 1); physical(game, f);
|
||||
});
|
||||
|
||||
void test('whole Thumper OR payment Yes preserves the actual printed route and the manual payment route through normal reveal', () => {
|
||||
const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient));
|
||||
const bound = bind(f, 'yes', mixed).game;
|
||||
const played = settle(act(bound, f.target, f.nativeAction));
|
||||
assert.equal(promise(played, f.target).fulfilled, true); assert.equal(player(played, f.recipient).bribes, 0);
|
||||
let paid = act(bound, f.target, f.payment); paid = close(paid, f);
|
||||
assert.equal(promise(paid, f.target).fulfilled, true); assert.equal(receipt(paid).played, false);
|
||||
assert.ok(player(paid, f.target).hand.some(card => card.id === f.thumper)); physical(played, f); physical(paid, f);
|
||||
});
|
||||
|
||||
void test('whole Thumper AND payment No accepts either first action and guards only the actual completed predicate', () => {
|
||||
const f = fixture();
|
||||
for (const first of ['payment', 'card'] as const) {
|
||||
let game = bind(f, 'no').game;
|
||||
if (first === 'payment') { game = act(game, f.target, f.payment); rejected(game, f.target, f.nativeAction); }
|
||||
else {
|
||||
game = settle(act(game, f.target, f.nativeAction)); rejected(game, f.target, f.payment);
|
||||
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
|
||||
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
|
||||
}
|
||||
game = close(game, f); assert.ok(!promise(game, f.target).released);
|
||||
game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('whole Thumper OR payment No retains its real cumulative cap after the original first draw', () => {
|
||||
const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient));
|
||||
let game = bind(f, 'no', mixed).game; rejected(game, f.target, f.nativeAction); rejected(game, f.target, f.payment);
|
||||
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); game = close(game, f);
|
||||
assert.deepEqual(receipt(game), { source: f.source, played: false, closed: true });
|
||||
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
|
||||
game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
});
|
||||
|
||||
void test('a skipped first-reveal Thumper OR remains payable through actual late Mentat', () => {
|
||||
const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient));
|
||||
let game = close(bind(f, 'yes', mixed).game, f);
|
||||
const evidence = structuredClone(receipt(game)); assert.deepEqual(evidence, { source: f.source, played: false, closed: true });
|
||||
assert.ok(!promise(game, f.target).fulfilled); assert.ok(player(game, f.target).hand.some(card => card.id === f.thumper));
|
||||
game = advance(game, state => state.phase === 8 && clean(state), [], true);
|
||||
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, f.payment);
|
||||
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(receipt(game), evidence); physical(game, f);
|
||||
});
|
||||
|
||||
void test('definite Thumper answers reopen actual Ready and own acceptance cannot skip a required cost', () => {
|
||||
const f = fixture(), ready = act(f.game, f.target, { type: 'ready' }); assert.ok(ready.ready.includes(f.target));
|
||||
let game = bind(f, 'yes', play(f.source), ready).game;
|
||||
assert.equal(game.ready.includes(f.target), false); rejected(game, f.target, { type: 'ready' });
|
||||
game = act(game, f.asker, { type: 'ready' }); game = act(game, f.observer, { type: 'ready' });
|
||||
assert.equal(game.thumperWindowOpen, true); rejected(game, f.target, { type: 'ready' });
|
||||
game = settle(act(game, f.target, f.nativeAction)); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
const declined = bind(f, 'no', play(f.source), ready).game; assert.equal(declined.ready.includes(f.target), false);
|
||||
const completed = close(act(declined, f.target, { type: 'ready' }), f);
|
||||
assert.equal(promise(completed, f.target).fulfilled, true); assert.equal(receipt(completed).played, false); physical(completed, f);
|
||||
for (const [selected, mixed] of [
|
||||
['yes', group('or', fact(f.spiceFact), play(f.source))],
|
||||
['no', group('and', fact({ kind: 'spice', compare: 'gte', value: 100 }), play(f.source))],
|
||||
] as const) {
|
||||
const answered = bind(f, selected, mixed, ready).game; assert.equal(answered.ready.includes(f.target), false); physical(answered, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('real Amal opening and Truthtrance modal passes preserve the original first-reveal source', () => {
|
||||
const f = fixture(); let game = structuredClone(f.sourceOpening);
|
||||
while (game.phaseOpening || game.response) {
|
||||
assert.equal(windowCardOpportunity(game, 'thumper')?.source, f.source); assert.equal(game.thumperWindowOpen, true);
|
||||
const next = nativeStep(game); assert.ok(next); game = act(game, next.actor, next.action);
|
||||
}
|
||||
assert.equal(beforeIxSpiceDraw(game), true); assert.deepEqual(game.ready, []);
|
||||
const declared = declare(f, game); assert.equal(windowCardOpportunity(declared.game, 'thumper')?.source, f.source);
|
||||
const asked = ask(declared.game, f.asker, f.target, play(f.source));
|
||||
assert.equal(windowCardOpportunity(asked, 'thumper')?.source, f.source);
|
||||
game = answer(asked, f.target, 'yes'); game = settle(act(game, f.target, f.nativeAction));
|
||||
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
});
|
||||
|
||||
void test('exact Thumper guides reject stale source, owner and event while a real first reveal permanently closes that source', () => {
|
||||
const f = fixture(), declared = declare(f);
|
||||
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, mixed: play('first-spice-reveal:2') } });
|
||||
rejected(declared.game, f.target, { type: 'truthAsk', question: f.question });
|
||||
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, mixed: { kind: 'action', action: {
|
||||
kind: 'windowCardPlay', effect: 'thumper', source: f.source, worm: true,
|
||||
} } } });
|
||||
let game = bind(f).game; game = act(game, f.target, f.payment);
|
||||
const own = viewGame(game, f.target), guide = own.turnActionGuide; assert.ok(guide);
|
||||
assert.deepEqual(Object.keys(guide.action).sort(), ['type', 'card', 'turnActionPromiseEvent', 'turnActionPromisePhase'].sort());
|
||||
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.thumper);
|
||||
assert.equal(guide.event, promise(game, f.target).event); assert.equal(guide.action.turnActionPromiseEvent, guide.event);
|
||||
assert.equal(guide.action.turnActionPromisePhase, 1); assert.deepEqual(own.thumperCards, [f.thumper]);
|
||||
assert.deepEqual(viewGame(game, f.asker).thumperCards, []);
|
||||
assert.ok(Object.keys(promise(game, f.target)).every(key => ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'].includes(key)));
|
||||
rejected(game, f.asker, guide.action);
|
||||
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-thumper-event' });
|
||||
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 0 });
|
||||
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, packet));
|
||||
rejected(game, f.target, packet); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
const closed = close(f.game, f); assert.equal(closed.thumperWindowOpen, false);
|
||||
assert.equal(windowCardOpportunity(closed, 'thumper'), null); rejected(closed, f.target, f.nativeAction);
|
||||
const after = declare(f, closed);
|
||||
rejected(after.game, f.asker, { type: 'truthAsk', question: f.question });
|
||||
});
|
||||
|
||||
void test('an original first-turn No does not forbid a later printed Thumper and second actual Truth in the next reveal window', () => {
|
||||
const f = fixture(), actions: TruthTurnThumperStep[] = [];
|
||||
let game = close(bind(f, 'no', play(f.source), f.game, 0, actions).game, f, actions);
|
||||
const earlier = structuredClone(receipt(game)); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), actions, true);
|
||||
game = settle(act(game, f.target, { type: 'ship', territory: f.previousTerritory, sector: f.previousSector,
|
||||
amount: 2, elite: 0, allyPayment: 0 }, actions), actions);
|
||||
game = act(game, f.target, { type: 'endMovement' }, actions);
|
||||
game = advance(game, state => state.turn === 2 && beforeIxSpiceDraw(state), actions, true);
|
||||
assert.deepEqual(actions.filter(step => step.action.type === 'stormDial').map(step => step.action.amount), [1, 1]);
|
||||
assert.equal(player(game, f.target).forces[f.previousLocation], 2); assert.equal(player(game, f.target).tanks, 0);
|
||||
orderIxSpiceCards(game, [{ territory: 'broken_land' }]);
|
||||
const next = windowCardOpportunity(game, 'thumper'); assert.ok(next); assert.notEqual(next.source, f.source);
|
||||
game = bind(f, 'yes', play(next.source), game, 1, actions).game;
|
||||
game = settle(act(game, f.target, f.nativeAction, actions), actions);
|
||||
assert.equal(game.turnActionPromises![1].fulfilled, true); assert.equal(receipt(game, 1).played, true);
|
||||
assert.deepEqual(receipt(game), earlier); assert.equal(receipt(game).played, false);
|
||||
assert.equal(player(game, f.target).tanks, 2); assert.equal(game.discard.filter(card => card.id === f.thumper).length, 1);
|
||||
assert.ok(actions.length > f.sourceActionCount); physical(game, f, 2);
|
||||
});
|
||||
|
||||
void test('the earned three-counter move baseline is real but mandatory later Thumper cannot preserve it after paying the current purse', () => {
|
||||
const f = fixture({ turn: 2, bodyCount: 3 }), amount = player(f.game, f.target).spice;
|
||||
assert.ok(f.priorShipment); assert.equal(f.bodyCount, 3);
|
||||
assert.equal(player(f.priorShipment.before, f.target).spice - player(f.priorShipment.after, f.target).spice, 6);
|
||||
assert.equal(player(f.game, f.target).forces[f.previousLocation], 3); assert.equal(player(f.game, f.target).tanks, 0);
|
||||
assert.equal(amount, 10); assert.equal(f.sourceCounts.priorShipment, f.priorShipment.steps.length);
|
||||
const baseline = group('and', move('polar_sink', 3), payment(f.recipient, amount));
|
||||
let game = bind(f, 'yes', baseline).game; game = act(game, f.target, { type: 'bribe', target: f.recipient, amount });
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), [], true);
|
||||
assert.equal(player(game, f.target).forces[f.previousLocation], 3);
|
||||
game = settle(act(game, f.target, { type: 'move', from: f.previousLocation, territory: 'polar_sink', sector: 0, amount: 3 }));
|
||||
assert.equal(player(game, f.target).forces['polar_sink:0'], 3); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
|
||||
let promised = bind(f, 'yes', baseline).game;
|
||||
promised = act(promised, f.target, { type: 'bribe', target: f.recipient, amount });
|
||||
rejected(promised, f.target, f.nativeAction);
|
||||
assert.equal(player(promised, f.target).forces[f.previousLocation], 3);
|
||||
const mixed = group('and', play(f.source), move('polar_sink', 3), payment(f.recipient, amount));
|
||||
const asked = ask(declare(f).game, f.asker, f.target, mixed);
|
||||
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
|
||||
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
let declined = answer(asked, f.target, 'no'); assert.equal(receipt(declined).played, false);
|
||||
declined = act(declined, f.target, { type: 'bribe', target: f.recipient, amount });
|
||||
declined = settle(act(declined, f.target, f.nativeAction));
|
||||
assert.equal(player(declined, f.target).forces[f.previousLocation] ?? 0, 0); assert.equal(player(declined, f.target).tanks, 3);
|
||||
assert.equal(receipt(declined).played, true); assert.ok(!promise(declined, f.target).released); physical(declined, f);
|
||||
});
|
||||
|
||||
void test('waiting Sandtrout three-counter answers ignore the hidden next Spice Card while an actual later Worm releases the impossible remainder', () => {
|
||||
const f = fixture({ turn: 2, waitingSandtrout: true, bodyCount: 3 }), amount = player(f.game, f.target).spice;
|
||||
assert.ok(f.priorShipment); assert.equal(f.bodyCount, 3);
|
||||
assert.equal(player(f.priorShipment.before, f.target).spice - player(f.priorShipment.after, f.target).spice, 6);
|
||||
assert.equal(amount, 10); assert.equal(f.sourceCounts.priorShipment, f.priorShipment.steps.length);
|
||||
const mixed = group('and', play(f.source), move('polar_sink', 3), payment(f.recipient, amount));
|
||||
for (const next of ['land', 'worm'] as const) {
|
||||
const source = structuredClone(f.game);
|
||||
orderIxSpiceCards(source, [...(next === 'worm' ? ['worm' as const] : []), { territory: 'broken_land' }]);
|
||||
assert.deepEqual(spice(source), f.originalSpice); assert.equal(source.sandtrout, true);
|
||||
assert.equal(player(source, f.target).forces[f.previousLocation], 3); assert.equal(player(source, f.target).tanks, 0);
|
||||
const asked = ask(declare(f, source).game, f.asker, f.target, mixed);
|
||||
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['yes', 'no'], next);
|
||||
let game = answer(asked, f.target, 'yes');
|
||||
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount });
|
||||
assert.equal(player(game, f.target).spice, 0);
|
||||
const paid = structuredClone(game.turnActionPromises![0].payments), history = structuredClone(game.truthHistory!.at(-1));
|
||||
game = settle(act(game, f.target, f.nativeAction));
|
||||
assert.equal(receipt(game).played, true); assert.equal(game.discard.filter(card => card.id === f.thumper).length, 1);
|
||||
assert.equal(game.sandtrout, false); assert.deepEqual(game.turnActionPromises![0].payments, paid);
|
||||
assert.deepEqual(game.truthHistory!.at(-1), history);
|
||||
if (next === 'worm') {
|
||||
assert.equal(player(game, f.target).forces[f.previousLocation] ?? 0, 0); assert.equal(player(game, f.target).tanks, 3);
|
||||
assert.equal(promise(game, f.target).released, true); assert.ok(!promise(game, f.target).fulfilled);
|
||||
} else {
|
||||
assert.equal(player(game, f.target).forces[f.previousLocation], 3); assert.equal(player(game, f.target).tanks, 0);
|
||||
assert.ok(!promise(game, f.target).released); assert.ok(!promise(game, f.target).fulfilled);
|
||||
assert.equal(game.spiceWindow?.territory, 'broken_land'); assert.equal(game.spiceWindow?.amount, 16);
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), [], true);
|
||||
game = settle(act(game, f.target, { type: 'move', from: f.previousLocation, territory: 'polar_sink', sector: 0, amount: 3 }));
|
||||
assert.equal(player(game, f.target).forces['polar_sink:0'], 3); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.ok(!promise(game, f.target).released); assert.deepEqual(game.turnActionPromises![0].payments, paid);
|
||||
assert.deepEqual(game.truthHistory!.at(-1), history);
|
||||
}
|
||||
physical(game, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles play actual first-turn Thumper guides and accept No with native first-reveal Ready', () => {
|
||||
const f = fixture(), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(asked); const actions: TruthTurnThumperStep[] = [];
|
||||
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, difficulty);
|
||||
assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
|
||||
for (let count = 0; count < 18 && !promise(game, f.target).fulfilled; count++) {
|
||||
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const guide = view.turnActionGuide; assert.ok(guide, difficulty);
|
||||
const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event);
|
||||
assert.ok(action, difficulty); assert.deepEqual(action, guide.action); game = act(game, f.target, action, actions);
|
||||
}
|
||||
assert.equal(promise(game, f.target).fulfilled, true, difficulty);
|
||||
assert.ok(actions.some(step => step.action.type === 'card' && step.action.card === f.thumper)); physical(game, f);
|
||||
const no = bind(f, 'no', play(f.source)).game, view = viewGame(no, f.target);
|
||||
view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const ready = botActions(view)[0]; assert.equal(ready.type, 'ready'); assert.deepEqual(ready, view.turnActionGuide?.action);
|
||||
const completed = close(act(no, f.target, ready), f);
|
||||
assert.equal(promise(completed, f.target).fulfilled, true); assert.equal(receipt(completed).played, false); physical(completed, f);
|
||||
}
|
||||
});
|
||||
@@ -14,11 +14,22 @@ import { botActions, runBots } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
truthFactAnswer,
|
||||
truthQuestionText,
|
||||
type TruthFact,
|
||||
type TruthQuestion,
|
||||
} from '../game/truthtrance';
|
||||
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
|
||||
|
||||
void test('whole card questions name Thumper and the actual printed Karama role and shipment beneficiary', () => {
|
||||
const question = (effect: 'thumper' | 'karamaAuction' | 'karamaShipment', source: string) =>
|
||||
truthQuestionText({ kind: 'turnActions', target: 'emperor', turn: 2, mixed: { kind: 'action', action: {
|
||||
kind: 'windowCardPlay', effect, source,
|
||||
} } }, id => id, id => id, id => id === 'emperor' ? 'Emperor' : id);
|
||||
assert.match(question('thumper', 'first-spice-reveal:2'), /Thumper.*original first Spice reveal.*ignored or suppressed/);
|
||||
assert.match(question('karamaAuction', 'ordinary-auction:2:1'), /Karama.*original ordinary auction lot.*winning invoice/);
|
||||
assert.match(question('karamaShipment', '["karamaShipment",2,"emperor","original"]'), /Karama.*Emperor’s original unused shipment price/);
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
const g = createGame('TRUTH22', newPlayer('a', 'Atreides', 'atreides'));
|
||||
g.players.push(
|
||||
|
||||
@@ -61,7 +61,7 @@ const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expressi
|
||||
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
|
||||
const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment'
|
||||
? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
|
||||
effect === 'harvester' ? 'spice-blow:1' : 'storm:1';
|
||||
effect === 'thumper' ? 'first-spice-reveal:1' : effect === 'harvester' ? 'spice-blow:1' : 'storm:1';
|
||||
const windowCard = (effect: WindowCardEffect = 'weather', source = windowSource(effect)): Expression =>
|
||||
({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
|
||||
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
|
||||
@@ -1520,3 +1520,55 @@ void test('closing a shipment-price opportunity retains actual play and later wh
|
||||
assert.deepEqual(expired.windowCards!.karamaShipment, { source, played: false, closed: true });
|
||||
validateTurnActionPromises([expired], seats);
|
||||
});
|
||||
|
||||
void test('Thumper receipt means accepted printed play after this answer and carries no worm result or chosen destination', () => {
|
||||
const original = windowed(windowCard('thumper'));
|
||||
const leaf = { kind: 'windowCardPlay', effect: 'thumper', source: 'first-spice-reveal:1' };
|
||||
for (const extra of [{ worm: true }, { territory: 'carthag' }, { suppressed: true }, { card: 'physical-thumper' }])
|
||||
assert.throws(() => parseTurnActionLeaf({ ...leaf, ...extra }), TurnActionError);
|
||||
const played = recordTurnActionWindowCardPlay([original], 'respondent', 1, 'thumper', 'first-spice-reveal:1')[0];
|
||||
assert.equal(played.fulfilled, true, 'No worm effect or successful outcome is part of the receipt');
|
||||
assert.equal(original.windowCards!.thumper!.played, false);
|
||||
const fresh = windowed(windowCard('thumper'), true, 'turn-action:2');
|
||||
assert.equal(fresh.windowCards!.thumper!.played, false);
|
||||
assert.equal(closeTurnActionWindowCardPlay([fresh], 'respondent', 1, 'thumper', 'first-spice-reveal:1')[0].fulfilled, false);
|
||||
assert.equal(recordTurnActionWindowCardPlay([played], 'respondent', 1, 'thumper', 'first-spice-reveal:1')[0], played);
|
||||
validateTurnActionPromises([played], seats);
|
||||
});
|
||||
|
||||
void test('Thumper No binds only the exact actor turn and original first reveal until actual source closure', () => {
|
||||
const no = windowed(windowCard('thumper'), false);
|
||||
for (const [player, turn, source] of [['other', 1, 'first-spice-reveal:1'],
|
||||
['respondent', 2, 'first-spice-reveal:1'], ['respondent', 1, 'first-spice-reveal:2']] as const) {
|
||||
assert.equal(recordTurnActionWindowCardPlay([no], player, turn, 'thumper', source)[0], no);
|
||||
assert.equal(closeTurnActionWindowCardPlay([no], player, turn, 'thumper', source)[0], no);
|
||||
}
|
||||
assert.equal(closeTurnActionPayments([no], 'respondent', 1)[0].fulfilled, false);
|
||||
assert.throws(() => recordTurnActionWindowCardPlay([no], 'respondent', 1, 'thumper', 'first-spice-reveal:1'), TurnActionError);
|
||||
assert.equal(no.windowCards!.thumper!.played, false);
|
||||
const closed = closeTurnActionWindowCardPlay([no], 'respondent', 1, 'thumper', 'first-spice-reveal:1')[0];
|
||||
assert.equal(closed.fulfilled, true);
|
||||
assert.equal(recordTurnActionWindowCardPlay([closed], 'respondent', 1, 'thumper', 'first-spice-reveal:1')[0], closed);
|
||||
validateTurnActionPromises([closed], seats);
|
||||
});
|
||||
|
||||
void test('Thumper whole AND No and OR Yes retain their later payment correlation after first-reveal closure', () => {
|
||||
const no = windowed(group('and', windowCard('thumper'), pay()), false);
|
||||
const granted = recordTurnActionWindowCardPlay([no], 'respondent', 1, 'thumper', 'first-spice-reveal:1');
|
||||
const closed = closeTurnActionWindowCardPlay(granted, 'respondent', 1, 'thumper', 'first-spice-reveal:1');
|
||||
assert.equal(closed[0].fulfilled, false);
|
||||
assert.deepEqual(branches(closed[0]), [[signedPay('recipient', 3, 0, false)]]);
|
||||
assert.throws(() => recordTurnActionPayment(closed, 'respondent', 1, payment()), TurnActionError);
|
||||
assert.deepEqual(closed[0].payments, []);
|
||||
const expiry = finishTurnActionTurn(closed, 'respondent', 1)[0];
|
||||
assert.equal(expiry.fulfilled, true);
|
||||
const alternative = windowed(group('or', windowCard('thumper'), pay()), true, 'turn-action:2');
|
||||
const absent = closeTurnActionWindowCardPlay([alternative], 'respondent', 1, 'thumper', 'first-spice-reveal:1');
|
||||
assert.deepEqual(branches(absent[0]), [[signedPay()]]);
|
||||
assert.equal(recordTurnActionPayment(absent, 'respondent', 1, payment())[0].fulfilled, true);
|
||||
const and = windowed(group('and', windowCard('thumper'), pay()), true, 'turn-action:3');
|
||||
const paid = recordTurnActionPayment([and], 'respondent', 1, payment());
|
||||
assert.equal(paid[0].fulfilled, false);
|
||||
assert.equal(recordTurnActionWindowCardPlay(paid, 'respondent', 1, 'thumper', 'first-spice-reveal:1')[0].fulfilled, true);
|
||||
validateTurnActionPromises([expiry], seats);
|
||||
});
|
||||
@@ -23,8 +23,44 @@ function shipments(): WindowCardGame {
|
||||
}
|
||||
|
||||
void test('window roles expose a shared exhaustive tuple and exact native printed-card mapping', () => {
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'thumper']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'thumper']);
|
||||
});
|
||||
|
||||
void test('Thumper binds the public first-reveal entitlement without any hidden deck or physical-card source', () => {
|
||||
const source = { ...storm(), phase: 1, thumperWindowOpen: true }, before = structuredClone(source);
|
||||
assert.deepEqual(windowCardOpportunity(source, 'thumper'), { effect: 'thumper', source: 'first-spice-reveal:3', turn: 3 });
|
||||
assert.equal(windowCardOpportunity(source, 'thumper', 'first-spice-reveal:3')!.source, 'first-spice-reveal:3');
|
||||
assert.equal(windowCardOpportunity(source, 'thumper', 'first-spice-reveal:4'), null);
|
||||
assert.equal(windowCardOpportunity({ ...source, turn: 4 }, 'thumper')!.source, 'first-spice-reveal:4');
|
||||
assert.deepEqual(source, before);
|
||||
});
|
||||
|
||||
void test('an explicitly open first-reveal entitlement survives temporary controls and summoned-worm or Nexus state', () => {
|
||||
const source = { ...storm(), phase: 1, thumperWindowOpen: true };
|
||||
for (const waiting of [{ response: {} }, { decision: {} }, { truthtrance: {} }, { phaseOpening: {} },
|
||||
{ nexus: true }, { spiceResolution: { kind: 'summonedWorm' } }, { spiceSequence: { skipped: [] } },
|
||||
{ spiceWindow: blow().spiceWindow }])
|
||||
assert.equal(windowCardOpportunity({ ...source, ...waiting }, 'thumper')!.source, 'first-spice-reveal:3');
|
||||
});
|
||||
|
||||
void test('first real reveal or printed Thumper closes the first-reveal source through later Nexus and second-blow windows', () => {
|
||||
const source = { ...storm(), phase: 1, thumperWindowOpen: false };
|
||||
for (const later of [{}, { nexus: true }, { spiceSequence: {} }, { spiceResolution: {} },
|
||||
{ spiceWindow: blow().spiceWindow }, { response: {} }])
|
||||
assert.equal(windowCardOpportunity({ ...source, ...later }, 'thumper'), null);
|
||||
for (const invalid of [{ phase: 0 }, { phase: 2 }, { status: 'setup' }, { turn: 0 }, { turn: 1.5 },
|
||||
{ turn: Number.MAX_SAFE_INTEGER + 1 }, { thumperWindowOpen: 'yes' }, { thumperWindowOpen: null }])
|
||||
assert.equal(windowCardOpportunity({ ...source, thumperWindowOpen: true, ...invalid } as WindowCardGame, 'thumper'), null);
|
||||
});
|
||||
|
||||
void test('legacy absent Thumper flag retains the original native clean first-reveal fallback', () => {
|
||||
const source = { ...storm(), phase: 1 };
|
||||
assert.equal(windowCardOpportunity(source, 'thumper')!.source, 'first-spice-reveal:3');
|
||||
for (const started of [{ spiceSequence: {} }, { spiceResolution: {} }, { spiceWindow: blow().spiceWindow }, { nexus: true }])
|
||||
assert.equal(windowCardOpportunity({ ...source, ...started }, 'thumper'), null);
|
||||
for (const waiting of [{ response: {} }, { decision: {} }, { truthtrance: {} }, { phaseOpening: {} }])
|
||||
assert.equal(windowCardOpportunity({ ...source, ...waiting }, 'thumper')!.source, 'first-spice-reveal:3');
|
||||
});
|
||||
|
||||
void test('shipment-price windows retain every unused original entitlement while new questions select only the active recipient', () => {
|
||||
|
||||
Reference in new issue
Block a user