Files
Dune/tests/treachery-discard-continuations.test.ts
2026-09-08 07:37:52 +10:00

814 lines
25 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { runInNewContext } from 'node:vm';
import { webcrypto } from 'node:crypto';
import ts from 'typescript';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { botActions, runBots } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { richeseCards } from '../game/richese-cards';
import { baseDeck } from '../game/cards';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
/** Test-only observation of the unchanged production inner dispatcher, before
* its public wrapper drains automatic continuations. No runtime export or gate
* is changed, and all resumed actions use the real production exports. */
const observed: {
applyActionInner?: (g: Game, id: string, a: Action) => Game;
finishResponse?: (g: Game, canceled: boolean) => void;
} = {};
runInNewContext(
ts.transpileModule(
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
'\nexport { applyActionInner, finishResponse };\n',
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
{
exports: observed,
require: createRequire(new URL('../game/engine.ts', import.meta.url)),
crypto: webcrypto,
structuredClone,
TextEncoder,
JSON,
},
);
function inner(g: Game, id: string, a: Action) {
const before = structuredClone(g);
const result = reload(observed.applyActionInner!(g, id, a));
assert.deepEqual(g, before);
return result;
}
function base() {
const g = createGame('DISCARDFRAME', newPlayer('ec', 'Ecaz', 'ecaz'));
g.players.push(
newPlayer('in', 'Entrant', 'emperor'),
newPlayer('al', 'Ally', 'atreides'),
);
Object.assign(g, {
status: 'playing',
turn: 2,
phase: 5,
storm: 18,
active: 'in',
order: ['in', 'ec', 'al'],
movementRemaining: ['in', 'ec', 'al'],
deck: baseDeck(),
});
for (const p of g.players) {
p.spice = 20;
p.traitors = [p.leaders[0].id];
}
return g;
}
function ambassador(
effect: 'ixians' | 'choam',
copied = false,
lastCohort = false,
) {
let g = base();
const inventory = createAmbassadors(() => 0),
chosen = inventory.tokens.find(
(t) => t.effect === (copied ? 'beneGesserit' : effect),
)!;
inventory.cohort = [
chosen,
...inventory.tokens.filter(
(t) =>
t.effect !== 'ecaz' &&
t.id !== chosen.id &&
(!copied || t.effect !== effect),
),
]
.slice(0, 5)
.map((t) => t.id);
for (const token of inventory.tokens)
token.zone =
token.effect === 'ecaz' || inventory.cohort.includes(token.id)
? 'supply'
: 'pool';
g.ecazAmbassadors = placeAmbassador(inventory, chosen.id, {
turn: 1,
availableSpice: 20,
destination: {
id: 'arrakeen',
stronghold: true,
inStorm: false,
allowed: true,
},
}).state;
if (lastCohort)
for (const token of g.ecazAmbassadors!.tokens)
if (inventory.cohort.includes(token.id) && token.id !== chosen.id)
token.zone = token.effect === 'beneGesserit' ? 'removed' : 'used';
player(g, 'ec').hand = g.deck.splice(0, 2);
g = applyAction(g, 'in', {
type: 'ship',
amount: 2,
territory: 'arrakeen',
sector: 10,
});
g = applyAction(g, 'ec', {
type: 'decision',
event: g.pendingAmbassador!.event,
trigger: true,
beneficiary: 'ec',
});
if (copied) {
assert.equal(g.pendingAmbassador?.stage, 'copy');
assert.ok(g.pendingAmbassador!.copyChoices.includes(effect));
g = applyAction(g, 'ec', {
type: 'decision',
event: g.pendingAmbassador!.event,
effect,
});
}
assert.equal(g.pendingAmbassador?.stage, 'cards');
const action = {
type: 'decision',
event: g.pendingAmbassador!.event,
cards: player(g, 'ec')
.hand.slice(0, effect === 'ixians' ? 1 : 2)
.map((c) => c.id),
};
return { g, action };
}
function ambassadorFrame(effect: 'ixians' | 'choam') {
const { g, action } = ambassador(effect),
frame = inner(g, 'ec', action);
assert.ok(frame.pendingTreacheryDiscard);
return { before: g, action, frame };
}
function ixFrame() {
let g = createGame(
'IXDISCARDFRAME',
newPlayer('i', 'Ixians', 'ixians'),
false,
['ix'],
);
g.players.push(
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('a', 'Atreides', 'atreides'),
);
Object.assign(g, {
status: 'playing',
phase: 3,
turn: 2,
storm: 18,
order: ['i', 'e', 'a'],
active: 'a',
deck: baseDeck(),
});
for (const p of g.players) {
p.spice = 20;
p.traitors = [p.leaders[0].id];
}
player(g, 'i').ally = 'a';
player(g, 'a').ally = 'i';
const won = g.deck.shift()!;
g.auction = {
cards: [won],
index: 0,
bid: 2,
bidder: 'a',
active: 'a',
passed: [],
opener: 0,
};
g.decision = { kind: 'auctionPayment', player: 'a' };
g = applyAction(g, 'a', { type: 'decision', karama: false });
assert.equal(g.decision?.kind, 'ixAllyCard');
const paid = reload(g);
g = inner(g, 'a', { type: 'decision', accept: true });
for (
let n = 0;
n < g.players.length && g.response?.kind === 'ixAllyCard';
n++
)
g = inner(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
assert.equal(g.response?.kind, 'ixAllyCard');
assert.equal(g.response!.passed.length, g.players.length);
// The automatic response loop invokes this same function before draining.
observed.finishResponse!(g, false);
g = reload(g);
assert.ok(g.pendingTreacheryDiscard);
return { paid, frame: g, won };
}
function counts(g: Game) {
return [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
.map((c) => c.id)
.sort();
}
function atomicReject(g: Game) {
const before = reload(g);
assert.throws(() => normalizeAutomaticGame(g));
assert.deepEqual(g, before);
assert.throws(() => applyAction(g, g.players[0].id, { type: 'advanceBots' }));
assert.deepEqual(g, before);
assert.throws(() => viewGame(g, g.players[0].id));
assert.deepEqual(g, before);
}
void test('actual Ixian Ambassador discard emits a detached private frame and restored replacement draws once', () => {
const { before, action, frame } = ambassadorFrame('ixians');
const pending = frame.pendingTreacheryDiscard!,
replacement = before.deck[0];
assert.equal(frame.pendingAmbassador, null);
assert.equal(frame.decision, null);
assert.equal(pending.sequence, frame.treacheryDiscardSequence);
assert.equal(
pending.sequence,
(frame.resolvedTreacheryDiscardSequence ?? 0) + 1,
);
assert.equal(
pending.batch.event,
`discard:${frame.turn}:${frame.phase}:${pending.sequence}`,
);
assert.equal(pending.batch.cause, 'ambassador:ixians');
assert.ok(
pending.batch.entries.every((e) => e.discardedBy === 'ec' && !e.publicFace),
);
assert.deepEqual(frame.deck, before.deck);
assert.equal(player(frame, 'ec').hand.length, 1);
assert.deepEqual(pending.continuation, {
kind: 'ambassador',
entry: before.pendingAmbassador,
});
const done = normalizeAutomaticGame(reload(frame));
assert.equal(done.pendingTreacheryDiscard, null);
assert.equal(done.resolvedTreacheryDiscardSequence, pending.sequence);
assert.equal(
player(done, 'ec').hand.filter((c) => c.id === replacement.id).length,
1,
);
assert.equal(player(done, 'ec').spice, 20);
assert.deepEqual(counts(done), counts(before));
assert.deepEqual(
player(done, 'in'),
player(frame, 'in'),
'paid shipment must not replay',
);
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
assert.deepEqual(
applyAction(before, 'ec', action),
done,
'public wrapper drains the same frame',
);
});
void test('CHOAM Ambassador bank payout and log are committed before the frame and never paid twice', () => {
const { before, action, frame } = ambassadorFrame('choam');
assert.equal(player(frame, 'ec').spice, 26);
assert.equal(player(frame, 'ec').hand.length, 0);
assert.equal(frame.pendingTreacheryDiscard!.batch.entries.length, 2);
const payoutLogs = frame.log.filter(
(e) => e.automatic?.name === 'CHOAM Ambassador',
);
assert.equal(payoutLogs.length, 1);
const done = normalizeAutomaticGame(reload(frame));
assert.equal(player(done, 'ec').spice, 26);
assert.deepEqual(done.deck, before.deck);
assert.deepEqual(
done.log.filter((e) => e.automatic?.name === 'CHOAM Ambassador'),
payoutLogs,
);
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
assert.deepEqual(applyAction(before, 'ec', action), done);
});
void test('Ixian ally replacement frame preserves its paid sale and draws before Emperor income exactly once', () => {
const { paid, frame, won } = ixFrame(),
replacement = paid.deck[0];
assert.equal(player(frame, 'a').spice, 18);
assert.equal(player(frame, 'e').spice, 20);
assert.equal(frame.pendingIxAlly, null);
assert.equal(frame.pendingTreacheryDiscard!.continuation.kind, 'ixAllyCard');
assert.deepEqual(frame.currentAuctionSale, paid.currentAuctionSale);
assert.deepEqual(frame.deck, paid.deck);
assert.equal(
player(frame, 'a').hand.some((c) => c.id === won.id),
false,
);
const done = normalizeAutomaticGame(reload(frame));
assert.equal(
player(done, 'a').hand.filter((c) => c.id === replacement.id).length,
1,
);
assert.equal(player(done, 'a').spice, 18);
assert.equal(player(done, 'e').spice, 22);
assert.equal(done.discard.filter((c) => c.id === won.id).length, 1);
assert.equal(done.pendingTreacheryDiscard, null);
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
});
void test('empty-deck Ix Ambassador resumes a refill once after its fresh discard, without a duplicate physical card', () => {
const { g, action } = ambassador('ixians');
// Isolate the real empty-deck continuation: all unused cards already lie in discard.
g.discard.push(...g.deck.splice(0));
const frame = inner(g, 'ec', action);
assert.equal(frame.deck.length, 0);
const inventory = counts(frame),
done = normalizeAutomaticGame(reload(frame));
assert.equal(player(done, 'ec').hand.length, 2);
assert.deepEqual(counts(done), inventory);
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
});
void test('stale sequence, event, turn, phase and physical ownership corruptions reject without mutation', () => {
const { frame } = ambassadorFrame('ixians');
const cases: Array<(g: Game) => void> = [
(g) => {
g.treacheryDiscardSequence!++;
},
(g) => {
g.resolvedTreacheryDiscardSequence = g.treacheryDiscardSequence;
},
(g) => {
g.pendingTreacheryDiscard!.sequence++;
},
(g) => {
g.pendingTreacheryDiscard!.batch.event = 'old';
},
(g) => {
g.pendingTreacheryDiscard!.batch.turn++;
},
(g) => {
g.pendingTreacheryDiscard!.batch.phase++;
},
(g) => {
g.pendingTreacheryDiscard!.batch.entries[0].discardedBy = 'in';
},
(g) => {
g.pendingTreacheryDiscard!.batch.entries[0].card.name = 'forged';
},
(g) => {
player(g, 'in').hand.push(
structuredClone(g.pendingTreacheryDiscard!.batch.entries[0].card),
);
},
(g) => {
g.discard = g.discard.filter(
(c) => c.id !== g.pendingTreacheryDiscard!.batch.entries[0].card.id,
);
},
];
for (const corrupt of cases) {
const g = reload(frame);
corrupt(g);
atomicReject(g);
}
});
void test('Ix saved sale and auction binding corruption cannot mint a replacement or income', () => {
const { frame } = ixFrame();
for (const corrupt of [
(g: Game) => {
g.currentAuctionSale!.amount++;
},
(g: Game) => {
g.auction!.index++;
},
(g: Game) => {
const c = g.pendingTreacheryDiscard!.continuation;
assert.equal(c.kind, 'ixAllyCard');
if (c.kind === 'ixAllyCard') c.player = 'e';
},
]) {
const g = reload(frame);
corrupt(g);
atomicReject(g);
}
});
void test('pending receipts and fresh private card faces stay out of every player view', () => {
for (const frame of [
ambassadorFrame('ixians').frame,
ambassadorFrame('choam').frame,
ixFrame().frame,
]) {
for (const p of frame.players) {
const view = viewGame(frame, p.id);
assert.equal('pendingTreacheryDiscard' in view, false);
assert.equal('treacheryDiscardSequence' in view, false);
for (const other of view.players.filter((o) => o.id !== p.id))
assert.equal(other.hand, undefined);
assert.equal('discard' in view, false);
assert.equal('deck' in view, false);
}
}
});
void test('runBots with zero gameplay steps still drains saved discard continuations exactly once', () => {
for (const frame of [
ambassadorFrame('ixians').frame,
ambassadorFrame('choam').frame,
ixFrame().frame,
]) {
const expected = normalizeAutomaticGame(reload(frame)),
actual = runBots(reload(frame), 0);
assert.equal(actual.pendingTreacheryDiscard, null);
assert.deepEqual(
{ ...actual, botsPending: undefined },
{ ...expected, botsPending: undefined },
);
assert.deepEqual(normalizeAutomaticGame(reload(actual)), actual);
}
});
void test('a different private Ambassador discard face does not change any public or unentitled player projection', () => {
const { frame } = ambassadorFrame('ixians'),
changed = reload(frame);
const entry = changed.pendingTreacheryDiscard!.batch.entries[0];
const pileIndex = changed.discard.findIndex(
(card) => card.id === entry.card.id,
);
const previous = changed.discard[pileIndex],
replacement = changed.deck[0];
changed.discard[pileIndex] = replacement;
changed.deck[0] = previous;
entry.card = structuredClone(replacement);
for (const p of frame.players)
assert.deepEqual(viewGame(changed, p.id), viewGame(frame, p.id));
});
function blackMarketFrame() {
let g = createGame(
'BLACKMARKETFRAME',
newPlayer('r', 'Richese', 'richese'),
true,
['ix', 'choam'],
);
g.players.push(
newPlayer('i', 'Ixians', 'ixians'),
newPlayer('a', 'Atreides', 'atreides'),
newPlayer('e', 'Emperor', 'emperor'),
);
Object.assign(g, {
status: 'playing',
phase: 2,
turn: 2,
storm: 18,
order: ['a', 'i', 'e', 'r'],
deck: baseDeck(),
richeseCache: richeseCards(),
richeseRemoved: [],
});
for (const p of g.players) {
p.spice = 20;
p.traitors = [p.leaders[0].id];
}
player(g, 'i').ally = 'a';
player(g, 'a').ally = 'i';
const sold = g.deck.shift()!;
player(g, 'r').hand.push(sold);
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
if (g.phaseOpening)
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'richeseBlackMarket');
g = applyAction(g, 'r', {
type: 'decision',
event: g.richeseBidding!.event,
card: sold.id,
method: 'silent',
});
for (const id of ['a', 'i', 'e', 'r'])
g = applyAction(g, id, {
type: 'richeseBid',
event: g.richeseAuction!.event,
amount: id === 'a' ? 3 : 0,
});
assert.equal(g.decision?.kind, 'ixAllyCard');
assert.equal(player(g, 'a').spice, 17);
assert.equal(player(g, 'r').spice, 20);
const paid = reload(g);
g = inner(g, 'a', { type: 'decision', accept: true });
for (let n = 0; n < g.players.length; n++)
g = inner(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
observed.finishResponse!(g, false);
assert.ok(g.pendingTreacheryDiscard);
return { frame: reload(g), paid, sold };
}
void test('an actual Black Market sale resumes Ix replacement before seller income and never pays Emperor', () => {
const { frame, paid, sold } = blackMarketFrame(),
replacement = paid.deck[0];
assert.equal(frame.currentAuctionSale!.origin, 'blackMarket');
assert.equal(
frame.pendingTreacheryDiscard!.batch.entries[0].publicFace,
false,
);
assert.equal(player(frame, 'r').spice, 20);
assert.equal(player(frame, 'e').spice, 20);
const done = normalizeAutomaticGame(reload(frame));
assert.equal(player(done, 'r').spice, 23);
assert.equal(player(done, 'a').spice, 17);
assert.equal(player(done, 'e').spice, 20);
assert.ok(player(done, 'a').hand.some((c) => c.id === replacement.id));
assert.equal(done.discard.filter((c) => c.id === sold.id).length, 1);
assert.equal(done.decision?.kind, 'richeseDeclaration');
assert.equal(done.richeseAuction, null);
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
const live = applyAction(paid, 'a', { type: 'decision', accept: true });
assert.ok(live.richeseBidding!.event);
assert.notEqual(live.richeseBidding!.event, paid.richeseBidding!.event);
// Independent continuations create independent UUIDs for the next declaration.
assert.deepEqual(
{
...live,
richeseBidding: {
...live.richeseBidding,
event: done.richeseBidding!.event,
},
},
done,
);
});
void test('BG copied Ixian and CHOAM effects preserve their discard batches and replenish the final cohort only once', () => {
for (const effect of ['ixians', 'choam'] as const)
for (const copied of [false, true]) {
const { g, action } = ambassador(effect, copied, true),
oldCohort = [...g.ecazAmbassadors!.cohort],
frame = inner(g, 'ec', action);
assert.deepEqual(frame.ecazAmbassadors!.cohort, oldCohort);
assert.equal(
frame.log.filter((e) =>
e.text.startsWith('All five random Ambassadors'),
).length,
0,
);
const entry = frame.pendingTreacheryDiscard!.continuation;
assert.equal(entry.kind, 'ambassador');
if (entry.kind !== 'ambassador') throw Error('Unexpected frame');
assert.equal(entry.entry.effect, effect);
if (copied) {
assert.ok(entry.entry.copyChoices.includes(effect));
assert.equal(
frame.ecazAmbassadors!.tokens.find((t) => t.id === entry.entry.token)!
.zone,
'removed',
);
}
assert.equal(
frame.pendingTreacheryDiscard!.batch.entries.length,
effect === 'ixians' ? 1 : 2,
);
const replacement = frame.deck[0],
done = normalizeAutomaticGame(reload(frame));
assert.equal(player(done, 'ec').spice, effect === 'choam' ? 26 : 20);
assert.equal(player(done, 'ec').hand.length, effect === 'ixians' ? 2 : 0);
if (effect === 'ixians')
assert.ok(player(done, 'ec').hand.some((c) => c.id === replacement.id));
assert.equal(
done.ecazAmbassadors!.tokens.filter(
(t) => t.zone === 'supply' && t.effect !== 'ecaz',
).length,
5,
);
assert.equal(
done.log.filter((e) => e.text.startsWith('All five random Ambassadors'))
.length,
1,
);
if (copied)
assert.equal(
done.ecazAmbassadors!.tokens.find((t) => t.id === entry.entry.token)!
.zone,
'removed',
);
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
}
});
void test('matching corrupted sale snapshots still fail their independent paid-auction and alliance bindings', () => {
const normal = ixFrame().frame,
black = blackMarketFrame().frame;
const cases: Array<[Game, (g: Game) => void]> = [
[
normal,
(g) => {
g.currentAuctionSale!.amount++;
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind === 'ixAllyCard')
c.sale.amount = g.currentAuctionSale!.amount;
},
],
[
normal,
(g) => {
g.currentAuctionSale!.amount = -1;
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind === 'ixAllyCard') c.sale.amount = -1;
},
],
[
normal,
(g) => {
player(g, 'i').ally = null;
},
],
[
normal,
(g) => {
player(g, 'a').ally = null;
},
],
[
normal,
(g) => {
g.currentAuctionSale!.origin = 'cache';
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind === 'ixAllyCard') c.sale.origin = 'cache';
},
],
[
normal,
(g) => {
Object.assign(g.currentAuctionSale!, { free: 'true' });
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind === 'ixAllyCard') {
Object.assign(c.sale, { free: 'true' });
Object.assign(c, { free: 'true' });
}
},
],
[
black,
(g) => {
g.currentAuctionSale!.free = true;
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind === 'ixAllyCard') {
c.sale.free = true;
c.free = true;
}
},
],
[
black,
(g) => {
g.currentAuctionSale!.amount++;
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind === 'ixAllyCard')
c.sale.amount = g.currentAuctionSale!.amount;
},
],
[
black,
(g) => {
g.richeseAuction!.outcome = { kind: 'unbid' };
},
],
[
black,
(g) => {
g.richeseAuction!.outcome = { kind: 'sold', winner: 'e', amount: 3 };
},
],
[
black,
(g) => {
g.currentAuctionSale!.seller = 'a';
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind === 'ixAllyCard') c.sale.seller = 'a';
},
],
];
for (const [source, corrupt] of cases) {
const g = reload(source);
corrupt(g);
atomicReject(g);
}
});
void test('wrong Ambassador token, copy permission, beneficiary, location or resume cannot finish a saved effect', () => {
const original = ambassadorFrame('ixians').frame;
const { g: copyBefore, action } = ambassador('choam', true),
copied = inner(copyBefore, 'ec', action);
const cases: Array<[Game, (g: Game) => void]> = [
[
original,
(g) => {
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind === 'ambassador') {
c.entry.effect = 'choam';
g.pendingTreacheryDiscard!.batch.cause = 'ambassador:choam';
}
},
],
[
original,
(g) => {
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind === 'ambassador')
g.ecazAmbassadors!.tokens.find((t) => t.id === c.entry.token)!.zone =
'supply';
},
],
[
original,
(g) => {
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind === 'ambassador') c.entry.resume = 'wormRide';
},
],
[
original,
(g) => {
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind === 'ambassador') c.entry.territory = 'polar_sink';
},
],
[
original,
(g) => {
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind === 'ambassador') c.entry.sector = 18;
},
],
[
original,
(g) => {
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind === 'ambassador') {
c.entry.beneficiary = 'al';
for (const e of g.pendingTreacheryDiscard!.batch.entries)
e.discardedBy = 'al';
}
},
],
[
copied,
(g) => {
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind === 'ambassador') c.entry.copyChoices = [];
},
],
[
copied,
(g) => {
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind === 'ambassador')
g.ecazAmbassadors!.tokens.find((t) => t.id === c.entry.token)!.zone =
'used';
},
],
];
for (const [source, corrupt] of cases) {
const g = reload(source);
corrupt(g);
atomicReject(g);
}
});
void test('impossible pending overlays are rejected and every profile waits for the automatic continuation marker', () => {
const sources = [
ambassadorFrame('ixians').frame,
ixFrame().frame,
blackMarketFrame().frame,
];
for (const source of sources) {
for (const key of [
'response',
'decision',
'truthtrance',
'phaseOpening',
'pendingRicheseGift',
'pendingKarama',
'pendingNullentropy',
'pendingAmbassador',
'pendingIxAlly',
]) {
const g = reload(source);
Object.assign(g, { [key]: {} });
atomicReject(g);
}
for (const level of DIFFICULTIES)
for (const p of source.players) {
const view = viewGame(source, p.id);
view.players.find((x) => x.id === p.id)!.bot = level;
assert.equal(view.automaticContinuationPending, true);
assert.deepEqual(botActions(view), []);
}
const done = normalizeAutomaticGame(reload(source));
for (const p of done.players)
assert.equal(viewGame(done, p.id).automaticContinuationPending, false);
}
});