587 lines
17 KiB
TypeScript
587 lines
17 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { baseDeck, spiceDeck } from '../game/cards';
|
|
import {
|
|
validateTerminalCancellation,
|
|
TerminalCancellationError,
|
|
type TerminalCancellationKind,
|
|
} from '../game/terminal-cancellation';
|
|
const p = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
function fixture(
|
|
faction: 'guild' | 'atreides' | 'fremen' | 'choam' | 'tleilaxu' = 'atreides',
|
|
advanced = true,
|
|
) {
|
|
const g = createGame(
|
|
'TERMINAL',
|
|
newPlayer('b', 'Sisterhood', 'beneGesserit'),
|
|
advanced,
|
|
faction === 'choam' ? ['choam'] : faction === 'tleilaxu' ? ['ix'] : [],
|
|
);
|
|
g.players.push(
|
|
newPlayer('k', 'Canceler', 'harkonnen'),
|
|
newPlayer('e', 'Emperor', 'emperor'),
|
|
newPlayer('p', 'Power', faction),
|
|
);
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 5,
|
|
turn: 2,
|
|
storm: 18,
|
|
active: 'e',
|
|
order: ['e', 'p', 'b', 'k'],
|
|
movementRemaining: ['e', 'p', 'b', 'k'],
|
|
deck: baseDeck(),
|
|
spiceDeck: spiceDeck(),
|
|
phaseOpening: null,
|
|
ready: [],
|
|
});
|
|
for (const seat of g.players)
|
|
Object.assign(seat, {
|
|
hand: [],
|
|
spice: 20,
|
|
forces: {},
|
|
reserves: 20,
|
|
tanks: 0,
|
|
traitors: [],
|
|
traitorChoices: [],
|
|
});
|
|
hold(g, 'k', 'karama');
|
|
hold(g, 'b', 'worthless');
|
|
return g;
|
|
}
|
|
function hold(g: Game, id: string, kind: string) {
|
|
const at = g.deck.findIndex((c) => c.kind === kind || c.effect === kind);
|
|
assert.ok(at >= 0);
|
|
const c = g.deck.splice(at, 1)[0];
|
|
p(g, id).hand.push(c);
|
|
return c;
|
|
}
|
|
function ready(g: Game) {
|
|
for (const seat of g.players) g = applyAction(g, seat.id, { type: 'ready' });
|
|
return g;
|
|
}
|
|
function openPhase(g: Game) {
|
|
g = ready(g);
|
|
if (g.phaseOpening) g = ready(g);
|
|
return g;
|
|
}
|
|
type Case = TerminalCancellationKind | 'gholaIncome';
|
|
function opportunity(kind: Case, advanced = true) {
|
|
const faction =
|
|
kind === 'guildIncome'
|
|
? 'guild'
|
|
: kind === 'revivalIncome' ||
|
|
kind === 'gholaIncome' ||
|
|
kind === 'faceDancerReplacement'
|
|
? 'tleilaxu'
|
|
: kind === 'choamCharity' || kind === 'choamInflation'
|
|
? 'choam'
|
|
: kind === 'stormPeek'
|
|
? 'fremen'
|
|
: 'atreides';
|
|
let g = fixture(faction, advanced);
|
|
if (kind === 'emperorGift' || kind === 'emperorRevival') {
|
|
p(g, 'e').ally = 'p';
|
|
p(g, 'p').ally = 'e';
|
|
if (kind === 'emperorRevival') {
|
|
g.phase = 4;
|
|
p(g, 'p').tanks = 3;
|
|
p(g, 'p').reserves = 17;
|
|
}
|
|
return applyAction(g, 'e', { type: kind, amount: 2 });
|
|
}
|
|
if (kind === 'guildIncome') {
|
|
g = applyAction(g, 'e', {
|
|
type: 'ship',
|
|
territory: 'polar_sink',
|
|
sector: 0,
|
|
amount: 2,
|
|
});
|
|
if (g.decision?.kind === 'guildShipment')
|
|
g = applyAction(g, 'p', { type: 'decision', allow: true });
|
|
return g;
|
|
}
|
|
if (kind === 'revivalIncome' || kind === 'gholaIncome') {
|
|
p(g, 'p').specialKaramaUsed = true;
|
|
p(g, 'e').tanks = 3;
|
|
p(g, 'e').reserves = 17;
|
|
if (kind === 'revivalIncome') {
|
|
g.phase = 4;
|
|
return applyAction(g, 'e', { type: 'revive', amount: 2 });
|
|
}
|
|
const card = hold(g, 'e', 'ghola');
|
|
return applyAction(g, 'e', { type: 'card', card: card.id, amount: 2 });
|
|
}
|
|
if (kind === 'bgCharity') {
|
|
g.phase = 2;
|
|
p(g, 'b').spice = 5;
|
|
return applyAction(g, 'b', { type: 'charity' });
|
|
}
|
|
if (kind === 'choamCharity') {
|
|
g.phase = 1;
|
|
g.nexus = true;
|
|
g.active = null;
|
|
g = openPhase(g);
|
|
if (g.decision?.kind === 'choamMarket')
|
|
g = applyAction(g, 'p', { type: 'decision', done: true });
|
|
return g;
|
|
}
|
|
if (kind === 'choamInflation') {
|
|
g.phase = 8;
|
|
return applyAction(g, 'p', { type: 'choamInflation', side: 'double' });
|
|
}
|
|
if (kind === 'stormPeek') {
|
|
g.phase = 8;
|
|
g.turn = 1;
|
|
g.active = null;
|
|
g = openPhase(g);
|
|
assert.equal(g.turn, 2);
|
|
assert.equal(g.phase, 0);
|
|
assert.equal(g.stormMovementSource?.kind, 'card');
|
|
return openPhase(g);
|
|
}
|
|
if (kind === 'atreidesSpice') {
|
|
g.phase = 4;
|
|
return openPhase(g);
|
|
}
|
|
if (kind === 'atreidesAuction') {
|
|
g.phase = 2;
|
|
return openPhase(g);
|
|
}
|
|
if (kind === 'faceDancerReplacement') {
|
|
g.phase = 8;
|
|
p(g, 'p').faceDancers = [
|
|
{ leader: p(g, 'e').leaders[0].id, revealed: false },
|
|
];
|
|
return applyAction(g, 'p', {
|
|
type: 'replaceFaceDancer',
|
|
leader: p(g, 'p').faceDancers![0].leader,
|
|
});
|
|
}
|
|
g = applyAction(g, 'e', {
|
|
type: 'ship',
|
|
territory: 'polar_sink',
|
|
sector: 0,
|
|
amount: 1,
|
|
});
|
|
assert.equal(g.decision?.kind, 'advisor');
|
|
return applyAction(g, 'b', { type: 'decision', accept: true });
|
|
}
|
|
const cases: Case[] = [
|
|
'advisor',
|
|
'emperorGift',
|
|
'emperorRevival',
|
|
'guildIncome',
|
|
'revivalIncome',
|
|
'gholaIncome',
|
|
'bgCharity',
|
|
'choamCharity',
|
|
'choamInflation',
|
|
'stormPeek',
|
|
'atreidesSpice',
|
|
'atreidesAuction',
|
|
'faceDancerReplacement',
|
|
];
|
|
function quote(g: Game) {
|
|
assert.ok(g.response);
|
|
return validateTerminalCancellation(g, g.response);
|
|
}
|
|
function cancel(g: Game, bg = false) {
|
|
const actor = bg ? 'b' : 'k',
|
|
card = p(g, actor).hand.find((c) =>
|
|
bg ? c.kind === 'worthless' : c.effect === 'karama',
|
|
)!;
|
|
assert.ok(card);
|
|
let done = applyAction(g, actor, {
|
|
type: 'card',
|
|
card: card.id,
|
|
mode: 'cancel',
|
|
});
|
|
if (bg) {
|
|
assert.equal(done.response?.kind, 'worthlessKarama');
|
|
while (done.response?.kind === 'worthlessKarama')
|
|
done = applyAction(
|
|
done,
|
|
done.players.find((p) => !done.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
}
|
|
assert.equal(done.discard.filter((c) => c.id === card.id).length, 1);
|
|
return done;
|
|
}
|
|
for (const kind of cases)
|
|
void test(`${kind} genuine terminal cancellation quote is pure, private and does not sample denied effects`, (t) => {
|
|
const g = opportunity(kind);
|
|
assert.equal(
|
|
g.response?.kind,
|
|
kind === 'gholaIncome' ? 'revivalIncome' : kind,
|
|
);
|
|
const before = structuredClone(g);
|
|
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw Error('Pure terminal quote drew randomness');
|
|
});
|
|
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
|
throw Error('Pure terminal quote generated UUID');
|
|
});
|
|
const result = quote(g);
|
|
assert.ok(result);
|
|
assert.deepEqual(quote(g), result);
|
|
assert.deepEqual(g, before);
|
|
for (const seat of g.players)
|
|
Object.defineProperty(seat, 'hand', {
|
|
get() {
|
|
throw Error('Terminal quote read private hand');
|
|
},
|
|
});
|
|
assert.deepEqual(quote(g), result);
|
|
assert.equal(JSON.stringify(result).includes('card'), false);
|
|
assert.equal(JSON.stringify(result).includes('leader'), false);
|
|
assert.equal(random.mock.callCount(), 0);
|
|
assert.equal(uuid.mock.callCount(), 0);
|
|
});
|
|
for (const kind of cases)
|
|
for (const bg of [false, true]) {
|
|
if (bg && (kind === 'advisor' || kind === 'bgCharity')) continue;
|
|
void test(`${bg ? 'BG' : 'printed'} ${kind} cancellation preserves committed resources and applies only terminal flags`, () => {
|
|
const g = opportunity(kind),
|
|
q = quote(g)!;
|
|
const done = cancel(reload(g), bg);
|
|
assert.deepEqual(
|
|
done.players.map((p) => ({
|
|
spice: p.spice,
|
|
reserves: p.reserves,
|
|
tanks: p.tanks,
|
|
forces: p.forces,
|
|
elites: p.elites,
|
|
faceDancers: p.faceDancers,
|
|
})),
|
|
g.players.map((p) => ({
|
|
spice: p.spice,
|
|
reserves: p.reserves,
|
|
tanks: p.tanks,
|
|
forces: p.forces,
|
|
elites: p.elites,
|
|
faceDancers: p.faceDancers,
|
|
})),
|
|
);
|
|
if (q.kind === 'choamCharity')
|
|
assert.deepEqual(done.choamCharity, q.receipt);
|
|
else if (q.kind === 'stormPeek')
|
|
assert.equal(done.stormCardKnown, q.known);
|
|
else if (q.kind === 'atreidesSpice')
|
|
assert.equal(done.spicePeekKnown, q.known);
|
|
else if (q.kind === 'atreidesAuction')
|
|
assert.equal(done.auction!.peekKnown, q.known);
|
|
else if (q.kind === 'choamInflation') {
|
|
assert.equal(done.inflation, g.inflation);
|
|
assert.equal(done.inflationAttemptTurn, g.turn);
|
|
} else if (q.kind === 'faceDancerReplacement')
|
|
assert.equal(p(done, 'p').faceDancerReplacedTurn, g.turn);
|
|
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
|
|
});
|
|
}
|
|
void test('Basic Emperor gifts, extra revivals, income, advisor shipment and expansion attempt variants retain their existing availability', () => {
|
|
for (const kind of [
|
|
'emperorGift',
|
|
'emperorRevival',
|
|
'guildIncome',
|
|
'revivalIncome',
|
|
'gholaIncome',
|
|
'advisor',
|
|
'choamCharity',
|
|
'choamInflation',
|
|
'atreidesSpice',
|
|
'atreidesAuction',
|
|
'faceDancerReplacement',
|
|
] as const) {
|
|
const g = opportunity(kind, false);
|
|
assert.ok(quote(g));
|
|
assert.doesNotThrow(() => cancel(g));
|
|
}
|
|
});
|
|
void test('denied effects do not require current payment, force placement, charity funds or a replacement candidate', () => {
|
|
for (const kind of [
|
|
'emperorGift',
|
|
'emperorRevival',
|
|
'advisor',
|
|
'faceDancerReplacement',
|
|
'bgCharity',
|
|
] as const) {
|
|
const g = opportunity(kind);
|
|
if (kind === 'emperorGift' || kind === 'emperorRevival') {
|
|
p(g, 'e').spice = 0;
|
|
p(g, 'p').tanks = 0;
|
|
p(g, 'p').reserves = 20;
|
|
} else if (kind === 'advisor') {
|
|
p(g, 'b').reserves = 0;
|
|
g.storm = 1;
|
|
} else if (kind === 'faceDancerReplacement') p(g, 'p').faceDancers = [];
|
|
else {
|
|
p(g, 'b').spice = 0;
|
|
g.players.push(newPlayer('c', 'CHOAM', 'choam'));
|
|
g.order.push('c');
|
|
p(g, 'c').spice = 0;
|
|
}
|
|
assert.ok(quote(g));
|
|
assert.doesNotThrow(() => cancel(g));
|
|
}
|
|
});
|
|
void test('gift and Ghola income keep valid non-Revival phase variants, and legacy omitted advisor destination remains Polar Sink', () => {
|
|
let g = fixture();
|
|
g.phase = 1;
|
|
p(g, 'e').ally = 'p';
|
|
p(g, 'p').ally = 'e';
|
|
g = applyAction(g, 'e', { type: 'emperorGift', amount: 2 });
|
|
assert.ok(quote(g));
|
|
assert.doesNotThrow(() => cancel(g, true));
|
|
const ghola = opportunity('gholaIncome');
|
|
assert.equal(ghola.phase, 5);
|
|
assert.ok(quote(ghola));
|
|
const advisor = opportunity('advisor', false);
|
|
delete advisor.response!.location;
|
|
assert.ok(quote(advisor));
|
|
assert.doesNotThrow(() => cancel(advisor));
|
|
});
|
|
void test('unknown families and Richese auction peeks explicitly retain their separate validation contracts', () => {
|
|
const g = fixture();
|
|
assert.equal(
|
|
validateTerminalCancellation(g, { kind: 'voice', owner: 'b', passed: [] }),
|
|
null,
|
|
);
|
|
g.richeseAuction = {} as NonNullable<Game['richeseAuction']>;
|
|
assert.equal(
|
|
validateTerminalCancellation(g, {
|
|
kind: 'atreidesAuction',
|
|
owner: 'p',
|
|
passed: [],
|
|
}),
|
|
null,
|
|
);
|
|
});
|
|
for (const kind of cases)
|
|
void test(`${kind} malformed source owner and concrete receipt fields reject without mutation`, () => {
|
|
const original = opportunity(kind);
|
|
const mutations: ((g: Game) => void)[] = [
|
|
(g) => {
|
|
g.response!.owner = 'missing';
|
|
},
|
|
(g) => {
|
|
g.response!.owner = 'k';
|
|
},
|
|
(g) => {
|
|
g.turn = 0;
|
|
},
|
|
];
|
|
if (
|
|
kind === 'emperorGift' ||
|
|
kind === 'emperorRevival' ||
|
|
kind === 'guildIncome' ||
|
|
kind === 'revivalIncome' ||
|
|
kind === 'gholaIncome' ||
|
|
kind === 'bgCharity'
|
|
)
|
|
mutations.push((g) => {
|
|
g.response!.amount = -1;
|
|
});
|
|
if (
|
|
kind === 'emperorGift' ||
|
|
kind === 'emperorRevival' ||
|
|
kind === 'revivalIncome' ||
|
|
kind === 'gholaIncome'
|
|
)
|
|
mutations.push((g) => {
|
|
g.response!.recipient = 'missing';
|
|
});
|
|
if (kind === 'emperorRevival')
|
|
mutations.push((g) => {
|
|
g.response!.elite = 99;
|
|
});
|
|
if (kind === 'stormPeek')
|
|
mutations.push(
|
|
(g) => {
|
|
g.stormCard = 0;
|
|
},
|
|
(g) => {
|
|
g.phase = 0;
|
|
},
|
|
);
|
|
if (kind === 'atreidesSpice')
|
|
mutations.push((g) => {
|
|
g.spiceDeck = [];
|
|
});
|
|
if (kind === 'atreidesAuction')
|
|
mutations.push((g) => {
|
|
g.auction = null;
|
|
});
|
|
if (kind === 'choamInflation')
|
|
mutations.push((g) => {
|
|
g.response!.intent = 'unknown';
|
|
});
|
|
if (kind === 'choamCharity')
|
|
mutations.push((g) => {
|
|
g.phase = 3;
|
|
});
|
|
if (kind === 'faceDancerReplacement')
|
|
mutations.push(
|
|
(g) => {
|
|
p(g, 'p').faceDancers = undefined;
|
|
},
|
|
(g) => {
|
|
p(g, 'p').faceDancerReplacedTurn = g.turn - 1;
|
|
},
|
|
);
|
|
if (kind === 'advisor')
|
|
mutations.push((g) => {
|
|
g.response!.location = 'polar_sink:00';
|
|
});
|
|
for (const mutate of mutations) {
|
|
const g = reload(original);
|
|
mutate(g);
|
|
const before = structuredClone(g);
|
|
assert.throws(() => quote(g), TerminalCancellationError);
|
|
assert.deepEqual(g, before);
|
|
}
|
|
});
|
|
|
|
for (const kind of cases)
|
|
for (const bg of [false, true]) {
|
|
if (bg && (kind === 'advisor' || kind === 'bgCharity')) continue;
|
|
void test(`${bg ? 'BG' : 'printed'} ${kind} rejects malformed live receipt before card cost`, (t) => {
|
|
const original = opportunity(kind);
|
|
const mutations: ((g: Game) => void)[] = [
|
|
(g) => {
|
|
g.response!.owner = 'missing';
|
|
},
|
|
(g) => {
|
|
g.turn = 0;
|
|
},
|
|
];
|
|
if (
|
|
[
|
|
'emperorGift',
|
|
'emperorRevival',
|
|
'guildIncome',
|
|
'revivalIncome',
|
|
'gholaIncome',
|
|
'bgCharity',
|
|
].includes(kind)
|
|
)
|
|
mutations.push((g) => {
|
|
g.response!.amount = Number.NaN;
|
|
});
|
|
if (
|
|
kind === 'emperorGift' ||
|
|
kind === 'emperorRevival' ||
|
|
kind === 'revivalIncome' ||
|
|
kind === 'gholaIncome'
|
|
)
|
|
mutations.push((g) => {
|
|
g.response!.recipient = 'missing';
|
|
});
|
|
if (kind === 'advisor')
|
|
mutations.push((g) => {
|
|
g.response!.location = 'polar_sink:01';
|
|
});
|
|
if (kind === 'emperorRevival')
|
|
mutations.push(
|
|
(g) => {
|
|
g.phase = 5;
|
|
},
|
|
(g) => {
|
|
g.response!.elite = 3;
|
|
},
|
|
);
|
|
if (kind === 'guildIncome')
|
|
mutations.push((g) => {
|
|
g.phase = 4;
|
|
});
|
|
if (kind === 'bgCharity')
|
|
mutations.push((g) => {
|
|
p(g, 'b').charityTurn = g.turn - 1;
|
|
});
|
|
if (kind === 'choamCharity')
|
|
mutations.push((g) => {
|
|
g.phase = 3;
|
|
});
|
|
if (kind === 'choamInflation')
|
|
mutations.push(
|
|
(g) => {
|
|
g.inflationAttemptTurn = g.turn - 1;
|
|
},
|
|
(g) => {
|
|
g.response!.intent = 'wrong';
|
|
},
|
|
);
|
|
if (kind === 'stormPeek')
|
|
mutations.push(
|
|
(g) => {
|
|
g.stormCard = 9;
|
|
},
|
|
(g) => {
|
|
g.phase = 0;
|
|
},
|
|
);
|
|
if (kind === 'atreidesSpice')
|
|
mutations.push((g) => {
|
|
g.spiceDeck = [];
|
|
});
|
|
if (kind === 'atreidesAuction')
|
|
mutations.push(
|
|
(g) => {
|
|
g.auction = null;
|
|
},
|
|
(g) => {
|
|
g.auction!.index = g.auction!.cards.length;
|
|
},
|
|
);
|
|
if (kind === 'faceDancerReplacement')
|
|
mutations.push(
|
|
(g) => {
|
|
p(g, 'p').faceDancers = undefined;
|
|
},
|
|
(g) => {
|
|
p(g, 'p').faceDancerReplacedTurn = g.turn - 1;
|
|
},
|
|
);
|
|
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw Error('Rejected terminal cancellation drew randomness');
|
|
});
|
|
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
|
throw Error('Rejected terminal cancellation generated event');
|
|
});
|
|
for (const mutate of mutations) {
|
|
const g = reload(original);
|
|
mutate(g);
|
|
const before = structuredClone(g),
|
|
actor = bg ? 'b' : 'k';
|
|
const card = p(g, actor).hand.find((c) =>
|
|
bg ? c.kind === 'worthless' : c.effect === 'karama',
|
|
)!;
|
|
assert.throws(() =>
|
|
applyAction(g, actor, {
|
|
type: 'card',
|
|
card: card.id,
|
|
mode: 'cancel',
|
|
}),
|
|
);
|
|
assert.deepEqual(g, before);
|
|
assert.ok(p(g, actor).hand.some((c) => c.id === card.id));
|
|
assert.equal(
|
|
g.discard.some((c) => c.id === card.id),
|
|
false,
|
|
);
|
|
assert.equal(g.pendingKarama, before.pendingKarama);
|
|
}
|
|
assert.equal(random.mock.callCount(), 0);
|
|
assert.equal(uuid.mock.callCount(), 0);
|
|
});
|
|
}
|