Files
Dune/tests/discovery-runtime.test.ts

237 lines
8.2 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
initializeDiscoveryGameForAudit,
normalizeAutomaticGame,
viewGame,
type Game,
} from '../game/engine';
import { baseDeck, spiceDeck } from '../game/cards';
import { DIFFICULTIES } from '../game/bot-profiles';
import { botActions } from '../game/bots';
import {
discoveryFixture,
discoveryLobby,
enterDiscoveryCollection,
} from './fixture-discovery';
import {
DISCOVERY_SPICE_CARDS,
validateDiscoveryState,
} from '../game/discoveries';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function custody(g: Game) {
assert.deepEqual(
[...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
.map((c) => c.id)
.sort(),
baseDeck()
.map((c) => c.id)
.sort(),
);
for (const p of g.players)
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
validateDiscoveryState(g.discoveries!);
}
void test('Discovery real setup deals seven spice cards and eight opaque tokens', () => {
for (const advanced of [false, true]) {
const lobby = discoveryLobby(advanced);
const g = initializeDiscoveryGameForAudit(lobby);
assert.equal(g.spiceDeck.length, spiceDeck().length + 7);
assert.equal(
g.spiceDeck.filter((card) => 'worm' in card && card.greatMaker).length,
1,
);
assert.deepEqual(
g.spiceDeck
.flatMap((card) =>
'territory' in card && card.discovery ? [card.discovery] : [],
)
.sort(),
DISCOVERY_SPICE_CARDS.map((c) => c.discovery).sort(),
);
assert.equal(g.discoveries!.tokens.length, 8);
assert.deepEqual(viewGame(g, lobby.host).discoveries!.tokens, []);
}
});
void test('Collection look remains private, deferred reveal survives reload, and a Spice Stash pays exactly once', () => {
let g = discoveryFixture();
const token = enterDiscoveryCollection(g, 'spice-stash');
assert.equal(viewGame(g, 'a').discoveries!.tokens[0].face, null);
assert.equal(viewGame(g, 'g').discoveries!.tokens[0].face, 'spice-stash');
assert.equal(viewGame(g, 'f').discoveries!.tokens[0].face, null);
const before = structuredClone(g);
assert.throws(() =>
applyAction(g, 'a', { type: 'discovery', token: token.id, reveal: true }),
);
assert.throws(() =>
applyAction(g, 'g', { type: 'discovery', token: token.id, reveal: true }),
);
assert.deepEqual(g, before);
g = applyAction(g, 'a', {
type: 'discovery',
token: token.id,
reveal: false,
});
const v = viewGame(g, 'a');
assert.equal(v.discoveries!.tokens[0].face, 'spice-stash');
v.discoveries!.tokens[0].owner = 'forged';
assert.equal(
g.discoveries!.tokens.find((t) => t.id === token.id)!.owner,
null,
);
assert.equal(viewGame(g, 'f').discoveries!.tokens[0].face, null);
const spice = g.players[0].spice;
g = applyAction(reload(g), 'a', {
type: 'discovery',
token: token.id,
reveal: true,
});
assert.equal(g.players[0].spice, spice + 7);
assert.equal(
g.discoveries!.tokens.find((t) => t.id === token.id)!.status,
'removed',
);
const done = structuredClone(g);
assert.throws(() =>
applyAction(g, 'a', { type: 'discovery', token: token.id, reveal: true }),
);
assert.deepEqual(g, done);
custody(g);
});
void test('Treachery Card Stash draws past a full hand, keeps the draw private and accepts every held discard through recovery', () => {
let g = discoveryFixture();
const token = enterDiscoveryCollection(g, 'treachery-card-stash');
// Exercise the previously flaky ID prefix collision with two distinct cards.
const take = (id: string) => {
const pile = [g.deck, g.discard, ...g.players.map((p) => p.hand)]
.find((cards) => cards.some((card) => card.id === id))!;
return pile.splice(pile.findIndex((card) => card.id === id), 1)[0];
};
const privateCard = take('treachery-3');
const visibleSibling = take('treachery-32');
g.players.find((p) => p.id === 'f')!.hand.push(visibleSibling);
while (g.players[0].hand.length < 4) g.players[0].hand.push(g.deck.shift()!);
g.deck.unshift(privateCard);
const original = g.players[0].hand.map((c) => c.id),
drawn = g.deck[0];
g = applyAction(g, 'a', {
type: 'discovery',
token: token.id,
reveal: false,
});
g = applyAction(g, 'a', { type: 'discovery', token: token.id, reveal: true });
assert.equal(g.decision?.kind, 'discoveryDiscard');
assert.equal(g.players[0].hand.length, 5);
const otherView = JSON.stringify(viewGame(g, 'f'));
assert.equal(otherView.includes(JSON.stringify(visibleSibling.id)), true);
assert.equal(otherView.includes(JSON.stringify(drawn.id)), false);
assert.deepEqual(
g.players[0].hand.map((c) => c.id),
[...original, drawn.id],
);
for (const card of g.players[0].hand) {
const done = applyAction(reload(g), 'a', {
type: 'decision',
event: g.discoveryStash!.event,
card: card.id,
});
assert.equal(done.players[0].hand.length, 4);
assert.equal(done.discoveryStash!.stage, 'complete');
assert.ok(done.discard.some((c) => c.id === card.id));
custody(done);
assert.deepEqual(normalizeAutomaticGame(done), done);
}
for (const difficulty of DIFFICULTIES) {
const v = viewGame(g, 'a');
v.players[0].bot = difficulty;
const actions = botActions(v);
assert.ok(actions.length);
for (const action of actions)
assert.doesNotThrow(() => applyAction(g, 'a', action));
}
});
void test('a revealed location is initially empty and becomes an ordinary stronghold-price destination', () => {
let g = discoveryFixture();
const token = enterDiscoveryCollection(g, 'cistern');
g = applyAction(g, 'a', {
type: 'discovery',
token: token.id,
reveal: false,
});
g = applyAction(g, 'a', { type: 'discovery', token: token.id, reveal: true });
assert.equal(
g.discoveries!.tokens.find((t) => t.id === token.id)!.revealedTurn,
g.turn,
);
assert.ok(g.players.every((p) => !p.forces['cistern:0']));
assert.equal(
viewGame(g, 'f').discoveries!.tokens.find((t) => t.id === token.id)!.face,
'cistern',
);
custody(g);
});
void test('each physical Discovery blow destroys the whole parent territory including Fremen before placing six new spice and a private token', () => {
for (const printed of DISCOVERY_SPICE_CARDS) {
let g = discoveryFixture();
const index = g.spiceDeck.findIndex(
(card) => 'territory' in card && card.discovery === printed.discovery,
);
assert.ok(index >= 0);
const card = g.spiceDeck.splice(index, 1)[0];
g.spiceDeck.unshift(card);
Object.assign(g, { phase: 1, ready: [], storm: 18, stormPending: null });
const key = `${printed.territory}:${printed.sector}`;
for (const p of [g.players[0], g.players[2]]) {
p.reserves -= 2;
p.forces[key] = 2;
}
g.spice[key] = 4;
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.spiceWindow?.territory, printed.territory);
assert.equal(g.spice[key], 6);
for (const p of [g.players[0], g.players[2]]) {
assert.equal(p.forces[key], undefined);
assert.equal(p.tanks, 2);
}
assert.equal(
g.discoveries!.tokens.filter((token) => token.status === 'placed').length,
1,
);
assert.equal(viewGame(g, 'a').discoveries!.tokens[0].face, null);
custody(g);
}
});
void test('Discovery spice still destroys old forces under the storm and does not discard the separately placed token', () => {
let g = discoveryFixture();
const printed = DISCOVERY_SPICE_CARDS[0];
const index = g.spiceDeck.findIndex(
(card) => 'territory' in card && card.discovery === printed.discovery,
);
g.spiceDeck.unshift(g.spiceDeck.splice(index, 1)[0]);
Object.assign(g, {
phase: 1,
ready: [],
storm: printed.sector,
stormPending: null,
});
const key = `${printed.territory}:${printed.sector}`;
g.players[0].reserves--;
g.players[0].forces[key] = 1;
g.spice[key] = 3;
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.spice[key], undefined);
assert.equal(g.players[0].tanks, 1);
assert.equal(
g.discoveries!.tokens.filter((token) => token.status === 'placed').length,
1,
);
custody(g);
});