Bind Truthtrance card plays to original Storm and spice windows

This commit is contained in:
Quintine committed 2026-10-27 02:25:46 +11:00
1 parent 09a81a9b65
commit e88f871385
20 files changed
+1519 -26

No files matched your search

+8
View File
@@ -82,6 +82,14 @@ function agrees(effect: Effect, change: (g: Game) => void, expected: boolean) {
void test('the four shared availability checks agree with legal server effects', () => {
for (const effect of effects) agrees(effect, () => {}, true);
});
void test('Storm cards close at actual movement even while the Storm phase continues', () => {
for (const effect of ['weather', 'atomics'] as const) {
agrees(effect, game => { game.stormCardWindowOpen = false; }, false);
agrees(effect, game => {
game.stormResolution = { from: game.storm, distance: 1, traversed: 0, pending: [] };
}, false);
}
});
void test('all four cards report pending table windows and reject without mutation', () => {
const pauses: ((g: Game) => void)[] = [
(g) => {
+204
View File
@@ -0,0 +1,204 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
import { windowCardOpportunity, type WindowCardEffect } 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 { TruthTurnPowerLeaf } from './fixture-truth-turn-power';
export type TruthTurnWindowCardLeaf = TruthTurnPowerLeaf |
{ kind: 'cardPlay'; effect: 'hajr' } |
{ kind: 'windowCardPlay'; effect: WindowCardEffect; source: string };
export type TruthTurnWindowCardExpression = MixedActionExpression<TruthTurnWindowCardLeaf>;
export type TruthTurnWindowCardStep = TruthTurnActionStep;
export const actTruthTurnWindowCard = actTruthTurnAction;
export const truthTurnWindowCardPlayer = truthTurnActionPlayer;
export const truthTurnWindowCardInventory = truthTurnActionInventory;
export const truthTurnWindowCardTraitors = truthTraitorDecisionTraitors;
export const truthTurnWindowCardClean = (game: Game) => !game.truthtrance && !game.response && !game.phaseOpening &&
!game.decision && !game.pendingTreacheryDiscard && !game.pendingRevival && !game.pendingShipment;
export const truthTurnWindowCardFact = (fact: TruthFact): TruthTurnWindowCardExpression => ({ kind: 'fact', fact });
export const truthTurnWindowCardPayment = (recipient: string, minimum = 2): TruthTurnWindowCardExpression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
export const truthTurnWindowCardPlay = (effect: WindowCardEffect, source: string): TruthTurnWindowCardExpression =>
({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
export const truthTurnWindowCardGroup = (kind: 'and' | 'or', ...terms: TruthTurnWindowCardExpression[]): TruthTurnWindowCardExpression =>
({ kind, terms });
export function nextTruthTurnWindowCardNative(game: Game): TruthTurnWindowCardStep | null {
if (game.truthtrance) return null;
return nextStrongholdFactionsNativeStep(game);
}
export function nextTruthTurnWindowCard(game: Game): TruthTurnWindowCardStep | 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 nextTruthTurnWindowCardNative(game);
}
export function advanceTruthTurnWindowCard(game: Game, until: (state: Game) => boolean,
actions: TruthTurnWindowCardStep[] = [], nativeOnly = false): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original window-card source ended before its native boundary');
const next = nativeOnly ? nextTruthTurnWindowCardNative(game) : nextTruthTurnWindowCard(game);
assert.ok(next, 'Supply the original Truthtrance question or native private plan');
game = actTruthTurnWindowCard(game, next.actor, next.action, actions);
}
throw Error('The genuine window-card chronology did not reach its finite boundary');
}
export function settleTruthTurnWindowCard(game: Game, actions: TruthTurnWindowCardStep[] = []): Game {
return advanceTruthTurnWindowCard(game, truthTurnWindowCardClean, actions, true);
}
/** Genuine original Basic A/H/E base33. Only original unopened deal/Spice
* positions and scalar lotteries are chosen. Atomics earns actual Shield Wall
* casualties by paying for two original reserves during the preceding turn. */
export function createTruthTurnWindowCardFixture(options: {
effect?: WindowCardEffect; window?: 'beforeDials' | 'afterReveal'; advanced?: boolean;
pairedTruthAndHajr?: boolean;
} = {}) {
const effect = options.effect ?? 'weather', window = options.window ?? 'beforeDials';
const advanced = options.advanced ?? false;
const pairedTruthAndHajr = options.pairedTruthAndHajr ?? false;
assert.ok(!advanced || effect === 'harvester', 'This bounded Advanced adapter covers original dual Spice blows only');
assert.ok(!pairedTruthAndHajr || effect === 'weather', 'The paired original Truth/Hajr deal belongs to this Weather source');
const asker = 'atreides', target = 'harkonnen', observer = 'emperor', recipient = asker;
let game = createGame('TRUTHTURNWINDOWCARD', newPlayer(asker, 'Atreides', 'atreides'), advanced);
joinGame(game, newPlayer(target, 'Harkonnen', 'harkonnen'));
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: TruthTurnWindowCardStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthTurnWindowCard(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthTurnWindowCard(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.equal(game.deck.length, 33);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalCards = truthTurnWindowCardInventory(game), originalTraitors = truthTurnWindowCardTraitors(game);
const truth = game.deck.find(card => card.effect === 'truthtrance');
const printed = game.deck.find(card => card.effect === effect);
const filler = game.deck.find(card => pairedTruthAndHajr ? card.effect === 'hajr' : card.kind === 'projectile');
const other = game.deck.find(card => pairedTruthAndHajr ? card.effect === 'truthtrance' && card.id !== truth?.id
: effect === 'atomics' ? card.effect === 'weather' : card.kind === 'shield');
assert.ok(truth && printed && filler && other);
const deal = [truth.id, printed.id, filler.id, other.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(truthTurnWindowCardPlayer(game, asker).hand.map(card => card.id), [truth.id]);
assert.deepEqual(truthTurnWindowCardPlayer(game, target).hand.map(card => card.id), [printed.id, filler.id]);
assert.deepEqual(truthTurnWindowCardPlayer(game, observer).hand.map(card => card.id), [other.id]);
assert.deepEqual(truthTurnWindowCardPlayer(game, target).forces, { 'carthag:11': 10 });
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
let spiceOrder: ReturnType<typeof orderIxSpiceCards> = [], beforeBlow: Game | null = null;
let wallShipment: { before: Game; steps: TruthTurnWindowCardStep[]; after: Game } | null = null;
let beforeSecondStorm: Game | null = null;
if (effect !== 'weather') {
reach(beforeIxSpiceDraw);
const land = game.spiceDeck.filter(card => 'territory' in card && !card.discovery && card.sector !== game.storm)
.slice(0, advanced ? 2 : 1);
assert.equal(land.length, advanced ? 2 : 1);
spiceOrder = orderIxSpiceCards(game, land.map(card => { assert.ok('territory' in card); return { territory: card.territory }; }));
beforeBlow = structuredClone(game);
if (effect === 'harvester') reach(state => !!state.spiceWindow && truthTurnWindowCardClean(state));
else {
reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthTurnWindowCardClean(state));
const before = structuredClone(game), from = actions.length;
act(target, { type: 'ship', territory: 'shield_wall', sector: 9, amount: 2, elite: 0, allyPayment: 0 });
reach(truthTurnWindowCardClean);
wallShipment = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
assert.equal(truthTurnWindowCardPlayer(before, target).spice - truthTurnWindowCardPlayer(game, target).spice, 4);
assert.equal(truthTurnWindowCardPlayer(game, target).forces['shield_wall:9'], 2);
act(target, { type: 'endMovement' });
reach(state => state.turn === 2 && state.phase === 0 && truthTurnWindowCardClean(state));
beforeSecondStorm = structuredClone(game);
}
} else reach(state => state.phase === 0 && truthTurnWindowCardClean(state));
const beforeDials = structuredClone(game), beforeDialsActions = structuredClone(actions);
if (effect !== 'harvester' && window === 'afterReveal')
reach(state => state.phase === 0 && state.stormPending !== null && truthTurnWindowCardClean(state));
const opportunity = windowCardOpportunity(game, effect); assert.ok(opportunity);
assert.equal(game.phase, effect === 'harvester' ? 1 : 0);
if (effect !== 'harvester') assert.equal(game.stormCardWindowOpen, true);
if (effect === 'atomics') {
assert.ok(wallShipment); assert.equal(game.turn, 2); assert.equal(game.shieldWallDestroyed, false);
assert.equal(truthTurnWindowCardPlayer(game, target).forces['shield_wall:9'], 2);
assert.equal(truthTurnWindowCardPlayer(game, target).spice, 6);
}
assert.ok(truthTurnWindowCardPlayer(game, target).hand.some(card => card.id === printed.id));
assert.deepEqual(truthTurnWindowCardInventory(game), originalCards);
assert.deepEqual(truthTurnWindowCardTraitors(game), originalTraitors);
const source = opportunity.source, nativeAction: Action = { type: 'card', card: printed.id,
...(effect === 'weather' ? { amount: 0 } : {}) };
const mixed = truthTurnWindowCardGroup('and', truthTurnWindowCardPlay(effect, source), truthTurnWindowCardPayment(recipient));
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTurnWindowCardPlayer(game, target).spice };
return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, asker, target, observer, recipient,
effect, source, opportunity, window, advanced, pairedTruthAndHajr,
truth: truth.id, card: printed.id, filler: filler.id, foreignCard: other.id,
hajr: pairedTruthAndHajr ? filler.id : null,
nativeAction, payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action, mixed, question, spiceFact,
originalCards, originalTraitors, originalSpice, dealOrder, spiceOrder, beforeBlow,
beforeDials, beforeDialsActions, wallShipment, beforeSecondStorm,
originalBlow: structuredClone(game.spiceWindow ?? null),
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { selected: actions.length, beforeDials: beforeDialsActions.length,
actualWallShipment: wallShipment?.steps.length ?? 0 },
sourceDisclosure: `Actual public ${advanced ? 'Advanced preview' : 'Basic'} Atreides/Harkonnen/Emperor base33 ready/start, native private Traitors and starting forces. ` +
'Only original unopened starting Treachery positions, safe original unopened land Spice positions when used, and scalar lotteries are selected. ' +
(pairedTruthAndHajr
? 'Atreides and Emperor each receive a different original Truthtrance; Harkonnen really receives original Weather Control and Hajr through its native two-card start. '
: 'Atreides receives one original Truthtrance; Harkonnen really receives its printed role and a distinct original projectile through its native two-card start. ') +
(effect === 'atomics' ? 'Harkonnen pays four spice for two original reserve counters on Shield Wall9, actually ends Movement and completes the first turn before original Storm2; those two real counters remain available for actual Atomics casualties. Emperor holds original Weather Control. '
: effect === 'harvester' ? 'Complete original Storm1 and actually draw the selected original safe land Spice card; stop at its fresh native blow and original event. ' +
(advanced ? 'The original second safe land card remains next for the actual Advanced second-blow continuation. ' : '')
: 'Stop at original Storm1 before any dial or after the actual original dial reveal, before storm movement. ') +
'Every count comes from its recorded action array. No hand, purse, army, phase, wall, spice, clock, printed play or promise is installed.',
};
}
export type TruthTurnWindowCardFixture = ReturnType<typeof createTruthTurnWindowCardFixture>;
export function declareTruthTurnWindowCard(f: TruthTurnWindowCardFixture, source = f.game,
actions: TruthTurnWindowCardStep[] = []) {
let game = actTruthTurnWindowCard(source, f.asker, { type: 'card', card: f.truth }, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthTurnWindowCard(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask'); return { game, actions };
}
export function askTruthTurnWindowCard(game: Game, asker: string, target: string, mixed: TruthTurnWindowCardExpression,
actions: TruthTurnWindowCardStep[] = []): Game {
return actTruthTurnWindowCard(game, asker, { type: 'truthAsk', question: {
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
} }, actions);
}
export function answerTruthTurnWindowCard(game: Game, target: string, answer: 'yes' | 'no' | 'unknown',
actions: TruthTurnWindowCardStep[] = []): Game {
return actTruthTurnWindowCard(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthTurnWindowCard(f: TruthTurnWindowCardFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
source = f.game, actions: TruthTurnWindowCardStep[] = []) {
const declared = declareTruthTurnWindowCard(f, source, actions);
const asked = askTruthTurnWindowCard(declared.game, f.asker, f.target, mixed, actions);
return { game: answerTruthTurnWindowCard(asked, f.target, answer, actions), declared, asked, actions };
}
export function truthTurnWindowCardPromise(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 closeTruthTurnWindowCard(game: Game, f: TruthTurnWindowCardFixture,
actions: TruthTurnWindowCardStep[] = []): Game {
return advanceTruthTurnWindowCard(game,
state => truthTurnWindowCardClean(state) && windowCardOpportunity(state, f.effect)?.source !== f.source, actions, true);
}
export function finishTruthTurnWindowCard(game: Game, actions: TruthTurnWindowCardStep[] = []): Game {
const turn = game.turn;
return advanceTruthTurnWindowCard(game, state => state.turn === turn + 1 && state.phase === 0 && truthTurnWindowCardClean(state), actions);
}
+172 -1
View File
@@ -11,6 +11,7 @@ import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView,
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { MixedBattleActionClaim } from '../game/mixed-battle-question';
import type { BattlePowerClaim } from '../game/battle-power-question';
import type { WindowCardEffect } from '../game/window-card-play';
import { createRevivalOrder } from '../game/revival-order';
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
import type { TurnActionLeaf } from '../game/turn-action-promises';
@@ -31,6 +32,8 @@ const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum =
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
const finalAlliance = (partner = 'r'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
const printedCard = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow' : '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 } });
const traitorClaim = (call = true): Expression =>
@@ -79,7 +82,8 @@ 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 effect = next.card.includes('hajr') ? 'hajr' : next.card.includes('ghola') ? 'ghola' : 'karama';
const cardId = next.card, effect = (['hajr', 'ghola', 'weather', 'atomics', 'harvester'] as const)
.find(effect => cardId.includes(effect)) ?? 'karama';
const face = baseDeck().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';
@@ -127,6 +131,16 @@ function battlePower(power: 'voice' | 'prescience' = 'voice', next?: Action, all
game.turnActionPromises[0].mixed = { kind: 'or', terms: [powerClaim({ kind: power, use: true }), payment()] };
return game;
}
function windowCard(effect: WindowCardEffect = 'weather', next?: Action): GameView {
const game = projected(effect === 'harvester' ? 1 : 0, next ?? { type: 'card', card: `controlled-${effect}`,
...(effect === 'weather' ? { amount: 4 } : {}) });
game.stormCardWindowOpen = true; game.shieldWallDestroyed = false; game.stormPending = null;
game.spiceWindow = effect === 'harvester' ? { event: 'controlled-native-blow', territory: 'carthag', sector: 11,
amount: 6, harvested: false } : null;
game.players.find(player => player.id === 'p')!.forces = { 'shield_wall:8': 1 };
game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim(effect), payment()] };
return game;
}
function revival(next: Action = { type: 'revive', amount: 3, elite: 1 }): GameView {
const game = projected(4, next);
game.revivalOrder = createRevivalOrder({ turn: 2, order: ['p', 'a', 'r'], ready: [] });
@@ -634,6 +648,163 @@ void test('native power history keeps raw declarations distinct from effects and
assert.doesNotMatch(html, /controlled-native-battle|battlePowers|declarations|private-card/);
});
void test('window-role editor and raw history use original public sources without hidden holdings or eligibility reads', () => {
const game = windowCard(), e = editor(physical(), false, game);
for (const player of game.players) for (const key of ['hand', 'spice', 'forces', 'advisors', 'tanks', 'leaders', 'traitors'])
Object.defineProperty(player, key, { get() { throw Error(`No private window authoring ${key} read`); }, configurable: true });
for (const key of ['windowCards', 'compiled', 'cardPlays'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} read`); }, configurable: true });
select(e.tree(), '1', 'action', 'windowCardPlay'); assert.deepEqual(e.value, windowClaim()); assert.equal(e.error(), null);
select(e.tree(), '1', 'window-effect', 'atomics'); assert.deepEqual(e.value, windowClaim('atomics')); assert.equal(e.error(), null);
game.shieldWallDestroyed = true; assert.ok(e.error()); assert.match(e.html(), /no current original open native window/);
select(e.tree(), '1', 'window-effect', 'weather'); assert.equal(e.error(), null, 'Weather needs no wall or private forces.');
game.stormCardWindowOpen = false; assert.ok(e.error()); game.stormCardWindowOpen = true;
select(e.tree(), '1', 'window-effect', 'harvester'); assert.ok(e.error());
game.phase = 1; game.spiceWindow = { event: 'new-original-blow', territory: 'carthag', sector: 11, amount: 6, harvested: false };
select(e.tree(), '1', 'window-source', 'new-original-blow'); assert.deepEqual(e.value, windowClaim('harvester', 'new-original-blow'));
assert.equal(e.error(), null);
delete game.spiceWindow.event; assert.ok(e.error()); select(e.tree(), '1', 'window-source', 'legacy-spice-blow:2:carthag:11');
assert.deepEqual(e.value, windowClaim('harvester', 'legacy-spice-blow:2:carthag:11')); assert.equal(e.error(), null);
game.spiceWindow.harvesterClosed = true; assert.ok(e.error()); game.spiceWindow.harvesterClosed = false;
game.turnActionGuide = null; game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim(), windowClaim('atomics'), windowClaim('harvester')] };
const html = markup(game); assert.match(html, /Weather Control/); assert.match(html, /Family Atomics/); assert.match(html, /Harvester/);
assert.match(html, /recovery alone do not count/); assert.match(html, /original native window closes/);
assert.match(html, /do not promise a selected storm distance or resulting spice amount/);
assert.doesNotMatch(html, /windowCards|compiled|controlled-weather/);
});
void test('window-role editor retains strict repairable drafts and the shared sixteen-leaf four-level budget', () => {
const game = windowCard(), e = editor(windowClaim(), false, game);
select(e.tree(), '1', 'window-source', ''); assert.ok(e.error()); assert.deepEqual(e.value, windowClaim('weather', ''));
select(e.tree(), '1', 'window-source', 'storm:2'); assert.equal(e.error(), null);
for (const action of [{ kind: 'windowCardPlay', effect: 'weather', source: 'storm:2', amount: 4 },
{ kind: 'windowCardPlay', effect: 'ghola', source: 'storm:2' },
{ kind: 'windowCardPlay', effect: 'weather', source: 'storm:1' }])
assert.ok(turnActionInputError({ kind: 'action', action }, game, 'p'));
const full = editor({ kind: 'or', terms: [windowClaim(), fact()] }, false, game);
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/); assert.equal(control(full.tree(), 'add-action-1').props.disabled, true);
const deep = editor({ kind: 'and', terms: [fact(), windowClaim()] }, false, game);
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/);
const frozen = editor(windowClaim(), true, game); select(frozen.tree(), '1', 'window-effect', 'atomics');
select(frozen.tree(), '1', 'window-source', ''); assert.equal(frozen.changes, 0);
});
void test('printed window guides forward exact owned Weather, Atomics and Harvester native packets out of turn', () => {
for (const effect of ['weather', 'atomics', 'harvester'] as const) {
const game = windowCard(effect), sent: Action[] = []; game.active = 'a';
for (const key of ['windowCards', 'compiled', 'cardPlays'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} guide read`); }, configurable: true });
const button = guide({ game, busy: false, act(action) { sent.push(action); } }); assert.equal(button.props.disabled, false); press(button);
assert.equal(sent[0], game.turnActionGuide!.action); assert.equal(sent[0].turnActionPromiseEvent, event);
assert.equal(sent[0].turnActionPromisePhase, effect === 'harvester' ? 1 : 0);
assert.match(renderToStaticMarkup(button), /original native window/);
}
for (const amount of [0, 10]) {
const game = windowCard('weather', { type: 'card', card: 'controlled-weather', amount });
let sent = 0; const button = guide({ game, busy: false, act() { sent++; } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent, 1);
}
});
void test('saved window-card callbacks reject source closure, public window mutation, physical card changes and modal handoffs', () => {
for (const effect of ['weather', 'atomics', 'harvester'] as const) for (const change of [
(game: GameView) => { if (effect === 'harvester') game.spiceWindow!.event = 'later-blow'; else game.stormCardWindowOpen = false; },
(game: GameView) => { if (effect === 'harvester') game.spiceWindow!.amount *= 2; else game.stormPending = 7; },
(game: GameView) => { game.turnActionPromises[0].mixed = windowClaim(effect, 'different-original-source'); },
(game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
(game: GameView) => { game.players.find(player => player.id === 'p')!.hand![0].effect = 'ghola'; },
(game: GameView) => { game.response = { kind: 'voice', owner: 'a', passed: [] }; },
(game: GameView) => { game.truthtrance = answering(game.phase).truthtrance; },
]) {
const game = windowCard(effect), button = guide({ game, busy: false, act() { assert.fail('A stale window card cannot send'); } });
assert.equal(button.props.disabled, false); change(game); press(button);
}
const game = windowCard('harvester'), button = guide({ game, busy: false, act() { assert.fail('A replaced original blow cannot send'); } });
game.spiceWindow = { ...game.spiceWindow! }; press(button);
const atomics = windowCard('atomics'), atomicButton = guide({ game: atomics, busy: false, act() { assert.fail('Changed native Atomics eligibility cannot send'); } });
atomics.players.find(player => player.id === 'p')!.forces = {}; press(atomicButton);
});
void test('window-role guides reject unsupported packets, missing native eligibility and generic card fallback', () => {
for (const amount of [undefined, -1, 11, 1.5, NaN, Infinity]) {
const game = windowCard('weather', { type: 'card', card: 'controlled-weather', amount });
const button = guide({ game, busy: false, act() { assert.fail('An invalid Weather distance cannot send'); } });
assert.equal(button.props.disabled, true); press(button);
}
for (const effect of ['weather', 'atomics', 'harvester'] as const) {
for (const extra of [{ mode: 'shipment' }, { leader: 'foreign-leader' }, { forces: { 'shield_wall:8': 1 } },
...(effect === 'weather' ? [] : [{ amount: 2 }])]) {
const game = windowCard(effect); Object.assign(game.turnActionGuide!.action, extra);
const button = guide({ game, busy: false, act() { assert.fail('An unsupported native window packet cannot send'); } });
assert.equal(button.props.disabled, true); press(button);
}
for (const phase of [2, 4, 5, 6]) {
const game = windowCard(effect); game.phase = phase; game.turnActionGuide!.action.turnActionPromisePhase = phase;
const button = guide({ game, busy: false, act() { assert.fail('A window role cannot bypass validation through generic preparation'); } });
assert.equal(button.props.disabled, true); press(button);
}
const wrongSource = windowCard(effect); wrongSource.turnActionPromises[0].mixed = payment();
assert.equal(guide({ game: wrongSource, busy: false, act() { assert.fail('A printed window guide needs its original raw source'); } }).props.disabled, true);
}
const atomics = windowCard('atomics'); atomics.players.find(player => player.id === 'p')!.forces = {};
assert.equal(guide({ game: atomics, busy: false, act() { assert.fail('Atomics needs actual owned native forces'); } }).props.disabled, true);
});
void test('early owned Box guides require the exact quoted Box and live original global question window', () => {
for (const effect of ['weather', 'harvester'] as const) {
const game = windowCard(effect), me = game.players.find(player => player.id === 'p')!;
me.hand = [{ id: 'controlled-box', name: 'Nullentropy Box', kind: 'special', effect: 'nullentropyBox' }];
game.nullentropy = { card: me.hand[0], blocked: null, search: null };
game.turnActionGuide!.action = { type: 'card', card: me.hand[0].id, turnActionPromiseEvent: event, turnActionPromisePhase: game.phase };
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.nullentropy.blocked = 'The native Box opening is no longer legal.'; press(button); assert.equal(sent.length, 1);
game.nullentropy.blocked = null;
if (effect === 'harvester') game.spiceWindow!.harvesterClosed = true; else game.stormCardWindowOpen = false;
press(button); assert.equal(sent.length, 1);
const unrelated = windowCard(effect); unrelated.turnActionPromises[0].mixed = payment();
unrelated.players.find(player => player.id === 'p')!.hand = me.hand;
unrelated.nullentropy = { card: me.hand[0], blocked: null, search: null };
unrelated.turnActionGuide!.action = { ...game.turnActionGuide!.action };
assert.equal(guide({ game: unrelated, busy: false, act() { assert.fail('An unrelated row cannot create a new early Box control'); } }).props.disabled, true);
}
});
void test('window Ready guides retain the exact live raw source, known Storm distance and fresh-blow callback ownership', () => {
const readyWindow = (effect: WindowCardEffect) => {
const game = windowCard(effect); game.stormPending = effect === 'harvester' ? null : 4;
game.turnActionGuide!.action = { type: 'ready', turnActionPromiseEvent: event, turnActionPromisePhase: game.phase };
if (effect === 'harvester') { game.turnActionPromises[0].answer = false; game.turnActionPromises[0].mixed = windowClaim(effect); }
Object.defineProperty(game.turnActionPromises[0], 'windowCards', { get() { throw Error('Ready must not read private window receipts'); } });
return game;
};
for (const effect of ['weather', 'atomics', 'harvester'] as const) {
const game = readyWindow(effect), sent: Action[] = [];
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
assert.match(renderToStaticMarkup(button), /Accept this .* window while preserving your whole answer/);
game.ready.push('p'); press(button); assert.equal(sent.length, 1, 'An accepted window cannot be reopened by the saved Ready callback.');
for (const change of [
(game: GameView) => { game.turnActionPromises[0].mixed = windowClaim(effect, 'later-original-source'); },
(game: GameView) => { if (effect === 'harvester') game.spiceWindow!.event = 'later-blow'; else game.stormCardWindowOpen = false; },
(game: GameView) => { if (effect === 'harvester') game.spiceWindow!.harvesterClosed = true; else game.stormPending = null; },
]) {
const stale = readyWindow(effect), saved = guide({ game: stale, busy: false, act() { assert.fail('A changed original Ready source cannot send'); } });
assert.equal(saved.props.disabled, false); change(stale); press(saved);
}
const extra = readyWindow(effect); extra.turnActionGuide!.action.amount = 4;
assert.equal(guide({ game: extra, busy: false, act() { assert.fail('Window Ready accepts no extra native fields'); } }).props.disabled, true);
const unrelated = readyWindow(effect); unrelated.turnActionPromises[0].mixed = payment();
assert.equal(guide({ game: unrelated, busy: false, act() { assert.fail('Window Ready needs its own original raw window clause'); } }).props.disabled, true);
}
const unknownStorm = readyWindow('weather'); unknownStorm.stormPending = null;
assert.equal(guide({ game: unknownStorm, busy: false, act() { assert.fail('Storm Ready waits for a known native movement'); } }).props.disabled, true);
const replaced = readyWindow('harvester'), saved = guide({ game: replaced, busy: false, act() { assert.fail('A replaced fresh blow cannot accept an old Ready callback'); } });
replaced.spiceWindow = { ...replaced.spiceWindow! }; press(saved);
});
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,336 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import { windowCardOpportunity, type WindowCardEffect } from '../game/window-card-play';
import {
actTruthTurnWindowCard as act, advanceTruthTurnWindowCard as advance,
askTruthTurnWindowCard as ask, bindTruthTurnWindowCard as bind, closeTruthTurnWindowCard as close,
createTruthTurnWindowCardFixture as fixture, declareTruthTurnWindowCard as declare,
finishTruthTurnWindowCard as finish, nextTruthTurnWindowCardNative as nativeStep,
settleTruthTurnWindowCard as settle, truthTurnWindowCardClean as clean,
truthTurnWindowCardFact as fact, truthTurnWindowCardGroup as group,
truthTurnWindowCardInventory as inventory, truthTurnWindowCardPayment as payment,
truthTurnWindowCardPlay as play, truthTurnWindowCardPlayer as player,
truthTurnWindowCardPromise as promise, truthTurnWindowCardTraitors as traitors,
type TruthTurnWindowCardExpression, type TruthTurnWindowCardFixture, type TruthTurnWindowCardStep,
} from './fixture-truth-turn-window-card';
const effects: WindowCardEffect[] = ['weather', 'atomics', 'harvester'];
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: TruthTurnWindowCardFixture) {
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors);
}
function receipt(game: Game, f: TruthTurnWindowCardFixture) {
const stage = game.turnActionPromises![0].windowCards?.[f.effect]; assert.ok(stage); return stage;
}
void test('window-card sources use original native Harkonnen deals, earned Wall casualties and fresh blow identities', () => {
for (const effect of effects) {
const f = fixture({ effect }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.equal(f.setup.deck.length, 33); assert.equal(f.originalCards.length, 33); assert.equal(f.originalTraitors.length, 15);
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.deepEqual(player(f.afterSetup, f.target).hand.map(card => card.id), [f.card, f.filler]);
assert.ok(player(f.game, f.asker).hand.some(card => card.id === f.truth));
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.card));
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.selected, f.actions.length);
assert.equal(f.sourceCounts.beforeDials, f.beforeDialsActions.length);
assert.deepEqual(windowCardOpportunity(f.game, effect), f.opportunity);
assert.equal(f.game.advanced, false); assert.deepEqual(f.game.expansions, []);
assert.ok(!f.game.nexusCards && !f.game.homeworlds && !f.game.leaderSkills && !f.game.strongholdCards);
assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
if (effect === 'atomics') {
assert.ok(f.wallShipment); assert.equal(f.game.turn, 2);
assert.equal(f.sourceCounts.actualWallShipment, f.wallShipment.steps.length);
assert.ok(f.wallShipment.steps.some(step => step.actor === f.target && step.action.type === 'ship' && step.action.amount === 2));
assert.equal(player(f.game, f.target).spice, 6); assert.equal(player(f.game, f.target).forces['shield_wall:9'], 2);
} else if (effect === 'harvester') {
assert.ok(f.originalBlow?.event); assert.equal(f.source, f.originalBlow.event);
assert.equal(f.originalBlow.harvested, false); assert.ok(f.beforeBlow);
} else {
assert.equal(f.source, 'storm:1'); assert.equal(f.game.stormPending, null); assert.equal(f.game.stormCardWindowOpen, true);
}
assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
}
});
void test('each named original-window role AND payment freezes the fact and one actual play satisfies repeated correlated leaves', () => {
for (const effect of effects) {
const f = fixture({ effect }), role = play(effect, f.source);
const mixed = group('and', fact(f.spiceFact), role, role, payment(f.recipient));
let game = bind(f, 'yes', mixed).game;
assert.deepEqual(receipt(game, f), { source: f.source, played: false, closed: false });
game = act(game, f.target, f.payment);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
const beforePlay = structuredClone(game); game = settle(act(game, f.target, f.nativeAction));
assert.equal(receipt(game, f).played, true); assert.equal(promise(game, f.target).fulfilled, true);
assert.deepEqual(promise(game, f.target).mixed, mixed); assert.equal(player(game, f.recipient).bribes, 2);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(beforePlay, f.target).spice, player(game, f.target).spice, 'The printed role adds no invented spice fee');
if (effect === 'weather') assert.equal(game.stormPending, 0);
if (effect === 'atomics') {
assert.equal(game.shieldWallDestroyed, true); assert.equal(player(game, f.target).forces['shield_wall:9'] ?? 0, 0);
assert.equal(player(game, f.target).tanks - player(beforePlay, f.target).tanks, 2);
assert.equal(player(game, f.target).reserves, player(beforePlay, f.target).reserves);
assert.equal(player(game, f.target).forces['carthag:11'], 10);
}
if (effect === 'harvester') {
const blow = f.originalBlow!; const key = `${blow.territory}:${blow.sector}`;
assert.equal(game.spiceWindow?.event, f.source); assert.equal(game.spiceWindow?.amount, blow.amount * 2);
assert.equal(game.spice[key] - (beforePlay.spice[key] ?? 0), blow.amount);
}
physical(game, f);
}
});
void test('Weather Control promises preserve manual typed distances zero through ten before dials and after real reveal', () => {
for (const window of ['beforeDials', 'afterReveal'] as const) {
const f = fixture({ effect: 'weather', window });
assert.equal(f.game.stormPending === null, window === 'beforeDials');
const bound = bind(f).game;
for (const amount of [-1, 11, 1.5, '5', null])
rejected(bound, f.target, { type: 'card', card: f.card, amount });
for (const amount of [0, 5, 10]) {
let game = act(bound, f.target, { type: 'card', card: f.card, amount });
assert.equal(game.stormPending, amount); assert.equal(game.stormMovementSource?.kind, 'weather');
assert.equal(receipt(game, f).played, true); assert.ok(!promise(game, f.target).fulfilled);
game = settle(game); game = act(game, f.target, f.payment);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
}
});
void test('whole printed-role AND payment No preserves either order and rejects only the actual completed predicate', () => {
for (const effect of effects) {
const f = fixture({ effect });
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 });
assert.equal(player(game, f.recipient).bribes, 1);
}
assert.ok(!promise(game, f.target).released); game = finish(close(game, f));
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
}
});
void test('whole printed-role OR payment No caps both real actions until the original window and turn actually close', () => {
for (const effect of effects) {
const f = fixture({ effect }), mixed = group('or', play(effect, 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.equal(receipt(game, f).closed, true); assert.equal(receipt(game, f).played, false);
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
assert.ok(!promise(game, f.target).fulfilled); game = finish(game);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('a skipped printed window leaves its manual payment OR alive through actual late Mentat', () => {
for (const effect of effects) {
const f = fixture({ effect }), mixed = group('or', play(effect, 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); physical(played, f);
let game = close(bound, f);
assert.deepEqual(receipt(game, f), { source: f.source, played: false, closed: true });
assert.ok(player(game, f.target).hand.some(card => card.id === f.card));
assert.ok(!promise(game, f.target).fulfilled); const evidence = structuredClone(receipt(game, f));
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, f), evidence); physical(game, f);
}
});
void test('definite window answers reopen actual own Ready including constants and cannot skip a required printed play', () => {
for (const effect of effects) {
const f = fixture({ effect, window: 'afterReveal' });
const ready = act(f.game, f.target, { type: 'ready' }); assert.ok(ready.ready.includes(f.target));
let game = bind(f, 'yes', f.mixed, ready).game;
assert.equal(game.ready.includes(f.target), false); rejected(game, f.target, { type: 'ready' });
game = act(game, f.target, f.payment); 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 constantYes = group('or', fact(f.spiceFact), play(effect, f.source));
const constantNo = group('and', fact({ kind: 'spice', compare: 'gte', value: 100 }), play(effect, f.source));
for (const [selected, mixed] of [['yes', constantYes], ['no', constantNo]] as const) {
const answered = bind(f, selected, mixed, ready).game;
assert.equal(answered.ready.includes(f.target), false, `${effect}/${selected} must reopen the concurrent original Ready`);
physical(answered, f);
}
}
});
void test('a real earlier Weather or Harvester play never pre-fills a newly answered future printed-play receipt', () => {
for (const effect of ['weather', 'harvester'] as const) {
const f = fixture({ effect }); let game = settle(act(f.game, f.target, f.nativeAction));
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(windowCardOpportunity(game, effect)?.source, f.source);
const mixed = play(effect, f.source), asked = ask(declare(f, game).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
game = act(asked, f.target, { type: 'truthAnswer', answer: 'no' });
assert.equal(receipt(game, f).played, false); game = close(game, f);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(receipt(game, f).played, false); physical(game, f);
}
});
void test('foreign original Weather and foreign Ready remain legal while another seat owes native Atomics', () => {
const f = fixture({ effect: 'atomics', window: 'afterReveal' }); let game = bind(f).game;
game = act(game, f.target, f.payment);
game = settle(act(game, f.observer, { type: 'card', card: f.foreignCard, amount: 0 }));
assert.equal(game.stormPending, 0); assert.equal(windowCardOpportunity(game, 'atomics')?.source, f.source);
assert.deepEqual(receipt(game, f), { source: f.source, played: false, closed: false });
assert.equal(game.discard.filter(card => card.id === f.foreignCard).length, 1);
game = act(game, f.asker, { type: 'ready' }); game = act(game, f.observer, { type: 'ready' });
assert.ok(game.ready.includes(f.asker) && game.ready.includes(f.observer));
rejected(game, f.target, { type: 'ready' }); game = settle(act(game, f.target, f.nativeAction));
assert.equal(game.shieldWallDestroyed, true); assert.equal(player(game, f.target).tanks, 2);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('an original first-blow No cannot forbid or count the actual held Harvester in the second Advanced blow', () => {
const f = fixture({ effect: 'harvester', advanced: true });
assert.equal(f.game.advanced, true); assert.equal(f.spiceOrder.length, 2);
let game = bind(f, 'no', play('harvester', f.source)).game; rejected(game, f.target, f.nativeAction);
game = close(game, f); const second = windowCardOpportunity(game, 'harvester'); assert.ok(second);
assert.equal(game.phase, 1); assert.equal(game.turn, f.game.turn); assert.notEqual(second.source, f.source);
assert.ok(game.spiceWindow?.event); assert.equal(second.source, game.spiceWindow.event);
assert.equal(promise(game, f.target).fulfilled, true);
const evidence = structuredClone(receipt(game, f)), amount = game.spiceWindow.amount;
game = settle(act(game, f.target, f.nativeAction));
assert.equal(game.spiceWindow?.amount, amount * 2); assert.equal(game.spiceWindow?.event, second.source);
assert.deepEqual(receipt(game, f), evidence); assert.equal(receipt(game, f).played, false);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
});
void test('exact window-card guides retain native card packets and reject wrong raw sources, actors and stale events', () => {
for (const effect of effects) {
const f = fixture({ effect }), declared = declare(f);
rejected(declared.game, f.asker, { type: 'truthAsk', question: { kind: 'turnActions', target: f.target,
turn: f.game.turn, mixed: play(effect, `${f.source}:stale`) } });
rejected(declared.game, f.observer, { type: 'truthAsk', question: f.question });
let game = bind(f).game; game = act(game, f.target, f.payment);
const row = promise(game, f.target), guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card);
if (effect === 'weather') assert.ok(Number.isSafeInteger(guide.action.amount) && Number(guide.action.amount) >= 0 && Number(guide.action.amount) <= 10);
assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event);
assert.equal(guide.action.turnActionPromisePhase, effect === 'harvester' ? 1 : 0);
assert.ok(Object.keys(row).every(key => ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'].includes(key)));
assert.deepEqual(viewGame(game, f.asker).turnActionPromises, []); assert.equal(viewGame(game, f.asker).turnActionGuide, null);
rejected(game, f.observer, guide.action);
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-promise' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 5 });
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);
}
});
void test('an actually closed Storm or blow cannot be reused by a held printed card or raw old-source question', () => {
for (const effect of effects) {
const f = fixture({ effect }); let game = close(f.game, f);
assert.ok(player(game, f.target).hand.some(card => card.id === f.card)); rejected(game, f.target, f.nativeAction);
game = declare(f, game).game;
rejected(game, f.asker, { type: 'truthAsk', question: f.question });
}
});
void test('two genuine questions tag the Weather guide with its matching row while an earlier Hajr promise remains live', () => {
const f = fixture({ effect: 'weather', pairedTruthAndHajr: true }); assert.ok(f.hajr);
assert.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), [f.truth]);
assert.deepEqual(player(f.afterSetup, f.target).hand.map(card => card.id), [f.card, f.hajr]);
assert.deepEqual(player(f.afterSetup, f.observer).hand.map(card => card.id), [f.foreignCard]);
assert.notEqual(f.truth, f.foreignCard); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.equal(f.sourceCounts.selected, f.sourceActions.length);
const actions: TruthTurnWindowCardStep[] = structuredClone(f.sourceActions);
const hajr: TruthTurnWindowCardExpression = { kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } };
const firstMixed = group('and', hajr, payment(f.asker));
let game = bind(f, 'yes', firstMixed, f.game, actions).game;
const firstEvent = game.turnActionPromises![0].event;
const secondFixture = { ...f, asker: f.observer, truth: f.foreignCard };
const weather = play('weather', f.source);
game = bind(secondFixture, 'yes', weather, game, actions).game;
assert.equal(game.turnActionPromises!.length, 2);
const secondEvent = game.turnActionPromises![1].event; assert.notEqual(firstEvent, secondEvent);
const paying = viewGame(game, f.target).turnActionGuide; assert.ok(paying);
assert.equal(paying.event, firstEvent); assert.equal(paying.action.type, 'bribe');
assert.equal(paying.action.target, f.asker); assert.equal(paying.action.amount, 2);
game = act(game, f.target, paying.action, actions);
assert.equal(player(game, f.asker).bribes, 2);
assert.ok(!game.turnActionPromises![0].fulfilled); assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false);
const view = viewGame(game, f.target), guide = view.turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card);
assert.equal(guide.event, secondEvent); assert.equal(guide.action.turnActionPromiseEvent, secondEvent);
assert.equal(guide.action.turnActionPromisePhase, 0);
const matching = view.turnActionPromises.find(row => row.event === guide.event); assert.ok(matching);
assert.deepEqual(matching.mixed, weather);
assert.deepEqual(view.turnActionPromises.find(row => row.event === firstEvent)?.mixed, firstMixed);
const packet: Action = JSON.parse(JSON.stringify(guide.action));
game = settle(act(game, f.target, packet, actions), actions);
assert.equal(game.turnActionPromises![1].fulfilled, true); assert.equal(game.turnActionPromises![1].windowCards?.weather?.played, true);
assert.ok(!game.turnActionPromises![0].fulfilled);
game = advance(game, state => state.turnActionPromises!.every(row => row.fulfilled), actions);
assert.equal(game.phase, 5); assert.equal(game.active, f.target);
assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, true);
assert.ok(game.turnActionPromises!.every(row => row.fulfilled));
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.hajr));
assert.equal(game.discard.filter(card => card.id === f.foreignCard).length, 1);
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
assert.ok(actions.length > f.sourceActionCount); physical(game, f);
});
void test('minimal profiles honor No Harvester with native Ready and do not reopen an accepted blow', () => {
const f = fixture({ effect: 'harvester' });
for (const difficulty of DIFFICULTIES) {
const game = bind(f, 'no', play(f.effect, f.source)).game;
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const guide = view.turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'ready');
const action = botActions(view)[0]; assert.deepEqual(action, guide.action);
const ready = act(game, f.target, action); assert.ok(ready.ready.includes(f.target));
assert.equal(receipt(ready, f).played, false);
// Controlled public bot view only: move one of its original counters to
// the fresh territory so ordinary automatic Harvester would be tempting.
// The real played Game and its original army remain unchanged.
const accepted = viewGame(ready, f.target), me = accepted.players.find(candidate => candidate.id === f.target)!;
me.bot = difficulty; me.forces = { ...me.forces, 'carthag:11': me.forces['carthag:11'] - 1,
[`${f.originalBlow!.territory}:${f.originalBlow!.sector}`]: 1 };
assert.ok(!botActions(accepted).some(candidate => candidate.type === 'card' && candidate.card === f.card));
const completed = close(ready, f); assert.equal(promise(completed, f.target).fulfilled, true); physical(completed, f);
}
});
void test('all four minimal profiles answer and execute actual named-window printed guides through payment fulfillment', () => {
for (const effect of effects) {
const f = fixture({ effect }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthTurnWindowCardStep[] = [];
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 < 28 && !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;
if (guide) {
const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event);
assert.ok(action, `${effect}/${difficulty}`); assert.deepEqual(action, guide.action);
game = act(game, f.target, action, actions);
} else {
const next = nativeStep(game); assert.ok(next, `${effect}/${difficulty}`);
game = act(game, next.actor, next.action, actions);
}
}
assert.equal(promise(game, f.target).fulfilled, true, `${effect}/${difficulty}`);
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.card));
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bribe')); physical(game, f);
}
}
});
+145
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { battlePowerOf, type BattlePowerClaim, type BattlePowerDeclaration } from '../game/battle-power-question';
import type { WindowCardEffect } from '../game/window-card-play';
import {
compileMixedActionExpression,
type MixedActionExpression,
@@ -12,6 +13,7 @@ import {
closeTurnActionBidding,
closeTurnActionCombat,
closeTurnActionBattlePower,
closeTurnActionWindowCardPlay,
closeTurnActionPayments,
closeTurnActionRevival,
closeTurnActionTransport,
@@ -21,6 +23,7 @@ import {
recordTurnActionBid,
recordTurnActionCardPlay,
recordTurnActionBattlePower,
recordTurnActionWindowCardPlay,
recordTurnActionPlan,
recordTurnActionTraitor,
recordTurnActionMovement,
@@ -56,6 +59,8 @@ const call = (call = true, battle = 'battle:1'): Expression =>
const card = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expression =>
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
const windowCard = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'spice-blow:1' : 'storm:1'): Expression =>
({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
const signedTravel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3,
@@ -104,6 +109,16 @@ function powers(mixed: Expression, answer = true, id = 'turn-action:1'): TurnAct
visit(mixed);
return row;
}
function windowed(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise {
const row: TurnActionPromise = { ...promise(mixed, answer, id), windowCards: {} };
const visit = (node: Expression): void => {
if (node.kind === 'action' && node.action.kind === 'windowCardPlay')
row.windowCards![node.action.effect] = { source: node.action.source, played: false, closed: false };
else if (node.kind !== 'action' && node.kind !== 'fact') node.terms.forEach(visit);
};
visit(mixed);
return row;
}
void test('turn-action leaves retain exact native fields, safe payment minima and no payment twenty-force cap', () => {
for (const kind of ['ship', 'move'] as const) {
@@ -1251,3 +1266,133 @@ void test('power predicates deduplicate only exact original clauses and retain t
assert.doesNotThrow(() => turnActionValue(powers(group('or', ...Array.from({ length: 16 }, () => power())))));
assert.throws(() => turnActionValue(powers(group('or', ...Array.from({ length: 17 }, () => power())))), TurnActionError);
});
void test('window-card raw leaves name only a printed role and exact original source, including folded clauses', () => {
for (const effect of ['weather', 'atomics', 'harvester'] as const) {
const original = { kind: 'windowCardPlay', effect, source: effect === 'harvester' ? 'spice-blow:1' : 'storm:1' };
assert.deepEqual(parseTurnActionLeaf(original), original);
assert.notEqual(parseTurnActionLeaf(original), original);
for (const invalid of [{ ...original, source: '' }, { ...original, card: 'physical-id' }, { ...original, amount: 12 },
{ ...original, distance: 4 }, { ...original, answer: true }, { ...original, played: true }])
assert.throws(() => parseTurnActionLeaf(invalid), TurnActionError);
assert.throws(() => turnActionValue(promise(windowCard(effect))), TurnActionError);
assert.throws(() => turnActionValue(promise(group('or', fact(), windowCard(effect)))), TurnActionError);
}
for (const effect of ['hajr', 'ghola', 'Weather Control', null])
assert.throws(() => parseTurnActionLeaf({ kind: 'windowCardPlay', effect, source: 'storm:1' }), TurnActionError);
});
void test('whole window-card AND and OR keep dynamic printed-play alternatives with no effect amount', () => {
const weather = { kind: 'windowCardPlay', effect: 'weather', source: 'storm:1', answer: true };
const atomics = { kind: 'windowCardPlay', effect: 'atomics', source: 'storm:1', answer: true };
const and = windowed(group('and', windowCard(), windowCard('atomics'), pay()));
const or = windowed(group('or', windowCard(), windowCard('atomics')), true, 'turn-action:2');
assert.deepEqual(branches(and), [[weather, atomics, signedPay()]]);
assert.deepEqual(branches(or), [[weather], [atomics]]);
const played = recordTurnActionWindowCardPlay([and, or], 'respondent', 1, 'weather', 'storm:1');
assert.deepEqual(branches(played[0]), [[atomics, signedPay()]]);
assert.equal(played[1].fulfilled, true);
const both = recordTurnActionWindowCardPlay(played, 'respondent', 1, 'atomics', 'storm:1');
assert.equal(both[0].fulfilled, false);
assert.deepEqual(branches(both[0]), [[signedPay()]]);
assert.equal(recordTurnActionPayment(both, 'respondent', 1, payment())[0].fulfilled, true);
assert.equal(and.windowCards!.weather!.played, false);
});
void test('whole window-card No negates the aggregate and actual plays or later payments reject atomically', () => {
const no = windowed(group('and', windowCard(), windowCard('atomics')), false);
assert.deepEqual(branches(no), [[{ kind: 'windowCardPlay', effect: 'weather', source: 'storm:1', answer: false }],
[{ kind: 'windowCardPlay', effect: 'atomics', source: 'storm:1', answer: false }]]);
const weather = recordTurnActionWindowCardPlay([no], 'respondent', 1, 'weather', 'storm:1');
const before = structuredClone(weather);
assert.throws(() => recordTurnActionWindowCardPlay(weather, 'respondent', 1, 'atomics', 'storm:1'), TurnActionError);
assert.deepEqual(weather, before);
const orNo = windowed(group('or', windowCard(), windowCard('atomics')), false);
assert.throws(() => recordTurnActionWindowCardPlay([orNo], 'respondent', 1, 'weather', 'storm:1'), TurnActionError);
const paidNo = windowed(group('and', windowCard(), pay()), false);
const declared = recordTurnActionWindowCardPlay([paidNo], 'respondent', 1, 'weather', 'storm:1');
assert.throws(() => recordTurnActionPayment(declared, 'respondent', 1, payment()), TurnActionError);
const yes = windowed(group('and', windowCard(), pay()), true, 'turn-action:2');
assert.throws(() => recordTurnActionWindowCardPlay([yes, orNo], 'respondent', 1, 'weather', 'storm:1'), TurnActionError);
assert.equal(yes.windowCards!.weather!.played, false);
});
void test('exact player turn effect and source binding preserves separate original-window answer intervals', () => {
const mixed = group('and', windowCard('harvester'), pay());
const original = windowed(mixed);
for (const [player, turn, effect, source] of [
['other', 1, 'harvester', 'spice-blow:1'], ['respondent', 2, 'harvester', 'spice-blow:1'],
['respondent', 1, 'weather', 'spice-blow:1'], ['respondent', 1, 'harvester', 'spice-blow:2'],
] as const) {
assert.equal(recordTurnActionWindowCardPlay([original], player, turn, effect, source)[0], original);
assert.equal(closeTurnActionWindowCardPlay([original], player, turn, effect, source)[0], original);
}
const played = recordTurnActionWindowCardPlay([original], 'respondent', 1, 'harvester', 'spice-blow:1')[0];
const fresh = windowed(mixed, true, 'turn-action:2');
assert.equal(fresh.windowCards!.harvester!.played, false);
const replay = recordTurnActionWindowCardPlay([played, fresh], 'respondent', 1, 'harvester', 'spice-blow:1');
assert.equal(replay[0], played);
assert.equal(replay[1].windowCards!.harvester!.played, true);
assert.equal(recordTurnActionWindowCardPlay(replay, 'respondent', 1, 'harvester', 'spice-blow:1')[1], replay[1]);
});
void test('closing one native window records absence only there and retains other window and later payment alternatives', () => {
const original = windowed(group('or', windowCard(), windowCard('atomics'), pay()));
const closed = closeTurnActionWindowCardPlay([original], 'respondent', 1, 'weather', 'storm:1');
assert.deepEqual(closed[0].windowCards!.weather, { source: 'storm:1', played: false, closed: true });
assert.equal(closed[0].windowCards!.atomics, original.windowCards!.atomics);
assert.equal(closed[0].paymentClosed, false);
assert.equal(branches(closed[0]).length, 2);
assert.equal(recordTurnActionWindowCardPlay(closed, 'respondent', 1, 'weather', 'storm:1')[0], closed[0]);
assert.equal(recordTurnActionWindowCardPlay(closed, 'respondent', 1, 'atomics', 'storm:1')[0].fulfilled, true);
const all = closeTurnActionWindowCardPlay(closed, 'respondent', 1, 'atomics', 'storm:1');
assert.deepEqual(branches(all[0]), [[signedPay()]]);
assert.equal(recordTurnActionPayment(all, 'respondent', 1, payment())[0].fulfilled, true);
const played = recordTurnActionWindowCardPlay([original], 'respondent', 1, 'weather', 'storm:1')[0];
assert.equal(played.fulfilled, true);
assert.equal(closeTurnActionWindowCardPlay([played], 'respondent', 1, 'weather', 'storm:1')[0], played);
});
void test('full-turn expiry closes remaining window stages while payment or transport closure preserves their real deadline', () => {
const no = windowed(windowCard('harvester'), false);
assert.equal(closeTurnActionPayments([no], 'respondent', 1)[0].windowCards!.harvester, no.windowCards!.harvester);
assert.equal(closeTurnActionTransport([no], 'respondent', 1)[0].windowCards!.harvester, no.windowCards!.harvester);
const finished = finishTurnActionTurn([no], 'respondent', 1)[0];
assert.equal(finished.fulfilled, true);
assert.deepEqual(finished.windowCards!.harvester, { source: 'spice-blow:1', played: false, closed: true });
const yes = windowed(windowCard(), true, 'turn-action:2');
assert.equal(finishTurnActionTurn([yes], 'respondent', 1)[0].released, true);
const terminal = [finished, finishTurnActionTurn([yes], 'respondent', 1)[0]];
for (const transform of [
(rows: TurnActionPromise[]) => recordTurnActionWindowCardPlay(rows, 'respondent', 1, 'harvester', 'spice-blow:1'),
(rows: TurnActionPromise[]) => closeTurnActionWindowCardPlay(rows, 'respondent', 1, 'weather', 'storm:1'),
(rows: TurnActionPromise[]) => finishTurnActionTurn(rows, 'respondent', 1),
]) assert.ok(transform(terminal).every((row, index) => row === terminal[index]));
validateTurnActionPromises(terminal, seats);
});
void test('strict window stages contain only exact raw named effects and sources without extra counters or physical IDs', () => {
const original = windowed(group('and', windowCard(), pay()));
const stage = original.windowCards!.weather!;
for (const windowCards of [undefined, null, {}, { weather: null }, { weather: { ...stage, source: 'storm:2' } },
{ weather: { ...stage, played: 1 } }, { weather: { ...stage, closed: 'yes' } }, { weather: { ...stage, count: 1 } },
{ weather: stage, atomics: { source: 'storm:1', played: false, closed: false } }, { weather: stage, ghola: stage }])
assert.throws(() => turnActionValue({ ...original, windowCards } as TurnActionPromise), TurnActionError);
assert.throws(() => turnActionValue(windowed(group('or', windowCard(), windowCard('weather', 'storm:2')))), TurnActionError);
const old = cards(group('and', card(), pay()));
assert.equal('windowCards' in recordTurnActionWindowCardPlay([old], 'respondent', 1, 'weather', 'storm:1')[0], false);
assert.equal('windowCards' in finishTurnActionTurn([old], 'respondent', 1)[0], false);
assert.throws(() => recordTurnActionWindowCardPlay([original], 'respondent', 1, 'hajr' as never, 'storm:1'), TurnActionError);
assert.throws(() => closeTurnActionWindowCardPlay([original], 'respondent', 1, 'weather', ''), TurnActionError);
});
void test('window-card clauses deduplicate only their exact original source and share the sixteen-leaf budget', () => {
const repeated = windowed(group('and', windowCard(), windowCard(), pay()));
assert.equal(branches(repeated)[0].length, 2);
assert.doesNotThrow(() => turnActionValue(windowed(group('or', ...Array.from({ length: 16 }, () => windowCard())))));
assert.throws(() => turnActionValue(windowed(group('or', ...Array.from({ length: 17 }, () => windowCard())))), TurnActionError);
const foreign = { ...windowed(windowCard()), compiled: { kind: 'action', action: {
kind: 'windowCardPlay', effect: 'weather', source: 'storm:2',
} } };
assert.throws(() => turnActionValue(foreign as TurnActionPromise), TurnActionError);
});
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { windowCardOpportunity, type WindowCardGame } from '../game/window-card-play';
function storm(): WindowCardGame {
return { status: 'playing', phase: 0, turn: 3, shieldWallDestroyed: false };
}
function blow(): WindowCardGame {
return { ...storm(), phase: 1, spiceWindow: { event: 'spice-blow:1', territory: 'hagga_basin', sector: 5, harvested: false } };
}
void test('Weather and Family Atomics share the exact original current Storm source before movement', () => {
assert.deepEqual(windowCardOpportunity(storm(), 'weather'), { effect: 'weather', source: 'storm:3', turn: 3 });
assert.deepEqual(windowCardOpportunity(storm(), 'atomics'), { effect: 'atomics', source: 'storm:3', turn: 3 });
assert.equal(windowCardOpportunity({ ...storm(), turn: 4 }, 'weather')!.source, 'storm:4');
});
void test('actual Storm close and movement resolution end the window while legacy absent open flag remains valid', () => {
for (const effect of ['weather', 'atomics'] as const) {
assert.equal(windowCardOpportunity({ ...storm(), stormCardWindowOpen: false }, effect), null);
assert.equal(windowCardOpportunity({ ...storm(), stormResolution: { event: 'storm-movement:1' } }, effect), null);
assert.equal(windowCardOpportunity({ ...storm(), phase: 1 }, effect), null);
assert.ok(windowCardOpportunity({ ...storm(), stormCardWindowOpen: true }, effect));
}
});
void test('a destroyed Shield Wall closes only Family Atomics rather than the remaining Weather window', () => {
const source = { ...storm(), shieldWallDestroyed: true };
assert.equal(windowCardOpportunity(source, 'atomics'), null);
assert.equal(windowCardOpportunity(source, 'weather')!.source, 'storm:3');
});
void test('temporary responses, decisions, phase openings and Truth preserve the native global source', () => {
for (const blocker of ['response', 'decision', 'phaseOpening', 'truthtrance'] as const) {
assert.equal(windowCardOpportunity({ ...storm(), [blocker]: {} }, 'weather')!.source, 'storm:3');
assert.equal(windowCardOpportunity({ ...blow(), [blocker]: {} }, 'harvester')!.source, 'spice-blow:1');
}
});
void test('a current Harvester window binds its actual blow event and preserves later distinct blow identity', () => {
const source = blow();
assert.deepEqual(windowCardOpportunity(source, 'harvester'), { effect: 'harvester', source: 'spice-blow:1', turn: 3 });
source.spiceWindow!.event = 'spice-blow:2';
assert.equal(windowCardOpportunity(source, 'harvester')!.source, 'spice-blow:2');
assert.equal(windowCardOpportunity({ ...source, phase: 2 }, 'harvester'), null);
});
void test('Harvester uses existing unlimited physical stacking and replay availability without a per-window count cap', () => {
const source = blow();
source.spiceWindow!.harvested = true;
assert.equal(windowCardOpportunity(source, 'harvester'), null, 'Legacy harvested-only windows are closed');
for (const harvesters of [1, 2, 20, 100, Number.MAX_SAFE_INTEGER]) {
source.spiceWindow!.harvesters = harvesters;
assert.equal(windowCardOpportunity(source, 'harvester')!.source, 'spice-blow:1');
}
source.spiceWindow!.harvesterClosed = true;
assert.equal(windowCardOpportunity(source, 'harvester'), null);
});
void test('legacy fresh blows get the specified deterministic source without mutating their original state', () => {
const source = blow(); delete source.spiceWindow!.event;
const before = structuredClone(source);
assert.equal(windowCardOpportunity(source, 'harvester')!.source, 'legacy-spice-blow:3:hagga_basin:5');
assert.deepEqual(source, before);
const otherSector = { ...source, spiceWindow: { ...source.spiceWindow!, sector: 6 } };
assert.notEqual(windowCardOpportunity(otherSector, 'harvester')!.source, windowCardOpportunity(source, 'harvester')!.source);
});
void test('invalid public source metadata and unavailable game windows do not create a native opportunity', () => {
for (const source of [{ ...storm(), status: 'setup' }, { ...storm(), turn: 0 }, { ...storm(), turn: 1.5 },
{ ...storm(), turn: Number.MAX_SAFE_INTEGER + 1 }])
assert.equal(windowCardOpportunity(source, 'weather'), null);
for (const source of [{ ...blow(), spiceWindow: null }, { ...blow(), spiceWindow: { ...blow().spiceWindow!, event: '' } },
{ ...blow(), spiceWindow: { ...blow().spiceWindow!, territory: ' hagga_basin' } },
{ ...blow(), spiceWindow: { ...blow().spiceWindow!, sector: -1 } },
{ ...blow(), spiceWindow: { ...blow().spiceWindow!, harvesters: NaN } }])
assert.equal(windowCardOpportunity(source, 'harvester'), null);
assert.equal(windowCardOpportunity(storm(), 'unknown' as never), null);
});