375 lines
11 KiB
TypeScript
375 lines
11 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
prepareSpecialKaramaIntent,
|
|
executeSpecialKaramaIntent,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { treacheryDeck } from '../game/cards';
|
|
import { mobileRoutes, MOBILE_LOCATION } from '../game/board';
|
|
import type { FactionId } from '../game/catalog';
|
|
|
|
function fixture(faction: FactionId) {
|
|
const g = createGame('INTENT22', newPlayer('p', 'Actor', faction), true, [
|
|
'ix',
|
|
'choam',
|
|
]);
|
|
g.players.push(
|
|
newPlayer(
|
|
'q',
|
|
'Opponent',
|
|
faction === 'harkonnen' ? 'atreides' : 'harkonnen',
|
|
),
|
|
);
|
|
g.status = 'playing';
|
|
g.phase = 4;
|
|
g.order = ['p', 'q'];
|
|
g.active = 'p';
|
|
g.deck = treacheryDeck(['ix']);
|
|
for (const p of g.players) {
|
|
p.spice = 20;
|
|
p.traitorChoices = [];
|
|
p.traitors = [];
|
|
}
|
|
hold(g, 'p', 'Karama');
|
|
return g;
|
|
}
|
|
function hold(g: Game, owner: string, name: string) {
|
|
const index = g.deck.findIndex((card) => card.name === name);
|
|
assert.ok(index >= 0, name);
|
|
const card = g.deck.splice(index, 1)[0];
|
|
g.players.find((p) => p.id === owner)!.hand.push(card);
|
|
return card.id;
|
|
}
|
|
function action(g: Game, extra: Record<string, unknown> = {}): Action {
|
|
return {
|
|
type: 'card',
|
|
mode: 'special',
|
|
card: g.players[0].hand.find((card) => card.effect === 'karama')!.id,
|
|
...extra,
|
|
};
|
|
}
|
|
function emperor() {
|
|
const g = fixture('emperor');
|
|
g.players[0].tanks = 5;
|
|
g.players[0].reserves = 15;
|
|
g.players[0].elites = { tanks: 2, reserves: 3, forces: {}, revived: 0 };
|
|
return g;
|
|
}
|
|
function guild() {
|
|
const g = fixture('guild');
|
|
g.phase = 5;
|
|
g.active = 'q';
|
|
g.pendingShipment = {
|
|
player: 'q',
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
amount: 2,
|
|
elite: 0,
|
|
cost: 2,
|
|
allyPayment: 0,
|
|
advisors: false,
|
|
};
|
|
g.decision = {
|
|
kind: 'guildShipment',
|
|
player: 'p',
|
|
shipper: 'q',
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
amount: 2,
|
|
};
|
|
return g;
|
|
}
|
|
function tleilaxu() {
|
|
const g = fixture('tleilaxu');
|
|
g.pendingRevival = {
|
|
player: 'q',
|
|
kind: 'forces',
|
|
amount: 2,
|
|
elite: 0,
|
|
normalCost: 0,
|
|
cost: 0,
|
|
free: 2,
|
|
checks: [],
|
|
};
|
|
g.decision = {
|
|
kind: 'revivalStop',
|
|
player: 'p',
|
|
recipient: 'q',
|
|
revival: 'forces',
|
|
};
|
|
return g;
|
|
}
|
|
function atreides() {
|
|
let g = fixture('atreides');
|
|
g.phase = 6;
|
|
g.storm = 18;
|
|
for (const p of g.players) {
|
|
p.forces = { 'arrakeen:10': 2 };
|
|
p.reserves = 18;
|
|
}
|
|
g = applyAction(g, 'p', {
|
|
type: 'chooseBattle',
|
|
territory: 'arrakeen',
|
|
target: 'q',
|
|
});
|
|
while (g.response || g.battle?.preparation) {
|
|
if (g.response)
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
{
|
|
type: 'passResponse',
|
|
},
|
|
);
|
|
else
|
|
g = applyAction(g, g.battle!.preparation!.owner, {
|
|
type: 'declineBattlePower',
|
|
});
|
|
}
|
|
assert.equal(g.decision?.kind, 'fullPlanOffer');
|
|
return g;
|
|
}
|
|
function examples(): [string, Game, Action][] {
|
|
const e = emperor();
|
|
const leader = emperor();
|
|
leader.players[0].leaders[0].dead = true;
|
|
const c = fixture('choam');
|
|
const cards = [hold(c, 'p', 'Baliset'), hold(c, 'p', 'Snooper')];
|
|
const i = fixture('ixians');
|
|
i.phase = 5;
|
|
i.mobileStronghold = { location: 'polar_sink:0' };
|
|
i.players[0].forces = { [MOBILE_LOCATION]: 1 };
|
|
i.players[0].reserves = 19;
|
|
const f = fixture('fremen');
|
|
f.phase = 1;
|
|
const a = atreides(),
|
|
t = tleilaxu(),
|
|
s = guild();
|
|
const h = fixture('harkonnen');
|
|
h.phase = 3;
|
|
hold(h, 'q', 'Baliset');
|
|
hold(h, 'q', 'Snooper');
|
|
return [
|
|
['emperorForces', e, action(e, { amount: 3, elite: 1 })],
|
|
[
|
|
'emperorLeader',
|
|
leader,
|
|
action(leader, { leader: leader.players[0].leaders[0].id }),
|
|
],
|
|
['choam', c, action(c, { cards })],
|
|
['ixians', i, action(i, { route: mobileRoutes(i, 2)[0], collect: false })],
|
|
['fremen', f, action(f, { territory: 'hagga_basin' })],
|
|
['atreides', a, action(a, { target: 'q' })],
|
|
['tleilaxu', t, action(t)],
|
|
['guild', s, action(s)],
|
|
['harkonnen', h, action(h, { target: 'q', amount: 1 })],
|
|
];
|
|
}
|
|
|
|
void test('preparing every implemented special Karama does not mutate state or draw randomness', (t) => {
|
|
const cases = examples();
|
|
t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw new Error('Preparation must not draw randomness.');
|
|
});
|
|
for (const [kind, g, a] of cases) {
|
|
const before = structuredClone(g);
|
|
prepareSpecialKaramaIntent(g, 'p', a);
|
|
assert.deepEqual(g, before, kind);
|
|
}
|
|
});
|
|
|
|
void test('Emperor force revival commits once after serialization and never during preparation', () => {
|
|
const g = emperor();
|
|
const intent = prepareSpecialKaramaIntent(
|
|
g,
|
|
'p',
|
|
action(g, { amount: 3, elite: 1 }),
|
|
);
|
|
assert.equal(g.players[0].tanks, 5);
|
|
assert.equal(g.players[0].specialKaramaUsed, undefined);
|
|
executeSpecialKaramaIntent(g, JSON.parse(JSON.stringify(intent)));
|
|
assert.equal(g.players[0].tanks, 2);
|
|
assert.equal(g.players[0].reserves, 18);
|
|
assert.equal(g.players[0].elites!.revived, 0);
|
|
assert.equal(g.players[0].specialKaramaUsed, true);
|
|
assert.equal(g.discard.filter((c) => c.id === intent.card).length, 1);
|
|
const after = structuredClone(g);
|
|
assert.throws(() => executeSpecialKaramaIntent(g, intent));
|
|
assert.deepEqual(g, after);
|
|
});
|
|
|
|
void test('a delayed intent cannot revive changed resources or a leader now controlled elsewhere', () => {
|
|
for (const leader of [false, true]) {
|
|
const g = emperor();
|
|
g.players[0].leaders[0].dead = true;
|
|
const intent = prepareSpecialKaramaIntent(
|
|
g,
|
|
'p',
|
|
action(
|
|
g,
|
|
leader
|
|
? { leader: g.players[0].leaders[0].id }
|
|
: { amount: 3, elite: 1 },
|
|
),
|
|
);
|
|
if (leader) g.players[0].leaders[0].capturedBy = 'q';
|
|
else g.players[0].tanks = 1;
|
|
const before = structuredClone(g);
|
|
assert.throws(() => executeSpecialKaramaIntent(g, intent));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
});
|
|
|
|
void test('every prepared intent rejects a changed phase, turn or missing activation before costs', () => {
|
|
for (const [kind, initial, a] of examples()) {
|
|
const intent = prepareSpecialKaramaIntent(initial, 'p', a);
|
|
for (const change of ['turn', 'phase', 'card'] as const) {
|
|
const g = structuredClone(initial);
|
|
if (change === 'turn') g.turn++;
|
|
else if (change === 'phase') g.phase = (g.phase + 1) % 9;
|
|
else g.players[1].hand.push(g.players[0].hand.splice(0, 1)[0]);
|
|
const before = structuredClone(g);
|
|
assert.throws(
|
|
() => executeSpecialKaramaIntent(g, intent),
|
|
`${kind}: ${change}`,
|
|
);
|
|
assert.deepEqual(g, before, `${kind}: ${change}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('CHOAM prepared selections are detached from the caller and revalidate exact card custody', () => {
|
|
const g = fixture('choam');
|
|
const cards = [hold(g, 'p', 'Baliset')];
|
|
const intent = prepareSpecialKaramaIntent(g, 'p', action(g, { cards }));
|
|
cards.push(hold(g, 'p', 'Snooper'));
|
|
if (intent.kind !== 'choam') throw new Error('Wrong intent');
|
|
assert.equal(intent.cards.length, 1);
|
|
const index = g.players[0].hand.findIndex(
|
|
(card) => card.id === intent.cards[0],
|
|
);
|
|
g.players[1].hand.push(g.players[0].hand.splice(index, 1)[0]);
|
|
const before = structuredClone(g);
|
|
assert.throws(() => executeSpecialKaramaIntent(g, intent));
|
|
assert.deepEqual(g, before);
|
|
});
|
|
|
|
void test('Guild and Tleilaxu intentions bind the actual declaration, not just its owner', () => {
|
|
for (const g of [guild(), tleilaxu()]) {
|
|
const intent = prepareSpecialKaramaIntent(g, 'p', action(g));
|
|
if (g.pendingShipment) g.pendingShipment.amount++;
|
|
if (g.pendingRevival) g.pendingRevival.amount!++;
|
|
const before = structuredClone(g);
|
|
assert.throws(() => executeSpecialKaramaIntent(g, intent));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
});
|
|
|
|
void test('Harkonnen preparation withholds unseen identities until the actual hand transfer', () => {
|
|
const g = fixture('harkonnen');
|
|
g.phase = 3;
|
|
const foreign = [hold(g, 'q', 'Baliset'), hold(g, 'q', 'Snooper')];
|
|
const intent = prepareSpecialKaramaIntent(
|
|
g,
|
|
'p',
|
|
action(g, { target: 'q', amount: 1 }),
|
|
);
|
|
assert.ok(foreign.every((id) => !JSON.stringify(intent).includes(id)));
|
|
executeSpecialKaramaIntent(g, intent);
|
|
assert.equal(g.players[1].hand.length, 1);
|
|
assert.equal(g.players[0].hand.length, 1);
|
|
assert.equal(g.decision?.kind, 'handExchange');
|
|
});
|
|
|
|
void test('a failure while committing a random special effect cannot publish partial public action state', (t) => {
|
|
const g = fixture('harkonnen');
|
|
g.phase = 3;
|
|
hold(g, 'q', 'Baliset');
|
|
hold(g, 'q', 'Snooper');
|
|
const a = action(g, { target: 'q', amount: 1 });
|
|
const before = structuredClone(g);
|
|
t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw new Error('Injected random source failure');
|
|
});
|
|
assert.throws(() => applyAction(g, 'p', a), /Injected random/);
|
|
assert.deepEqual(g, before);
|
|
});
|
|
|
|
const pauseWindows: [string, (g: Game) => void][] = [
|
|
[
|
|
'phase opening',
|
|
(g) => {
|
|
g.phaseOpening = { passed: [], initialize: false };
|
|
},
|
|
],
|
|
[
|
|
'market',
|
|
(g) => {
|
|
g.choamMarket = { owner: 'q', resume: 'phase', blocked: [] };
|
|
},
|
|
],
|
|
[
|
|
'revival',
|
|
(g) => {
|
|
g.pendingRevival = {
|
|
player: 'q',
|
|
kind: 'forces',
|
|
amount: 1,
|
|
normalCost: 0,
|
|
cost: 0,
|
|
free: 1,
|
|
checks: [],
|
|
};
|
|
},
|
|
],
|
|
[
|
|
'Truthtrance',
|
|
(g) => {
|
|
g.truthtrance = {
|
|
stage: 'priority',
|
|
queue: [],
|
|
passed: [],
|
|
question: null,
|
|
};
|
|
},
|
|
],
|
|
];
|
|
|
|
void test('delayed execution respects newly opened table-wide windows just like the public dispatcher', () => {
|
|
for (const [name, pause] of pauseWindows) {
|
|
const g = emperor();
|
|
const a = action(g, { amount: 3, elite: 1 });
|
|
const intent = prepareSpecialKaramaIntent(g, 'p', a);
|
|
pause(g);
|
|
const before = structuredClone(g);
|
|
assert.throws(() => applyAction(g, 'p', a), name);
|
|
assert.throws(() => prepareSpecialKaramaIntent(g, 'p', a), name);
|
|
assert.throws(() => executeSpecialKaramaIntent(g, intent), name);
|
|
assert.deepEqual(g, before, name);
|
|
}
|
|
});
|
|
|
|
void test('CHOAM retains its existing cash-in priority exceptions, but waits for Truthtrance', () => {
|
|
for (const [name, pause] of pauseWindows) {
|
|
const g = fixture('choam');
|
|
const a = action(g, { cards: [hold(g, 'p', 'Baliset')] });
|
|
const intent = prepareSpecialKaramaIntent(g, 'p', a);
|
|
pause(g);
|
|
if (name === 'Truthtrance') {
|
|
const before = structuredClone(g);
|
|
assert.throws(() => executeSpecialKaramaIntent(g, intent));
|
|
assert.deepEqual(g, before);
|
|
} else {
|
|
const publicResult = applyAction(g, 'p', a);
|
|
executeSpecialKaramaIntent(g, intent);
|
|
assert.equal(g.players[0].spice, publicResult.players[0].spice, name);
|
|
assert.equal(g.players[0].specialKaramaUsed, true, name);
|
|
}
|
|
}
|
|
});
|