Bind Thumper promises before the original hidden Spice draw

This commit is contained in:
Quintine committed 2026-10-27 04:33:20 +11:00
1 parent fd38c51ffd
commit 00741a37d0
19 files changed
+985 -34

No files matched your search

+211
View File
@@ -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);
}
+119 -4
View File
@@ -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'])
+315
View File
@@ -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);
}
});
+11
View File
@@ -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(
+53 -1
View File
@@ -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);
});
+38 -2
View File
@@ -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', () => {