Bind Truthtrance card plays to original Storm and spice windows
This commit is contained in:
1 parent
09a81a9b65
commit
e88f871385
20 files changed
+1519
-26
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user