539 lines
18 KiB
TypeScript
539 lines
18 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
initializeBaseGameForAudit,
|
|
joinGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
|
import { FACTIONS, type FactionId } from '../game/catalog';
|
|
import { baseDeck } from '../game/cards';
|
|
import { location, territory } from '../game/board';
|
|
import { isAdvisor } from '../game/advisors';
|
|
import { createTechTokens } from '../game/tech-tokens';
|
|
|
|
const BASE: FactionId[] = [
|
|
'atreides',
|
|
'harkonnen',
|
|
'emperor',
|
|
'fremen',
|
|
'guild',
|
|
'beneGesserit',
|
|
];
|
|
|
|
function lobby(
|
|
roster: FactionId[] = BASE,
|
|
advanced = true,
|
|
reverse = false,
|
|
difficulty?: Difficulty,
|
|
): Game {
|
|
let g = createGame(
|
|
'BASESETUPAUDIT',
|
|
newPlayer(roster[0], roster[0], roster[0]),
|
|
advanced,
|
|
);
|
|
if (reverse)
|
|
g = applyAction(g, roster[0], { type: 'seatPosition', position: 6 });
|
|
for (let i = 1; i < roster.length; i++) {
|
|
joinGame(g, newPlayer(roster[i], roster[i], roster[i]));
|
|
const position = reverse ? 6 - i : i + 1;
|
|
if (g.playerPositions![roster[i]] !== position)
|
|
g = applyAction(g, roster[i], { type: 'seatPosition', position });
|
|
}
|
|
for (const id of roster) g = applyAction(g, id, { type: 'ready' });
|
|
if (difficulty)
|
|
g.players.forEach((p) => {
|
|
p.bot = difficulty;
|
|
});
|
|
return g;
|
|
}
|
|
function rejectUnchanged(g: Game, player: string, action: Action) {
|
|
const before = structuredClone(g);
|
|
assert.throws(() => applyAction(g, player, action));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
function predict(g: Game) {
|
|
return applyAction(g, 'beneGesserit', {
|
|
type: 'predict',
|
|
faction: g.players.find((p) => p.faction !== 'beneGesserit')!.faction,
|
|
turn: 4,
|
|
});
|
|
}
|
|
function assertTraitorInventory(g: Game) {
|
|
const cards = [
|
|
...(g.traitorReserve ?? []),
|
|
...g.players.flatMap((p) => [...p.traitors, ...p.traitorChoices]),
|
|
];
|
|
assert.equal(new Set(cards).size, cards.length);
|
|
assert.deepEqual(
|
|
cards.slice().sort(),
|
|
g.players.flatMap((p) => p.leaders.map((l) => l.id)).sort(),
|
|
);
|
|
}
|
|
function selectTraitors(state: Game) {
|
|
let g = state;
|
|
for (let count = 0; g.setupStage === 'traitors' && count < 6; count++) {
|
|
assertTraitorInventory(g);
|
|
const p = g.players.find((p) => p.traitorChoices.length);
|
|
assert.ok(p, 'The traitor stage must have an entitled unresolved actor.');
|
|
assert.ok(g.players.every((p) => p.hand.length === 0));
|
|
g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0] });
|
|
}
|
|
assert.notEqual(g.setupStage, 'traitors');
|
|
return g;
|
|
}
|
|
function fremenPlacement(elite = 0): Action {
|
|
const key = location('sietch_tabr', territory('sietch_tabr').sectors[0]);
|
|
return {
|
|
type: 'fremenSetup',
|
|
placements: { [key]: 10 },
|
|
elitePlacements: { [key]: elite },
|
|
};
|
|
}
|
|
function completeSetup(state: Game, difficulty: Difficulty, reverse = false) {
|
|
let g = state;
|
|
const history: string[] = [];
|
|
for (let step = 0; g.status === 'setup' && step < 30; step++) {
|
|
let progressed = false;
|
|
const ids = g.players.map((p) => p.id);
|
|
if (reverse) ids.reverse();
|
|
for (const id of ids) {
|
|
const view = viewGame(g, id);
|
|
view.players.find((p) => p.id === id)!.bot = difficulty;
|
|
for (const other of view.players.filter((p) => p.id !== id)) {
|
|
assert.equal(other.hand, undefined);
|
|
assert.equal(other.traitors, undefined);
|
|
assert.equal(other.traitorChoices, undefined);
|
|
assert.equal(other.prediction, undefined);
|
|
}
|
|
assert.equal('traitorReserve' in view, false);
|
|
assert.equal('deck' in view, false);
|
|
const beforeView = structuredClone(view);
|
|
const actions = botActions(view);
|
|
assert.deepEqual(
|
|
view,
|
|
beforeView,
|
|
'Setup candidate generation must leave the entitled projection unchanged.',
|
|
);
|
|
for (const action of actions)
|
|
assert.doesNotThrow(
|
|
() => applyAction(g, id, action),
|
|
`${difficulty}/${g.setupStage}/${id}: ${JSON.stringify(action)}`,
|
|
);
|
|
if (!actions.length) continue;
|
|
history.push(`${g.setupStage}:${actions[0].type}`);
|
|
g = applyAction(g, id, actions[0]);
|
|
progressed = true;
|
|
break;
|
|
}
|
|
assert.ok(
|
|
progressed,
|
|
`Unresolved setup stall: ${difficulty} ${g.setupStage} ${g.players.map((p) => p.faction).join(',')}`,
|
|
);
|
|
}
|
|
assert.equal(g.status, 'playing');
|
|
return { game: g, history };
|
|
}
|
|
function assertComplete(g: Game) {
|
|
assert.equal(g.status, 'playing');
|
|
assert.equal(g.setupStage ?? null, null);
|
|
assert.equal(g.turn, 1);
|
|
assert.equal(g.phase, 0);
|
|
assert.equal(g.storm, 1);
|
|
assert.equal(g.stormDialers.length, 2);
|
|
assert.ok(g.stormDialers.every((id) => g.players.some((p) => p.id === id)));
|
|
for (const p of g.players) {
|
|
assert.equal(p.spice, FACTIONS.find((f) => f.id === p.faction)!.spice);
|
|
assert.equal(
|
|
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
|
20,
|
|
);
|
|
assert.equal(p.hand.length, p.faction === 'harkonnen' ? 2 : 1);
|
|
assert.equal(p.traitors.length, p.faction === 'harkonnen' ? 4 : 1);
|
|
assert.equal(p.traitorChoices.length, 0);
|
|
if (g.advanced && (p.faction === 'fremen' || p.faction === 'emperor')) {
|
|
assert.ok(p.elites);
|
|
assert.equal(
|
|
p.elites.reserves +
|
|
p.elites.tanks +
|
|
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
|
|
p.faction === 'fremen' ? 3 : 5,
|
|
);
|
|
assert.ok(p.elites.reserves <= p.reserves);
|
|
assert.ok(p.elites.tanks <= p.tanks);
|
|
for (const [key, amount] of Object.entries(p.elites.forces))
|
|
assert.ok(amount <= p.forces[key]);
|
|
} else assert.equal(p.elites, undefined);
|
|
if (p.faction === 'fremen') assert.equal(p.reserves, 10);
|
|
if (p.faction === 'beneGesserit') {
|
|
assert.ok(p.prediction);
|
|
assert.equal(p.reserves, 19);
|
|
assert.equal(
|
|
Object.values(p.forces).reduce((a, b) => a + b, 0),
|
|
1,
|
|
);
|
|
if (g.advanced) assert.equal(p.advisorSetup, true);
|
|
else assert.deepEqual(p.forces, { 'polar_sink:0': 1 });
|
|
}
|
|
const own = viewGame(g, p.id).players.find((x) => x.id === p.id)!;
|
|
assert.deepEqual(own.hand, p.hand);
|
|
assert.deepEqual(own.traitors, p.traitors);
|
|
for (const observer of g.players.filter((x) => x.id !== p.id)) {
|
|
const hidden = viewGame(g, observer.id).players.find(
|
|
(x) => x.id === p.id,
|
|
)!;
|
|
assert.equal(hidden.hand, undefined);
|
|
assert.equal(hidden.traitors, undefined);
|
|
assert.equal(hidden.traitorChoices, undefined);
|
|
assert.equal(hidden.prediction, undefined);
|
|
}
|
|
}
|
|
const cards = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)];
|
|
assert.equal(new Set(cards.map((c) => c.id)).size, cards.length);
|
|
assert.deepEqual(
|
|
cards.map((c) => c.id).sort(),
|
|
baseDeck()
|
|
.map((c) => c.id)
|
|
.sort(),
|
|
);
|
|
assertTraitorInventory(g);
|
|
const heldTraitors = g.players.flatMap((p) => p.traitors);
|
|
assert.equal(new Set(heldTraitors).size, heldTraitors.length);
|
|
assert.ok(
|
|
heldTraitors.every((id) =>
|
|
g.players.some((p) => p.leaders.some((l) => l.id === id)),
|
|
),
|
|
);
|
|
}
|
|
function seeded<T>(fn: () => T): T {
|
|
const cryptoObject = globalThis.crypto;
|
|
const original = Object.getOwnPropertyDescriptor(
|
|
cryptoObject,
|
|
'getRandomValues',
|
|
);
|
|
let state = 20260922;
|
|
Object.defineProperty(cryptoObject, 'getRandomValues', {
|
|
configurable: true,
|
|
value: (array: Uint32Array) => {
|
|
assert.ok(array instanceof Uint32Array);
|
|
for (let i = 0; i < array.length; i++) {
|
|
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
|
array[i] = state;
|
|
}
|
|
return array;
|
|
},
|
|
});
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
if (original)
|
|
Object.defineProperty(cryptoObject, 'getRandomValues', original);
|
|
else Reflect.deleteProperty(cryptoObject, 'getRandomValues');
|
|
}
|
|
}
|
|
|
|
void test('source order locks private BG prediction before cards, traitors before placements, and treachery after every force choice in Basic and Advanced', () => {
|
|
for (const advanced of [false, true]) {
|
|
const initial = lobby(BASE, advanced);
|
|
const original = structuredClone(initial);
|
|
let g = initializeBaseGameForAudit(initial);
|
|
assert.deepEqual(initial, original);
|
|
assert.equal(g.status, 'setup');
|
|
assert.equal(g.setupStage, 'prediction');
|
|
for (const p of g.players) {
|
|
assert.deepEqual(p.hand, []);
|
|
assert.deepEqual(p.traitors, []);
|
|
assert.deepEqual(p.traitorChoices, []);
|
|
assert.deepEqual(p.forces, {});
|
|
assert.equal(p.spice, 0);
|
|
assert.equal(p.reserves, 20);
|
|
const view = viewGame(g, p.id);
|
|
assert.equal(view.setupStage, 'prediction');
|
|
assert.deepEqual(view.setupPending, ['beneGesserit']);
|
|
assert.equal('deck' in view, false);
|
|
assert.equal('spiceDeck' in view, false);
|
|
assert.deepEqual(view.players.find((x) => x.id === p.id)!.hand, []);
|
|
}
|
|
rejectUnchanged(g, 'atreides', {
|
|
type: 'predict',
|
|
faction: 'fremen',
|
|
turn: 4,
|
|
});
|
|
rejectUnchanged(g, 'beneGesserit', {
|
|
type: 'traitor',
|
|
leader: 'atreides-0',
|
|
});
|
|
rejectUnchanged(g, 'fremen', fremenPlacement());
|
|
rejectUnchanged(g, 'beneGesserit', {
|
|
type: 'advisorSetup',
|
|
territory: 'carthag',
|
|
sector: 11,
|
|
});
|
|
g = predict(g);
|
|
assert.equal(g.setupStage, 'traitors');
|
|
assertTraitorInventory(g);
|
|
assert.deepEqual(
|
|
g.players.find((p) => p.faction === 'beneGesserit')!.prediction,
|
|
{ faction: 'atreides', turn: 4 },
|
|
);
|
|
assert.ok(
|
|
g.players.every(
|
|
(p) =>
|
|
p.hand.length === 0 &&
|
|
p.spice === 0 &&
|
|
Object.keys(p.forces).length === 0,
|
|
),
|
|
);
|
|
for (const p of g.players)
|
|
assert.equal(
|
|
p.faction === 'harkonnen' ? p.traitors.length : p.traitorChoices.length,
|
|
4,
|
|
);
|
|
for (const p of g.players) {
|
|
const view = viewGame(g, p.id);
|
|
const own = view.players.find((x) => x.id === p.id)!;
|
|
assert.deepEqual(own.traitors, p.traitors);
|
|
assert.deepEqual(own.traitorChoices, p.traitorChoices);
|
|
for (const other of view.players.filter((x) => x.id !== p.id)) {
|
|
assert.equal(other.traitors, undefined);
|
|
assert.equal(other.traitorChoices, undefined);
|
|
assert.equal(other.hand, undefined);
|
|
}
|
|
assert.equal('traitorReserve' in view, false);
|
|
const bg = view.players.find((x) => x.faction === 'beneGesserit')!;
|
|
assert.equal(!!bg.prediction, p.faction === 'beneGesserit');
|
|
}
|
|
rejectUnchanged(g, 'beneGesserit', {
|
|
type: 'predict',
|
|
faction: 'harkonnen',
|
|
turn: 9,
|
|
});
|
|
rejectUnchanged(g, 'fremen', fremenPlacement());
|
|
g = selectTraitors(g);
|
|
assert.equal(g.setupStage, 'forces');
|
|
assert.deepEqual(viewGame(g, 'fremen').setupPending, ['fremen']);
|
|
assert.ok(g.players.every((p) => p.hand.length === 0));
|
|
assert.deepEqual(g.players.find((p) => p.faction === 'atreides')!.forces, {
|
|
'arrakeen:10': 10,
|
|
});
|
|
assert.deepEqual(g.players.find((p) => p.faction === 'harkonnen')!.forces, {
|
|
'carthag:11': 10,
|
|
});
|
|
assert.deepEqual(g.players.find((p) => p.faction === 'guild')!.forces, {
|
|
'tueks_sietch:5': 5,
|
|
});
|
|
for (const p of g.players)
|
|
assert.equal(p.spice, FACTIONS.find((f) => f.id === p.faction)!.spice);
|
|
rejectUnchanged(g, 'atreides', {
|
|
type: 'traitor',
|
|
leader: g.players[0].traitors[0],
|
|
});
|
|
if (advanced)
|
|
rejectUnchanged(g, 'beneGesserit', {
|
|
type: 'advisorSetup',
|
|
territory: 'carthag',
|
|
sector: 11,
|
|
});
|
|
g = applyAction(g, 'fremen', fremenPlacement(advanced ? 3 : 0));
|
|
if (advanced) {
|
|
assert.equal(g.setupStage, 'forces');
|
|
assert.deepEqual(viewGame(g, 'beneGesserit').setupPending, [
|
|
'beneGesserit',
|
|
]);
|
|
assert.ok(g.players.every((p) => p.hand.length === 0));
|
|
g = applyAction(g, 'beneGesserit', {
|
|
type: 'advisorSetup',
|
|
territory: 'carthag',
|
|
sector: 11,
|
|
});
|
|
assert.equal(
|
|
isAdvisor(
|
|
g.players.find((p) => p.faction === 'beneGesserit')!,
|
|
'carthag',
|
|
),
|
|
true,
|
|
);
|
|
}
|
|
assertComplete(g);
|
|
rejectUnchanged(g, 'beneGesserit', {
|
|
type: 'predict',
|
|
faction: 'guild',
|
|
turn: 7,
|
|
});
|
|
rejectUnchanged(g, 'fremen', fremenPlacement());
|
|
}
|
|
});
|
|
|
|
void test('Fremen initial elite distributions zero through three conserve typed forces; invalid allocations leave the force stage and undealt hands intact', () => {
|
|
for (const elite of [0, 1, 2, 3]) {
|
|
let g = selectTraitors(
|
|
initializeBaseGameForAudit(lobby(['fremen', 'emperor'])),
|
|
);
|
|
const f = g.players[0];
|
|
assert.equal(f.elites!.reserves, 3);
|
|
assert.equal(g.players[1].elites!.reserves, 5);
|
|
const tabr = location('sietch_tabr', territory('sietch_tabr').sectors[0]);
|
|
const south = location(
|
|
'false_wall_south',
|
|
territory('false_wall_south').sectors[0],
|
|
);
|
|
rejectUnchanged(g, f.id, {
|
|
type: 'fremenSetup',
|
|
placements: { [tabr]: 10 },
|
|
elitePlacements: { [tabr]: 4 },
|
|
});
|
|
rejectUnchanged(g, f.id, {
|
|
type: 'fremenSetup',
|
|
placements: { [tabr]: 5, [south]: 5 },
|
|
elitePlacements: { [tabr]: 2, [south]: 2 },
|
|
});
|
|
g = applyAction(g, f.id, {
|
|
type: 'fremenSetup',
|
|
placements: { [tabr]: 5, [south]: 5 },
|
|
elitePlacements: {
|
|
[tabr]: Math.min(elite, 2),
|
|
[south]: Math.max(0, elite - 2),
|
|
},
|
|
});
|
|
assertComplete(g);
|
|
assert.equal(g.players[0].elites!.reserves, 3 - elite);
|
|
}
|
|
});
|
|
|
|
void test('public Advanced start remains gated with forged bypass fields, while the audit seam shares corrected Basic initialization and leaves inputs unchanged', () => {
|
|
const advanced = lobby();
|
|
rejectUnchanged(advanced, advanced.host, { type: 'start' });
|
|
rejectUnchanged(advanced, advanced.host, {
|
|
type: 'start',
|
|
audit: true,
|
|
allowUnsupported: true,
|
|
advanced: false,
|
|
});
|
|
rejectUnchanged(advanced, 'fremen', { type: 'start' });
|
|
const basic = lobby(BASE, false);
|
|
const before = structuredClone(basic);
|
|
const normal = seeded(() =>
|
|
applyAction(basic, basic.host, { type: 'start' }),
|
|
);
|
|
const audit = seeded(() => initializeBaseGameForAudit(basic));
|
|
assert.deepEqual(normal, audit);
|
|
assert.deepEqual(basic, before);
|
|
assert.equal(audit.setupStage, 'prediction');
|
|
for (const mutate of [
|
|
(g: Game) => {
|
|
g.players[0].ready = false;
|
|
},
|
|
(g: Game) => {
|
|
g.players = g.players.slice(0, 1);
|
|
},
|
|
(g: Game) => {
|
|
g.players.push(newPlayer('seventh', 'seventh', 'atreides'));
|
|
},
|
|
(g: Game) => {
|
|
g.players[1].id = g.players[0].id;
|
|
},
|
|
(g: Game) => {
|
|
g.players[1].faction = g.players[0].faction;
|
|
},
|
|
(g: Game) => {
|
|
g.host = 'absent';
|
|
},
|
|
(g: Game) => {
|
|
g.playerPositions![g.players[1].id] = g.playerPositions![g.players[0].id];
|
|
},
|
|
(g: Game) => {
|
|
g.expansions = ['ix'];
|
|
},
|
|
(g: Game) => {
|
|
g.players[1].faction = 'ixians';
|
|
},
|
|
(g: Game) => {
|
|
g.techTokens = createTechTokens();
|
|
},
|
|
(g: Game) => {
|
|
g.players[0].hand = [baseDeck()[0]];
|
|
},
|
|
(g: Game) => {
|
|
g.players[0].spice = 1;
|
|
},
|
|
(g: Game) => {
|
|
g.players[0].forces = { 'arrakeen:10': 1 };
|
|
g.players[0].reserves = 19;
|
|
},
|
|
]) {
|
|
const malformed = structuredClone(advanced);
|
|
mutate(malformed);
|
|
const original = structuredClone(malformed);
|
|
assert.throws(() => initializeBaseGameForAudit(malformed));
|
|
assert.deepEqual(malformed, original);
|
|
}
|
|
const started = initializeBaseGameForAudit(advanced);
|
|
const snapshot = structuredClone(started);
|
|
assert.throws(() => initializeBaseGameForAudit(started));
|
|
assert.deepEqual(started, snapshot);
|
|
});
|
|
|
|
void test('legacy no-stage setup can finish its already-dealt prediction without dealing another hand', () => {
|
|
let g = completeSetup(
|
|
initializeBaseGameForAudit(lobby(BASE, false)),
|
|
'Medium',
|
|
).game;
|
|
const hands = g.players.map((p) => structuredClone(p.hand));
|
|
const deck = structuredClone(g.deck);
|
|
g.status = 'setup';
|
|
delete g.setupStage;
|
|
delete g.players.find((p) => p.faction === 'beneGesserit')!.prediction;
|
|
g = predict(g);
|
|
assert.equal(g.status, 'playing');
|
|
assert.deepEqual(
|
|
g.players.map((p) => p.hand),
|
|
hands,
|
|
);
|
|
assert.deepEqual(g.deck, deck);
|
|
});
|
|
|
|
void test(
|
|
'all57base rosters complete shared Advanced setup across four profiles and both physical/actor orders with every proposed action legal',
|
|
{ timeout: 180000 },
|
|
() => {
|
|
let count = 0;
|
|
for (let mask = 0; mask < 64; mask++) {
|
|
const roster = BASE.filter((_, i) => mask & (1 << i));
|
|
if (roster.length < 2) continue;
|
|
for (const difficulty of DIFFICULTIES)
|
|
for (const reverse of [false, true]) {
|
|
const before = lobby(roster, true, reverse, difficulty);
|
|
const original = structuredClone(before);
|
|
const initialized = initializeBaseGameForAudit(before);
|
|
assert.deepEqual(before, original);
|
|
assert.equal(initialized.advanced, true);
|
|
assert.equal(
|
|
initialized.setupStage,
|
|
roster.includes('beneGesserit') ? 'prediction' : 'traitors',
|
|
);
|
|
const completed = completeSetup(initialized, difficulty, reverse);
|
|
assertComplete(completed.game);
|
|
if (roster.includes('beneGesserit'))
|
|
assert.equal(completed.history[0], 'prediction:predict');
|
|
assert.ok(
|
|
completed.history.every(
|
|
(step, i) =>
|
|
!step.startsWith('traitors:') ||
|
|
!completed.history
|
|
.slice(0, i)
|
|
.some((previous) => previous.startsWith('forces:')),
|
|
),
|
|
);
|
|
count++;
|
|
}
|
|
}
|
|
assert.equal(count, 456);
|
|
},
|
|
);
|