674 lines
22 KiB
TypeScript
674 lines
22 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 { baseDeck } from '../game/cards';
|
|
import type { FactionId } from '../game/catalog';
|
|
import {
|
|
createTerrorState,
|
|
placeTerror,
|
|
type TerrorKind,
|
|
} from '../game/moritani-terror';
|
|
import { createRicheseNoField } from '../game/richese-no-field';
|
|
|
|
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 fixture(
|
|
kind: TerrorKind = 'sabotage',
|
|
entrant: FactionId = 'emperor',
|
|
) {
|
|
const g = createGame('TERRORFRAME', newPlayer('m', 'Moritani', 'moritani'));
|
|
g.players.push(
|
|
newPlayer('e', 'Entrant', entrant),
|
|
newPlayer('a', 'Atreides', 'atreides'),
|
|
);
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 5,
|
|
turn: 2,
|
|
storm: 18,
|
|
active: 'e',
|
|
order: ['e', 'm', 'a'],
|
|
movementRemaining: ['e', 'm', 'a'],
|
|
deck: baseDeck(),
|
|
});
|
|
for (const p of g.players) {
|
|
p.forces = {};
|
|
p.reserves = 20;
|
|
p.spice = 20;
|
|
p.hand = [];
|
|
}
|
|
g.moritaniTerror = createTerrorState(() => 0);
|
|
const token = g.moritaniTerror.tokens.find((t) => t.kind === kind)!;
|
|
g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'arrakeen', 1);
|
|
return g;
|
|
}
|
|
function hold(g: Game, id: string, count: number) {
|
|
player(g, id).hand.push(...g.deck.splice(0, count));
|
|
}
|
|
function named(g: Game, id: string, name: string) {
|
|
const index = g.deck.findIndex((c) => c.name === name);
|
|
assert.ok(index >= 0, name);
|
|
const [card] = g.deck.splice(index, 1);
|
|
player(g, id).hand.push(card);
|
|
return card.id;
|
|
}
|
|
const enter = (g: Game) =>
|
|
applyAction(g, 'e', {
|
|
type: 'ship',
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
amount: 2,
|
|
});
|
|
const decide = (g: Game, a: Omit<Action, 'type'>) =>
|
|
applyAction(g, 'm', { type: 'decision', ...a });
|
|
function inventory(g: Game) {
|
|
const cards = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
|
|
.map((c) => c.id)
|
|
.sort();
|
|
assert.equal(new Set(cards).size, cards.length);
|
|
for (const p of g.players)
|
|
assert.equal(
|
|
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
|
20,
|
|
);
|
|
return cards;
|
|
}
|
|
function continuation(g: Game) {
|
|
const c = g.pendingTreacheryDiscard!.continuation;
|
|
if (c.kind !== 'terrorDiscard')
|
|
throw Error('Missing Terror discard continuation');
|
|
return c;
|
|
}
|
|
function recover(g: Game) {
|
|
const old = structuredClone(g),
|
|
done = normalizeAutomaticGame(reload(g));
|
|
assert.deepEqual(g, old);
|
|
assert.equal(done.pendingTreacheryDiscard, null);
|
|
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
|
|
return done;
|
|
}
|
|
function sabotage(gift = false) {
|
|
const g = fixture();
|
|
hold(g, 'e', 3);
|
|
if (gift) hold(g, 'm', 1);
|
|
const before = enter(g),
|
|
pending = inner(before, 'm', { type: 'decision', reveal: true });
|
|
assert.equal(continuation(pending).source, 'sabotage');
|
|
return { before, pending };
|
|
}
|
|
function robbery(initialCards = 4, emptyDeck = false) {
|
|
const initial = fixture('robbery');
|
|
hold(initial, 'm', initialCards);
|
|
if (emptyDeck) {
|
|
initial.discard.push(...initial.deck);
|
|
initial.deck = [];
|
|
}
|
|
const before = decide(decide(enter(initial), { reveal: true }), {
|
|
choice: 'card',
|
|
});
|
|
assert.equal(before.pendingTerrorEntry?.stage, 'discard');
|
|
return before;
|
|
}
|
|
|
|
for (const gift of [false, true])
|
|
void test(`Sabotage recovery preserves its one random victim and ${gift ? 'opens the optional gift' : 'finishes without a gift'}`, () => {
|
|
const { before, pending } = sabotage(gift),
|
|
batch = pending.pendingTreacheryDiscard!.batch;
|
|
const c = continuation(pending),
|
|
victim = batch.entries[0].card;
|
|
assert.equal(batch.entries.length, 1);
|
|
assert.equal(batch.cause, 'terror:sabotage');
|
|
assert.equal(batch.entries[0].discardedBy, 'e');
|
|
assert.equal(batch.entries[0].publicFace, false);
|
|
assert.equal(c.entry.stage, 'offer');
|
|
assert.equal(c.discardedHandSize, 3);
|
|
assert.equal(pending.pendingTerrorEntry, null);
|
|
assert.equal(pending.decision, null);
|
|
assert.ok(player(before, 'e').hand.some((card) => card.id === victim.id));
|
|
assert.ok(!player(pending, 'e').hand.some((card) => card.id === victim.id));
|
|
assert.equal(player(pending, 'e').spice, 18);
|
|
assert.equal(player(pending, 'e').reserves, 18);
|
|
const token = pending.moritaniTerror!.tokens.find(
|
|
(t) => t.id === c.entry.token,
|
|
)!;
|
|
assert.equal(token.status, 'removed');
|
|
assert.equal(token.location, null);
|
|
const done = recover(pending);
|
|
assert.deepEqual(done.discard, pending.discard);
|
|
assert.deepEqual(done.deck, pending.deck);
|
|
assert.deepEqual(done.players, pending.players);
|
|
assert.deepEqual(done.moritaniTerror, pending.moritaniTerror);
|
|
assert.deepEqual(done.log, pending.log);
|
|
assert.equal(done.resolvedTreacheryDiscardSequence, 1);
|
|
if (gift) {
|
|
assert.equal(done.pendingTerrorEntry?.stage, 'gift');
|
|
assert.equal(done.decision?.kind, 'moritaniTerror');
|
|
const gifted = player(done, 'm').hand[0].id;
|
|
const result = decide(reload(done), { card: gifted });
|
|
assert.ok(player(result, 'e').hand.some((card) => card.id === gifted));
|
|
assert.equal(result.pendingTerrorEntry, null);
|
|
assert.equal(result.treacheryDiscardSequence, 1);
|
|
assert.deepEqual(result.discard, done.discard);
|
|
} else assert.equal(done.pendingTerrorEntry, null);
|
|
assert.deepEqual(inventory(done), inventory(before));
|
|
});
|
|
|
|
void test('empty Sabotage does not invent a discard event, while still permitting the optional gift', () => {
|
|
for (const gift of [false, true]) {
|
|
const initial = fixture();
|
|
if (gift) hold(initial, 'm', 1);
|
|
const result = inner(enter(initial), 'm', {
|
|
type: 'decision',
|
|
reveal: true,
|
|
});
|
|
assert.equal(result.pendingTreacheryDiscard ?? null, null);
|
|
assert.equal(result.treacheryDiscardSequence ?? 0, 0);
|
|
assert.equal(result.discard.length, 0);
|
|
assert.equal(
|
|
result.pendingTerrorEntry?.stage ?? null,
|
|
gift ? 'gift' : null,
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('Robbery recovery never redraws or reshuffles when discarding an old or newly drawn card', () => {
|
|
for (const emptyDeck of [false, true])
|
|
for (const drawn of [false, true]) {
|
|
const before = robbery(4, emptyDeck),
|
|
hand = player(before, 'm').hand;
|
|
const card = hand[drawn ? 4 : 0].id,
|
|
pending = inner(before, 'm', { type: 'decision', card });
|
|
assert.equal(continuation(pending).source, 'robberyOverflow');
|
|
assert.equal(continuation(pending).entry.stage, 'discard');
|
|
assert.equal(continuation(pending).discardedHandSize, 5);
|
|
assert.equal(
|
|
pending.pendingTreacheryDiscard!.batch.entries[0].discardedBy,
|
|
'm',
|
|
);
|
|
assert.equal(
|
|
pending.pendingTreacheryDiscard!.batch.entries[0].publicFace,
|
|
false,
|
|
);
|
|
const done = recover(pending);
|
|
assert.deepEqual(done.deck, pending.deck);
|
|
assert.deepEqual(done.discard, pending.discard);
|
|
assert.deepEqual(done.players, pending.players);
|
|
assert.deepEqual(done.log, pending.log);
|
|
assert.equal(player(done, 'm').hand.length, 4);
|
|
assert.equal(done.pendingTerrorEntry, null);
|
|
assert.deepEqual(done, decide(before, { card }));
|
|
assert.deepEqual(inventory(done), inventory(before));
|
|
}
|
|
});
|
|
|
|
void test('exceptional Robbery overflow reopens only the next discard and retires each batch once', () => {
|
|
const before = robbery(5),
|
|
one = inner(before, 'm', {
|
|
type: 'decision',
|
|
card: player(before, 'm').hand[0].id,
|
|
});
|
|
const next = recover(one);
|
|
assert.equal(player(next, 'm').hand.length, 5);
|
|
assert.equal(next.pendingTerrorEntry?.stage, 'discard');
|
|
const two = inner(next, 'm', {
|
|
type: 'decision',
|
|
card: player(next, 'm').hand[0].id,
|
|
});
|
|
assert.equal(two.pendingTreacheryDiscard!.sequence, 2);
|
|
assert.notEqual(
|
|
two.pendingTreacheryDiscard!.batch.event,
|
|
one.pendingTreacheryDiscard!.batch.event,
|
|
);
|
|
const done = recover(two);
|
|
assert.equal(player(done, 'm').hand.length, 4);
|
|
assert.equal(done.pendingTerrorEntry, null);
|
|
assert.equal(done.resolvedTreacheryDiscardSequence, 2);
|
|
assert.deepEqual(done.deck, before.deck);
|
|
const replay = reload(done);
|
|
replay.pendingTreacheryDiscard = one.pendingTreacheryDiscard;
|
|
assert.throws(() => normalizeAutomaticGame(replay));
|
|
});
|
|
|
|
void test('all seats see only the automatic marker and hidden-face changes preserve their projections', () => {
|
|
const rob = robbery(),
|
|
frames = [
|
|
sabotage(true).pending,
|
|
inner(rob, 'm', { type: 'decision', card: player(rob, 'm').hand[0].id }),
|
|
];
|
|
for (const pending of frames) {
|
|
const alternate = reload(pending),
|
|
entry = alternate.pendingTreacheryDiscard!.batch.entries[0];
|
|
const index = alternate.discard.findIndex((c) => c.id === entry.card.id),
|
|
replacement = alternate.deck[0];
|
|
alternate.deck[0] = alternate.discard[index];
|
|
alternate.discard[index] = replacement;
|
|
entry.card = structuredClone(replacement);
|
|
for (const p of pending.players) {
|
|
const view = viewGame(pending, p.id);
|
|
assert.equal(view.automaticContinuationPending, true);
|
|
assert.equal(view.terrorEntry, null);
|
|
assert.ok(!('pendingTreacheryDiscard' in view));
|
|
assert.ok(!('discard' in view));
|
|
assert.ok(
|
|
!JSON.stringify(view).includes(
|
|
pending.pendingTreacheryDiscard!.batch.entries[0].card.id,
|
|
),
|
|
);
|
|
assert.deepEqual(viewGame(alternate, p.id), view);
|
|
for (const level of DIFFICULTIES) {
|
|
view.players.find((x) => x.id === p.id)!.bot = level;
|
|
assert.deepEqual(botActions(view), []);
|
|
}
|
|
}
|
|
const done = runBots(reload(pending), 0);
|
|
assert.equal(done.pendingTreacheryDiscard, null);
|
|
assert.deepEqual(
|
|
{ ...done, botsPending: undefined },
|
|
{ ...recover(pending), botsPending: undefined },
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('Terror frames reject stale stage, removed-token identity, hand counts, overlays and physical custody atomically', () => {
|
|
const before = robbery(),
|
|
frames = [
|
|
sabotage().pending,
|
|
inner(before, 'm', {
|
|
type: 'decision',
|
|
card: player(before, 'm').hand[0].id,
|
|
}),
|
|
];
|
|
for (const valid of frames) {
|
|
const token = (g: Game) =>
|
|
g.moritaniTerror!.tokens.find(
|
|
(t) => t.id === continuation(g).entry.token,
|
|
)!;
|
|
const mutations: ((g: Game) => void)[] = [
|
|
(g) => {
|
|
g.turn++;
|
|
},
|
|
(g) => {
|
|
g.phase = 4;
|
|
},
|
|
(g) => {
|
|
g.treacheryDiscardSequence!++;
|
|
},
|
|
(g) => {
|
|
g.pendingTreacheryDiscard!.batch.event = 'stale';
|
|
},
|
|
(g) => {
|
|
continuation(g).owner = 'e';
|
|
},
|
|
(g) => {
|
|
continuation(g).entry.entrant = 'm';
|
|
},
|
|
(g) => {
|
|
continuation(g).entry.entrant = 'missing';
|
|
},
|
|
(g) => {
|
|
continuation(g).entry.stage = 'gift';
|
|
},
|
|
(g) => {
|
|
continuation(g).entry.turn--;
|
|
},
|
|
(g) => {
|
|
continuation(g).entry.phase--;
|
|
},
|
|
(g) => {
|
|
continuation(g).entry.territory = 'red_chasm';
|
|
},
|
|
(g) => {
|
|
continuation(g).entry.sector = 1;
|
|
},
|
|
(g) => {
|
|
continuation(g).entry.amount = -1;
|
|
},
|
|
(g) => {
|
|
continuation(g).entry.elite = 3;
|
|
},
|
|
(g) => {
|
|
continuation(g).entry.resume = 'wormRide';
|
|
},
|
|
(g) => {
|
|
continuation(g).discardedHandSize++;
|
|
},
|
|
(g) => {
|
|
continuation(g).discardedHandSize = 0;
|
|
},
|
|
(g) => {
|
|
token(g).status = 'placed';
|
|
},
|
|
(g) => {
|
|
token(g).location = 'arrakeen';
|
|
},
|
|
(g) => {
|
|
token(g).kind = 'assassination';
|
|
},
|
|
(g) => {
|
|
g.moritaniTerror!.tokens.push(structuredClone(token(g)));
|
|
},
|
|
(g) => {
|
|
continuation(g).entry.token = 'missing';
|
|
},
|
|
(g) => {
|
|
g.pendingTerrorEntry = structuredClone(continuation(g).entry);
|
|
},
|
|
(g) => {
|
|
g.pendingTreacheryDiscard!.batch.entries[0].publicFace = true;
|
|
},
|
|
(g) => {
|
|
g.pendingTreacheryDiscard!.batch.entries[0].discardedBy = 'a';
|
|
},
|
|
(g) => {
|
|
g.pendingTreacheryDiscard!.batch.cause = 'terror:wrong';
|
|
},
|
|
(g) => {
|
|
g.deck.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 key of [
|
|
'truthtrance',
|
|
'response',
|
|
'decision',
|
|
'pendingNullentropy',
|
|
'pendingRicheseGift',
|
|
'pendingKarama',
|
|
])
|
|
mutations.push((g) => {
|
|
Object.assign(g, { [key]: {} });
|
|
});
|
|
for (const mutate of mutations) {
|
|
const corrupt = reload(valid);
|
|
mutate(corrupt);
|
|
const old = structuredClone(corrupt);
|
|
assert.throws(() => normalizeAutomaticGame(corrupt));
|
|
assert.throws(() => applyAction(corrupt, 'm', { type: 'advanceBots' }));
|
|
assert.throws(() => viewGame(corrupt, 'm'));
|
|
assert.deepEqual(corrupt, old);
|
|
}
|
|
}
|
|
});
|
|
|
|
function wormEntry(remaining: boolean, truth = false) {
|
|
const g = fixture('robbery', 'fremen');
|
|
Object.assign(g, {
|
|
advanced: true,
|
|
phase: 1,
|
|
nexus: true,
|
|
active: null,
|
|
order: ['e', 'm', 'a'],
|
|
});
|
|
player(g, 'e').forces = {
|
|
'imperial_basin:10': 3,
|
|
...(remaining ? { 'hagga_basin:11': 1 } : {}),
|
|
};
|
|
player(g, 'e').reserves = remaining ? 16 : 17;
|
|
const karama = named(g, 'e', 'Karama');
|
|
if (truth) named(g, 'm', 'Truthtrance');
|
|
hold(g, 'm', truth ? 3 : 4);
|
|
g.decision = { kind: 'wormRide', player: 'e', territory: 'imperial_basin' };
|
|
g.wormRides = remaining ? ['hagga_basin'] : [];
|
|
const entered = applyAction(g, 'e', {
|
|
type: 'decision',
|
|
accept: true,
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
forces: { 'imperial_basin:10': 2 },
|
|
});
|
|
return applyAction(entered, 'e', {
|
|
type: 'card',
|
|
mode: 'special',
|
|
card: karama,
|
|
territory: 'broken_land',
|
|
});
|
|
}
|
|
|
|
for (const remaining of [false, true])
|
|
void test(`Robbery discard restores the summoned Nexus before ${remaining ? 'the remaining worm ride' : 'phase advancement'}`, () => {
|
|
let g = wormEntry(remaining);
|
|
const forces = structuredClone(player(g, 'e').forces);
|
|
assert.equal(g.summonedNexusBeforeRides, true);
|
|
g = decide(decide(g, { reveal: true }), { choice: 'card' });
|
|
const pending = inner(g, 'm', {
|
|
type: 'decision',
|
|
card: player(g, 'm').hand[0].id,
|
|
});
|
|
assert.equal(continuation(pending).entry.resume, 'wormRide');
|
|
const after = recover(pending);
|
|
assert.equal(after.nexus, true);
|
|
assert.equal(after.phase, 1);
|
|
assert.equal(after.summonedNexusBeforeRides, false);
|
|
assert.equal(after.pendingTerrorEntry, null);
|
|
assert.equal(after.decision, null);
|
|
assert.deepEqual(after.ready, []);
|
|
assert.deepEqual(player(after, 'e').forces, forces);
|
|
assert.deepEqual(after.deck, g.deck);
|
|
let result = after;
|
|
for (const p of result.players)
|
|
result = applyAction(result, p.id, { type: 'ready' });
|
|
if (remaining)
|
|
assert.deepEqual(result.decision, {
|
|
kind: 'wormRide',
|
|
player: 'e',
|
|
territory: 'hagga_basin',
|
|
});
|
|
else assert.equal(result.phase, 2);
|
|
assert.deepEqual(player(result, 'e').forces, forces);
|
|
inventory(result);
|
|
});
|
|
|
|
void test('Truthtrance consumes Robbery overflow with only its own discard event and resumes the deferred Nexus once', () => {
|
|
let g = wormEntry(true, true);
|
|
const truth = player(g, 'm').hand.find((c) => c.effect === 'truthtrance')!.id;
|
|
g = decide(decide(g, { reveal: true }), { choice: 'card' });
|
|
const deck = structuredClone(g.deck),
|
|
forces = structuredClone(player(g, 'e').forces);
|
|
g = applyAction(g, 'm', { type: 'card', card: truth });
|
|
while (g.truthtrance?.stage === 'priority') {
|
|
const id = g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id;
|
|
g = applyAction(g, id, { type: 'truthPass' });
|
|
}
|
|
g = applyAction(g, 'm', {
|
|
type: 'truthAsk',
|
|
question: {
|
|
kind: 'fact',
|
|
target: 'a',
|
|
fact: { kind: 'hand', name: 'Shield' },
|
|
},
|
|
});
|
|
g = applyAction(reload(g), 'a', { type: 'truthAnswer', answer: 'no' });
|
|
assert.equal(g.pendingTerrorEntry, null);
|
|
assert.equal(g.decision, null);
|
|
assert.equal(g.truthtrance, null);
|
|
assert.equal(g.treacheryDiscardSequence, 1);
|
|
assert.equal(g.resolvedTreacheryDiscardSequence, 1);
|
|
assert.equal(g.pendingTreacheryDiscard ?? null, null);
|
|
assert.equal(player(g, 'm').hand.length, 4);
|
|
assert.equal(g.nexus, true);
|
|
assert.equal(g.phase, 1);
|
|
assert.deepEqual(g.deck, deck);
|
|
assert.deepEqual(player(g, 'e').forces, forces);
|
|
assert.equal(g.discard.filter((c) => c.id === truth).length, 1);
|
|
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
|
|
inventory(g);
|
|
});
|
|
|
|
void test('saved Terror origins require the real Fremen worm-ride continuation and reject conflicting active contexts', () => {
|
|
let worm = wormEntry(true);
|
|
worm = decide(decide(worm, { reveal: true }), { choice: 'card' });
|
|
const ridden = inner(worm, 'm', {
|
|
type: 'decision',
|
|
card: player(worm, 'm').hand[0].id,
|
|
});
|
|
const shipped = sabotage().pending;
|
|
assert.equal(continuation(ridden).entry.cause, 'wormRide');
|
|
assert.equal(continuation(ridden).entry.resume, 'wormRide');
|
|
assert.equal(continuation(shipped).entry.cause, 'shipment');
|
|
assert.equal(continuation(shipped).entry.resume, 'none');
|
|
const cases: [Game, (g: Game) => void][] = [
|
|
[
|
|
ridden,
|
|
(g) => {
|
|
continuation(g).entry.resume = 'none';
|
|
},
|
|
],
|
|
[
|
|
ridden,
|
|
(g) => {
|
|
player(g, 'e').faction = 'emperor';
|
|
},
|
|
],
|
|
[
|
|
ridden,
|
|
(g) => {
|
|
continuation(g).entry.cause = 'movement';
|
|
},
|
|
],
|
|
[
|
|
ridden,
|
|
(g) => {
|
|
// Keep the generic frame stamp coherent so the producer's phase binding
|
|
// itself rejects a worm ride transplanted into Shipment & Movement.
|
|
g.phase = 5;
|
|
continuation(g).entry.phase = 5;
|
|
g.pendingTreacheryDiscard!.batch.phase = 5;
|
|
g.pendingTreacheryDiscard!.batch.event = `discard:${g.turn}:5:${g.treacheryDiscardSequence}`;
|
|
},
|
|
],
|
|
[
|
|
shipped,
|
|
(g) => {
|
|
continuation(g).entry.cause = 'wormRide';
|
|
},
|
|
],
|
|
[
|
|
shipped,
|
|
(g) => {
|
|
continuation(g).entry.resume = 'wormRide';
|
|
},
|
|
],
|
|
];
|
|
for (const source of [ridden, shipped])
|
|
for (const key of ['pendingExchange', 'summonedWorm'])
|
|
cases.push([
|
|
source,
|
|
(g) => {
|
|
Object.assign(g, { [key]: {} });
|
|
},
|
|
]);
|
|
for (const [source, mutate] of cases) {
|
|
const corrupt = reload(source);
|
|
mutate(corrupt);
|
|
const before = structuredClone(corrupt);
|
|
assert.throws(() => normalizeAutomaticGame(corrupt));
|
|
assert.throws(() => applyAction(corrupt, 'm', { type: 'advanceBots' }));
|
|
assert.throws(() => viewGame(corrupt, 'm'));
|
|
assert.deepEqual(corrupt, before);
|
|
}
|
|
recover(ridden);
|
|
recover(shipped);
|
|
});
|
|
|
|
void test('an actual zero-valued No-Field shipment triggers Sabotage and restores its one-marker arrival receipt', () => {
|
|
const initial = fixture('sabotage', 'richese');
|
|
initial.expansions = ['choam'];
|
|
const richese = player(initial, 'e');
|
|
richese.noField = createRicheseNoField([
|
|
'zero-marker',
|
|
'three-marker',
|
|
'five-marker',
|
|
]);
|
|
richese.noFieldEvent = 'zero-shipment-event';
|
|
hold(initial, 'e', 2);
|
|
const ids = inventory(initial);
|
|
const entered = applyAction(initial, 'e', {
|
|
type: 'ship',
|
|
noField: 'zero-marker',
|
|
event: richese.noFieldEvent,
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
});
|
|
assert.equal(entered.pendingTerrorEntry?.stage, 'offer');
|
|
// The concealed zero marker counts as one arrival, without moving any
|
|
// physical reserve force. The Terror receipt records that public presence.
|
|
assert.equal(entered.pendingTerrorEntry?.amount, 1);
|
|
assert.equal(entered.pendingTerrorEntry?.elite, 0);
|
|
assert.equal(player(entered, 'e').shipped, true);
|
|
assert.equal(player(entered, 'e').spice, 19);
|
|
assert.equal(player(entered, 'e').reserves, 20);
|
|
assert.deepEqual(player(entered, 'e').forces, {});
|
|
assert.ok(player(entered, 'e').noField!.deployed);
|
|
assert.equal(
|
|
player(entered, 'e').noField!.tokens.find((t) => t.id === 'zero-marker')!
|
|
.value,
|
|
0,
|
|
);
|
|
const pending = inner(entered, 'm', { type: 'decision', reveal: true });
|
|
assert.equal(continuation(pending).entry.amount, 1);
|
|
assert.equal(continuation(pending).entry.cause, 'shipment');
|
|
const settled = recover(pending);
|
|
assert.equal(settled.pendingTerrorEntry, null);
|
|
assert.equal(player(settled, 'e').hand.length, 1);
|
|
assert.deepEqual(player(settled, 'e').noField, player(entered, 'e').noField);
|
|
assert.equal(player(settled, 'e').spice, 19);
|
|
assert.equal(player(settled, 'e').reserves, 20);
|
|
assert.deepEqual(player(settled, 'e').forces, {});
|
|
assert.deepEqual(inventory(settled), ids);
|
|
for (const p of settled.players) viewGame(settled, p.id);
|
|
});
|