591 lines
18 KiB
TypeScript
591 lines
18 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
initializeFactionExpansionsGameForAudit,
|
|
initializeHomeworldGameForAudit,
|
|
joinGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
|
import { FACTIONS, faction, type FactionId } from '../game/catalog';
|
|
import { CHOAM_AUDITOR_ID, spiceDeck, treacheryDeck } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { traitorDeck } from '../game/traitors';
|
|
import { MOBILE_LOCATION } from '../game/board';
|
|
import {
|
|
ECAZ_START_FORCES,
|
|
ECAZ_START_LOCATIONS,
|
|
quoteEcazStartingForces,
|
|
} from '../game/ecaz-setup';
|
|
|
|
const combinations = [
|
|
['ix'],
|
|
['choam'],
|
|
['ecaz'],
|
|
['ix', 'choam'],
|
|
['ix', 'ecaz'],
|
|
['choam', 'ecaz'],
|
|
['ix', 'choam', 'ecaz'],
|
|
];
|
|
const sum = (forces: Record<string, number>) =>
|
|
Object.values(forces).reduce((a, b) => a + b, 0);
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
function rosterFor(expansions: string[]): FactionId[] {
|
|
const selected = FACTIONS.filter((f) => expansions.includes(f.expansion)).map(
|
|
(f) => f.id,
|
|
);
|
|
return [
|
|
...selected,
|
|
...(
|
|
['fremen', 'beneGesserit', 'atreides', 'harkonnen'] as FactionId[]
|
|
).slice(0, 6 - selected.length),
|
|
];
|
|
}
|
|
function lobby(
|
|
expansions = ['choam'],
|
|
advanced = false,
|
|
roster = rosterFor(expansions),
|
|
): Game {
|
|
let g = createGame(
|
|
'FACTIONS',
|
|
newPlayer(roster[0], roster[0], roster[0]),
|
|
advanced,
|
|
expansions,
|
|
);
|
|
for (const id of roster.slice(1)) joinGame(g, newPlayer(id, id, id));
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
return g;
|
|
}
|
|
function privateViews(g: Game) {
|
|
const saved = reload(g);
|
|
for (const p of g.players) {
|
|
const view = viewGame(g, p.id);
|
|
assert.deepEqual(viewGame(saved, p.id), view);
|
|
assert.equal('deck' in view, false);
|
|
assert.equal('traitorReserve' in view, false);
|
|
for (const other of view.players.filter((other) => other.id !== p.id)) {
|
|
assert.equal(other.hand, undefined);
|
|
assert.equal(other.traitors, undefined);
|
|
assert.equal(other.traitorChoices, undefined);
|
|
assert.equal(other.faceDancers, undefined);
|
|
assert.equal(other.spice, undefined);
|
|
}
|
|
if (g.ixSetupCards && p.faction !== 'ixians')
|
|
assert.equal(view.ixTechnology, null);
|
|
if (g.players.some((other) => other.noField)) {
|
|
assert.equal(!!view.richeseNoField?.private, p.faction === 'richese');
|
|
if (p.faction !== 'richese')
|
|
for (const token of g.players.find(
|
|
(other) => other.faction === 'richese',
|
|
)!.noField!.tokens)
|
|
assert.equal(JSON.stringify(view).includes(token.id), false);
|
|
}
|
|
if (g.moritaniTerror) {
|
|
assert.equal(
|
|
view.moritaniTerror!.tokens.length,
|
|
p.faction === 'moritani' ? 6 : 0,
|
|
);
|
|
if (p.faction === 'moritani')
|
|
assert.ok(
|
|
view.moritaniTerror!.tokens.every((token) => 'kind' in token),
|
|
);
|
|
}
|
|
}
|
|
return saved;
|
|
}
|
|
function inventory(g: Game) {
|
|
const expected = [
|
|
...treacheryDeck(g.expansions),
|
|
...(g.players.some((p) => p.faction === 'richese') ? richeseCards() : []),
|
|
];
|
|
const cards = [
|
|
...g.deck,
|
|
...g.discard,
|
|
...g.players.flatMap((p) => p.hand),
|
|
...(g.ixSetupCards ?? []),
|
|
...(g.richeseCache ?? []),
|
|
];
|
|
assert.deepEqual(
|
|
cards.map((c) => c.id).sort(),
|
|
expected.map((c) => c.id).sort(),
|
|
);
|
|
assert.equal(new Set(cards.map((c) => c.id)).size, cards.length);
|
|
assert.ok(cards.every((c) => !c.id.startsWith('ecaz-')));
|
|
assert.equal(
|
|
g.spiceDeck.length + g.spiceDiscard.flat().length,
|
|
spiceDeck(g.expansions.includes('ix')).length,
|
|
);
|
|
for (const p of g.players)
|
|
assert.equal(p.reserves + p.tanks + sum(p.forces), 20, p.id);
|
|
if (g.setupStage !== 'prediction') {
|
|
const traitors = [
|
|
...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : []),
|
|
...(g.traitorReserve ?? []),
|
|
...g.players.flatMap((p) => [
|
|
...p.traitors,
|
|
...p.traitorChoices,
|
|
...(p.faceDancers ?? []).map((c) => c.leader),
|
|
]),
|
|
];
|
|
assert.deepEqual(
|
|
traitors.slice().sort(),
|
|
traitorDeck(g.players, g.expansions.includes('ix')).sort(),
|
|
);
|
|
assert.equal(new Set(traitors).size, traitors.length);
|
|
}
|
|
}
|
|
function setup(state: Game, difficulty: Difficulty) {
|
|
let g = state;
|
|
const history: string[] = [];
|
|
for (let step = 0; g.status === 'setup' && step < 40; step++) {
|
|
inventory(g);
|
|
g = privateViews(g);
|
|
let next: Game | undefined;
|
|
for (const p of g.players) {
|
|
const view = viewGame(g, p.id);
|
|
view.players.find((player) => player.id === p.id)!.bot = difficulty;
|
|
const before = structuredClone(view);
|
|
const actions = botActions(view);
|
|
assert.deepEqual(view, before);
|
|
for (const action of actions) {
|
|
const candidate = applyAction(g, p.id, action);
|
|
next ??= candidate;
|
|
}
|
|
if (next) {
|
|
const action = actions[0];
|
|
history.push(
|
|
action.type === 'decision' ? g.decision!.kind : action.type,
|
|
);
|
|
if (g.decision?.kind === 'moritaniSetup') {
|
|
assert.equal(g.setupStage, undefined);
|
|
assert.ok(
|
|
g.players.every(
|
|
(player) =>
|
|
player.hand.length === (player.faction === 'harkonnen' ? 2 : 1),
|
|
),
|
|
);
|
|
assert.ok(
|
|
g.players.every(
|
|
(player) => player.faction !== 'ecaz' || player.reserves === 14,
|
|
),
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
assert.ok(
|
|
next,
|
|
`${g.expansions.join(',')}/${difficulty}: stalled ${g.setupStage}/${g.decision?.kind}/${viewGame(g, g.host).setupPending.join(',')}`,
|
|
);
|
|
g = next;
|
|
}
|
|
assert.equal(g.status, 'playing');
|
|
assert.equal(g.turn, 1);
|
|
assert.equal(g.phase, 0);
|
|
inventory(g);
|
|
privateViews(g);
|
|
return { game: g, history };
|
|
}
|
|
function reject(g: Game, operation = initializeFactionExpansionsGameForAudit) {
|
|
const before = structuredClone(g);
|
|
assert.throws(() => operation(g));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
function rejectAction(g: Game, player: string, action: Action) {
|
|
const before = structuredClone(g);
|
|
assert.throws(() => applyAction(g, player, action));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
|
|
for (const expansions of combinations)
|
|
void test(`genuine ${expansions.join('+')} faction setup supports Basic and Advanced with every AI profile and private JSON continuation`, () => {
|
|
for (const advanced of [false, true])
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const initial = lobby(expansions, advanced),
|
|
before = structuredClone(initial);
|
|
const started = initializeFactionExpansionsGameForAudit(initial);
|
|
assert.deepEqual(initial, before);
|
|
assert.equal(started.leaderSkills, undefined);
|
|
assert.equal(
|
|
started.deck.length,
|
|
expansions.includes('ix')
|
|
? 47
|
|
: expansions.includes('choam')
|
|
? 35
|
|
: 33,
|
|
);
|
|
const { game: g, history } = setup(started, difficulty);
|
|
assert.deepEqual(g.expansions, expansions);
|
|
for (const p of g.players) {
|
|
assert.equal(p.spice, faction(p.faction).spice);
|
|
assert.equal(p.hand.length, p.faction === 'harkonnen' ? 2 : 1);
|
|
}
|
|
const ecaz = g.players.find((p) => p.faction === 'ecaz');
|
|
if (ecaz) {
|
|
assert.equal(ecaz.reserves, 14);
|
|
assert.equal(sum(ecaz.forces), 6);
|
|
assert.ok(
|
|
Object.keys(ecaz.forces).every((key) =>
|
|
ECAZ_START_LOCATIONS.includes(key),
|
|
),
|
|
);
|
|
assert.equal(g.ecazAmbassadors!.tokens.length, 11);
|
|
assert.equal(
|
|
g.ecazAmbassadors!.tokens.filter((t) => t.zone === 'supply').length,
|
|
6,
|
|
);
|
|
assert.equal(g.ecazAmbassadors!.cohort.length, 5);
|
|
if (g.players.some((p) => p.faction === 'fremen'))
|
|
assert.ok(
|
|
history.indexOf('fremenSetup') < history.indexOf('ecazSetup'),
|
|
);
|
|
if (advanced && g.players.some((p) => p.faction === 'beneGesserit'))
|
|
assert.ok(
|
|
history.indexOf('ecazSetup') < history.indexOf('advisorSetup'),
|
|
);
|
|
}
|
|
const moritani = g.players.find((p) => p.faction === 'moritani');
|
|
if (moritani) {
|
|
assert.equal(moritani.reserves, 14);
|
|
assert.equal(sum(moritani.forces), 6);
|
|
assert.equal(g.moritaniTerror!.tokens.length, 6);
|
|
assert.equal(history.at(-1), 'moritaniSetup');
|
|
assert.ok(g.dukeVidal);
|
|
assert.ok(
|
|
g.players.every(
|
|
(p) => !p.leaders.some((l) => l.id === g.dukeVidal!.leader.id),
|
|
),
|
|
);
|
|
}
|
|
const richese = g.players.find((p) => p.faction === 'richese');
|
|
if (richese) {
|
|
assert.equal(g.richeseCache!.length, 10);
|
|
assert.deepEqual(
|
|
richese.noField!.tokens.map((t) => t.value).sort((a, b) => a - b),
|
|
[0, 3, 5],
|
|
);
|
|
assert.equal(richese.reserves, 20);
|
|
}
|
|
const choam = g.players.find((p) => p.faction === 'choam');
|
|
if (choam)
|
|
assert.equal(
|
|
choam.leaders.some((l) => l.id === CHOAM_AUDITOR_ID),
|
|
advanced,
|
|
);
|
|
const ix = g.players.find((p) => p.faction === 'ixians');
|
|
if (ix) {
|
|
assert.deepEqual(ix.forces, { [MOBILE_LOCATION]: 6 });
|
|
assert.deepEqual(ix.elites!.forces, { [MOBILE_LOCATION]: 3 });
|
|
assert.equal(ix.elites!.reserves, 4);
|
|
assert.equal(g.mobileStronghold!.location, null);
|
|
}
|
|
const tleilaxu = g.players.find((p) => p.faction === 'tleilaxu');
|
|
if (tleilaxu) assert.equal(tleilaxu.faceDancers!.length, 3);
|
|
reject(g);
|
|
}
|
|
});
|
|
|
|
void test('faction prototype handles two through six seats and preserves the public start gates', () => {
|
|
const roster: FactionId[] = [
|
|
'choam',
|
|
'richese',
|
|
'atreides',
|
|
'harkonnen',
|
|
'guild',
|
|
'emperor',
|
|
];
|
|
for (const count of [2, 3, 4, 5, 6])
|
|
for (const advanced of [false, true]) {
|
|
const g = lobby(['choam'], advanced, roster.slice(0, count));
|
|
assert.equal(
|
|
setup(initializeFactionExpansionsGameForAudit(g), 'Medium').game.players
|
|
.length,
|
|
count,
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('Ecaz starting-force quote accepts exact six-force Basin allocations and rejects invalid or ambiguous input purely', () => {
|
|
assert.equal(ECAZ_START_FORCES, 6);
|
|
assert.deepEqual(ECAZ_START_LOCATIONS, [
|
|
'imperial_basin:9',
|
|
'imperial_basin:10',
|
|
'imperial_basin:11',
|
|
]);
|
|
const input = {
|
|
'imperial_basin:9': 2,
|
|
'imperial_basin:10': 4,
|
|
'imperial_basin:11': 0,
|
|
};
|
|
assert.deepEqual(quoteEcazStartingForces(input), {
|
|
'imperial_basin:9': 2,
|
|
'imperial_basin:10': 4,
|
|
});
|
|
for (const bad of [
|
|
null,
|
|
[],
|
|
{},
|
|
{ 'imperial_basin:9': 5 },
|
|
{ 'imperial_basin:9': 7 },
|
|
{ 'imperial_basin:9': 5.5, 'imperial_basin:10': 0.5 },
|
|
{ 'imperial_basin:9': -1, 'imperial_basin:10': 7 },
|
|
{ 'imperial_basin:09': 6 },
|
|
{ imperial_basin: 6 },
|
|
{ 'arrakeen:10': 6 },
|
|
{ 'imperial_basin:9': '6' },
|
|
]) {
|
|
const before = structuredClone(bad);
|
|
assert.throws(() => quoteEcazStartingForces(bad));
|
|
assert.deepEqual(bad, before);
|
|
}
|
|
});
|
|
|
|
void test('Ecaz splits six forces once between Fremen and Advanced BG, then excludes Imperial Basin from Moritani final placement', () => {
|
|
let g = initializeFactionExpansionsGameForAudit(
|
|
lobby(['ecaz'], true, ['ecaz', 'moritani', 'fremen', 'beneGesserit']),
|
|
);
|
|
const actionsFor = (id: string) => {
|
|
const view = viewGame(g, id);
|
|
view.players.find((p) => p.id === id)!.bot = 'Medium';
|
|
return botActions(view);
|
|
};
|
|
for (
|
|
let step = 0;
|
|
!(
|
|
g.setupStage === 'forces' &&
|
|
viewGame(g, g.host).setupPending[0] === 'ecaz'
|
|
) && step < 20;
|
|
step++
|
|
) {
|
|
const actor = g.players.find((p) => actionsFor(p.id).length > 0)!;
|
|
assert.ok(actor);
|
|
g = applyAction(reload(g), actor.id, actionsFor(actor.id)[0]);
|
|
}
|
|
assert.deepEqual(viewGame(g, g.host).setupPending, ['ecaz']);
|
|
assert.equal(g.players.find((p) => p.faction === 'fremen')!.reserves, 10);
|
|
assert.ok(g.players.every((p) => p.hand.length === 0));
|
|
const split = {
|
|
type: 'ecazSetup',
|
|
placements: {
|
|
'imperial_basin:9': 2,
|
|
'imperial_basin:10': 2,
|
|
'imperial_basin:11': 2,
|
|
},
|
|
};
|
|
rejectAction(g, 'beneGesserit', {
|
|
type: 'advisorSetup',
|
|
territory: 'polar_sink',
|
|
sector: 0,
|
|
});
|
|
rejectAction(g, 'fremen', split);
|
|
rejectAction(g, 'ecaz', {
|
|
type: 'ecazSetup',
|
|
placements: { 'imperial_basin:9': 5 },
|
|
});
|
|
rejectAction(g, 'ecaz', { ...split, elitePlacements: {} });
|
|
g = applyAction(privateViews(g), 'ecaz', split);
|
|
assert.deepEqual(g.players[0].forces, split.placements);
|
|
assert.equal(g.players[0].reserves, 14);
|
|
assert.deepEqual(viewGame(g, g.host).setupPending, ['beneGesserit']);
|
|
rejectAction(g, 'ecaz', split);
|
|
g = applyAction(privateViews(g), 'beneGesserit', {
|
|
type: 'advisorSetup',
|
|
territory: 'polar_sink',
|
|
sector: 0,
|
|
});
|
|
assert.deepEqual(g.decision, { kind: 'moritaniSetup', player: 'moritani' });
|
|
assert.ok(g.players.every((p) => p.hand.length === 1));
|
|
rejectAction(g, 'moritani', {
|
|
type: 'decision',
|
|
territory: 'imperial_basin',
|
|
sector: 9,
|
|
});
|
|
rejectAction(g, 'moritani', {
|
|
type: 'decision',
|
|
territory: 'polar_sink',
|
|
sector: 0,
|
|
});
|
|
for (const [reserves, forces] of [
|
|
[20, {}],
|
|
[14, { 'arrakeen:10': 6 }],
|
|
] as const) {
|
|
const incomplete = reload(g);
|
|
incomplete.players[0].reserves = reserves;
|
|
incomplete.players[0].forces = forces;
|
|
rejectAction(incomplete, 'moritani', {
|
|
type: 'decision',
|
|
territory: 'carthag',
|
|
sector: 11,
|
|
});
|
|
incomplete.decision = null;
|
|
const before = structuredClone(incomplete),
|
|
normalized = normalizeAutomaticGame(incomplete);
|
|
assert.deepEqual(incomplete, before);
|
|
assert.equal(normalized.status, 'setup');
|
|
assert.equal(normalized.decision, null);
|
|
}
|
|
g = applyAction(privateViews(g), 'moritani', {
|
|
type: 'decision',
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
});
|
|
assert.equal(g.status, 'playing');
|
|
assert.equal(g.players[1].reserves, 14);
|
|
assert.deepEqual(g.players[1].forces, { 'arrakeen:10': 6 });
|
|
rejectAction(g, 'ecaz', split);
|
|
inventory(g);
|
|
});
|
|
|
|
void test('a stale or partially played lobby cannot redeal faction inventories, leaders, or optional modules', () => {
|
|
const mutations: Array<(g: Game) => void> = [
|
|
(g) => {
|
|
g.expansions = [];
|
|
},
|
|
(g) => {
|
|
g.expansions = ['ecaz-treachery'];
|
|
},
|
|
(g) => {
|
|
g.expansions.push('choam');
|
|
},
|
|
(g) => {
|
|
g.players[0].ready = false;
|
|
},
|
|
(g) => {
|
|
g.host = 'missing';
|
|
},
|
|
(g) => {
|
|
g.turn = 2;
|
|
},
|
|
(g) => {
|
|
g.advanced = 'advanced' as unknown as boolean;
|
|
},
|
|
(g) => {
|
|
g.status = 'setup';
|
|
},
|
|
(g) => {
|
|
g.players[0].spice = 1;
|
|
},
|
|
(g) => {
|
|
g.players[0].reserves = 19;
|
|
},
|
|
(g) => {
|
|
g.richeseCache = [];
|
|
},
|
|
(g) => {
|
|
g.richeseRemoved = [];
|
|
},
|
|
(g) => {
|
|
g.ecazAmbassadors = { tokens: [], cohort: [], placement: null };
|
|
},
|
|
(g) => {
|
|
g.moritaniTerror = { tokens: [] };
|
|
},
|
|
(g) => {
|
|
g.dukeVidal = {} as NonNullable<Game['dukeVidal']>;
|
|
},
|
|
(g) => {
|
|
g.mobileStronghold = { location: null };
|
|
},
|
|
(g) => {
|
|
g.ixSetupCards = [];
|
|
},
|
|
(g) => {
|
|
g.players[1].noField = {} as NonNullable<
|
|
Game['players'][number]['noField']
|
|
>;
|
|
},
|
|
(g) => {
|
|
g.players[1].noFieldEvent = 'old';
|
|
},
|
|
(g) => {
|
|
g.players[0].faceDancers = [];
|
|
},
|
|
(g) => {
|
|
g.players[0].leaders[0].capturedBy = g.players[1].id;
|
|
},
|
|
(g) => {
|
|
g.players[0].leaders[0].gholaBy = g.players[1].id;
|
|
},
|
|
(g) => {
|
|
g.players[0].leaders[0].dead = true;
|
|
},
|
|
(g) => {
|
|
g.players[0].leaders[0].deaths = 1;
|
|
},
|
|
(g) => {
|
|
g.players[0].leaders[0].usedAt = 'arrakeen';
|
|
},
|
|
(g) => {
|
|
g.players[0].leaders[0].strength++;
|
|
},
|
|
(g) => {
|
|
g.players[0].revived = 1;
|
|
},
|
|
(g) => {
|
|
g.players[0].freeForcesRevived = 1;
|
|
},
|
|
(g) => {
|
|
g.players[0].leaderRevived = true;
|
|
},
|
|
(g) => {
|
|
g.players[0].revivalCycle = 1;
|
|
},
|
|
(g) => {
|
|
g.homeworlds = { custody: null };
|
|
},
|
|
(g) => {
|
|
g.nexusCards = { cards: null, phase: null };
|
|
},
|
|
(g) => {
|
|
g.leaderSkills = {} as NonNullable<Game['leaderSkills']>;
|
|
},
|
|
(g) => {
|
|
g.discoveryEnabled = true;
|
|
},
|
|
(g) => {
|
|
g.discoveries = {} as NonNullable<Game['discoveries']>;
|
|
},
|
|
(g) => {
|
|
g.techTokens = {} as NonNullable<Game['techTokens']>;
|
|
},
|
|
(g) => {
|
|
g.strongholdCards = {} as NonNullable<Game['strongholdCards']>;
|
|
},
|
|
];
|
|
for (const mutate of mutations) {
|
|
const g = lobby();
|
|
mutate(g);
|
|
reject(g);
|
|
}
|
|
const absentFaction = lobby(['choam']);
|
|
absentFaction.players[0] = {
|
|
...newPlayer('ecaz', 'Ecaz', 'ecaz'),
|
|
ready: true,
|
|
};
|
|
absentFaction.host = 'ecaz';
|
|
reject(absentFaction);
|
|
const tooMany = lobby(['ix']);
|
|
tooMany.players.push({
|
|
...newPlayer('guild', 'Guild', 'guild'),
|
|
ready: true,
|
|
});
|
|
reject(tooMany);
|
|
const alone = lobby(['choam'], false, ['choam']);
|
|
reject(alone);
|
|
});
|
|
|
|
void test('accepting the Ecaz deck selector does not open the existing Homeworld expansion gate', () => {
|
|
for (const expansions of [['choam'], ['ecaz'], ['ix', 'ecaz']]) {
|
|
const home = lobby(expansions, true);
|
|
home.homeworlds = { custody: null };
|
|
reject(home, initializeHomeworldGameForAudit);
|
|
}
|
|
});
|