Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,538 @@
|
||||
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);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user