814 lines
25 KiB
TypeScript
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);
|
|
}
|
|
});
|