Files
Dune/tests/discovery-collection.test.ts
T

536 lines
17 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { baseDeck } from '../game/cards';
import type { FactionId } from '../game/catalog';
import {
createDiscoveryState,
validateDiscoveryState,
type DiscoveryLocationId,
} from '../game/discoveries';
import {
CISTERN,
ORGIZ_PROCESSING_STATION,
quoteDiscoveryCollection,
} from '../game/discovery-collection';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
type Game,
} from '../game/engine';
type Seat = [id: string, faction: FactionId];
function fixture(
advanced = false,
seats: Seat[] = [
['a', 'atreides'],
['h', 'harkonnen'],
['g', 'guild'],
],
): Game {
const [first, ...rest] = seats;
const game = createGame(
'DISCOVERYCOLLECTION',
newPlayer(first[0], first[0], first[1]),
advanced,
);
game.players.push(...rest.map(([id, faction]) => newPlayer(id, id, faction)));
Object.assign(game, {
status: 'playing',
phase: 7,
turn: 2,
storm: 18,
order: seats.map(([id]) => id),
spice: {},
discoveries: createDiscoveryState(() => 0),
});
for (const player of game.players) {
player.forces = {};
player.spice = 0;
}
return game;
}
function reveal(game: Game, id: DiscoveryLocationId) {
const token = game.discoveries!.tokens.find((entry) => entry.face === id)!;
const smuggler = token.type === 'smuggler';
Object.assign(token, {
status: 'placed',
territory: smuggler ? 'pasty_mesa' : 'meridian',
sector: smuggler ? 7 : 1,
revealedTurn: 2,
});
validateDiscoveryState(game.discoveries!);
}
function composed(game: Game) {
const ordinary = quoteSpiceCollection(game);
return {
ordinary,
discovery: quoteDiscoveryCollection(game, ordinary),
};
}
function balance(
quote: ReturnType<typeof quoteDiscoveryCollection>,
player: string,
) {
return quote.receipts.find((receipt) => receipt.player === player)!.balance;
}
void test('a sole Cistern occupant receives two bank spice in Basic and Advanced without changing collection facts', () => {
for (const advanced of [false, true]) {
const game = fixture(advanced);
reveal(game, CISTERN);
game.players[0].forces = { [`${CISTERN}:0`]: 1 };
game.players[0].spice = 4;
const before = structuredClone(game);
const { ordinary, discovery } = composed(game);
assert.deepEqual(discovery.effects, [
{
kind: 'cistern',
player: 'a',
amount: 2,
source: 'bank',
},
]);
assert.equal(
balance(discovery, 'a'),
balance({ ...discovery, receipts: ordinary.receipts }, 'a') + 2,
);
assert.deepEqual(
discovery.receipts.map(({ player, collected, desert, strongholds }) => ({
player,
collected,
desert,
strongholds,
})),
ordinary.receipts.map(({ player, collected, desert, strongholds }) => ({
player,
collected,
desert,
strongholds,
})),
);
assert.deepEqual(game, before);
}
});
void test('Cistern follows Collection advisor releases and excludes remaining advisors', () => {
const hidden = fixture(true);
const hiddenToken = hidden.discoveries!.tokens.find(
(entry) => entry.face === CISTERN,
)!;
Object.assign(hiddenToken, {
status: 'placed',
territory: 'meridian',
sector: 1,
});
assert.deepEqual(composed(hidden).discovery.effects, []);
const advisors = fixture(true, [
['b', 'beneGesserit'],
['h', 'harkonnen'],
]);
reveal(advisors, CISTERN);
advisors.players[0].forces = { [`${CISTERN}:0`]: 1 };
advisors.players[0].advisors = { [CISTERN]: {} };
const released = composed(advisors);
assert.deepEqual(released.ordinary.released, [{ player: 'b', territory: CISTERN }]);
assert.deepEqual(released.discovery.effects, [
{ kind: 'cistern', player: 'b', amount: 2, source: 'bank' },
]);
advisors.players[1].forces = { [`${CISTERN}:0`]: 1 };
const remainingAdvisor = composed(advisors);
assert.deepEqual(remainingAdvisor.ordinary.released, []);
assert.deepEqual(remainingAdvisor.discovery.effects, [
{ kind: 'cistern', player: 'h', amount: 2, source: 'bank' },
]);
});
void test('the bounded Orgiz quote transfers one spice from each uniquely collected observable board deposit', () => {
const game = fixture();
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].forces = {
[`${ORGIZ_PROCESSING_STATION}:0`]: 1,
'red_chasm:7': 1,
};
game.players[1].forces = { 'hagga_basin:12': 1 };
game.players[2].forces = { 'cielago_south:2': 1 };
game.spice = {
'red_chasm:7': 2,
'hagga_basin:12': 4,
'cielago_south:2': 3,
};
const before = structuredClone(game);
const { ordinary, discovery } = composed(game);
assert.deepEqual(discovery.effects, [
{
kind: 'orgiz',
player: 'a',
from: 'g',
location: 'cielago_south:2',
amount: 1,
source: 'player',
},
{
kind: 'orgiz',
player: 'a',
from: 'h',
location: 'hagga_basin:12',
amount: 1,
source: 'player',
},
]);
assert.equal(
balance(discovery, 'a'),
balance({ ...discovery, receipts: ordinary.receipts }, 'a') + 2,
);
assert.equal(
balance(discovery, 'h'),
balance({ ...discovery, receipts: ordinary.receipts }, 'h') - 1,
);
assert.equal(
balance(discovery, 'g'),
balance({ ...discovery, receipts: ordinary.receipts }, 'g') - 1,
);
assert.equal(
discovery.receipts.reduce((sum, receipt) => sum + receipt.balance, 0),
ordinary.receipts.reduce((sum, receipt) => sum + receipt.balance, 0),
);
assert.deepEqual(game, before);
assert.deepEqual(quoteSpiceCollection(game), ordinary);
});
void test('one rival pays once per collected pile, not once per player or stacked spice amount', () => {
const game = fixture();
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
game.players[1].forces = { 'hagga_basin:12': 1, 'cielago_south:2': 1 };
game.spice = { 'hagga_basin:12': 8, 'cielago_south:2': 2 };
const { ordinary, discovery } = composed(game);
assert.deepEqual(
discovery.effects.filter(effect => effect.kind === 'orgiz')
.map(effect => [effect.from, effect.location]),
[['h', 'cielago_south:2'], ['h', 'hagga_basin:12']],
);
assert.equal(balance(discovery, 'a'), 2);
assert.equal(balance(discovery, 'h'), balance({
...discovery, receipts: ordinary.receipts,
}, 'h') - 2);
assert.equal(ordinary.spice['hagga_basin:12'], 6);
});
void test('Orgiz does not transfer spice from its occupant to itself and ignores untouched deposits', () => {
const game = fixture();
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].forces = {
[`${ORGIZ_PROCESSING_STATION}:0`]: 1,
'red_chasm:7': 1,
};
game.players[1].forces = { 'hagga_basin:12': 1 };
game.spice = {
'red_chasm:7': 2,
'hagga_basin:12': 2,
'cielago_south:2': 6,
};
const { ordinary, discovery } = composed(game);
assert.deepEqual(discovery.effects, [
{
kind: 'orgiz',
player: 'a',
from: 'h',
location: 'hagga_basin:12',
amount: 1,
source: 'player',
},
]);
assert.equal(
balance(discovery, 'a'),
balance({ ...discovery, receipts: ordinary.receipts }, 'a') + 1,
);
assert.equal(discovery.receipts[0].collected, ordinary.receipts[0].collected);
assert.equal(discovery.receipts[0].desert, ordinary.receipts[0].desert);
});
void test('Orgiz charges a collector whose advisors become fighters before Collection', () => {
const game = fixture(true, [
['a', 'atreides'], ['b', 'beneGesserit'], ['h', 'harkonnen'],
]);
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
game.players[1].forces = { 'hagga_basin:12': 1 };
game.players[1].advisors = { hagga_basin: { lockedTurn: 1 } };
game.spice = { 'hagga_basin:12': 2 };
const ordinary = quoteSpiceCollection(game);
assert.deepEqual(ordinary.released, [{ player: 'b', territory: 'hagga_basin' }]);
const discovery = quoteDiscoveryCollection(game, ordinary);
assert.deepEqual(discovery.effects, [{
kind: 'orgiz', player: 'a', from: 'b', location: 'hagga_basin:12',
amount: 1, source: 'player',
}]);
assert.equal(balance(discovery, 'a'), 1);
assert.equal(balance(discovery, 'b'), 1);
});
void test('Orgiz withholds uncertain Ecaz shared theft while collecting an independent deposit', () => {
const game = fixture(false, [
['e', 'ecaz'],
['a', 'atreides'],
['g', 'guild'],
]);
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].ally = 'a';
game.players[1].ally = 'e';
game.players[0].forces = { 'hagga_basin:12': 1, 'red_chasm:7': 1 };
game.players[1].forces = { 'hagga_basin:12': 1 };
game.players[2].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
game.spice = { 'hagga_basin:12': 4, 'red_chasm:7': 2 };
const ordinary = quoteSpiceCollection(game);
assert.deepEqual(ordinary.shared, [
{ territory: 'hagga_basin', ecaz: 'e', ally: 'a', amount: 4 },
]);
const before = structuredClone({ game, ordinary });
const discovery = quoteDiscoveryCollection(game, ordinary);
assert.deepEqual(discovery.effects, [{
kind: 'orgiz', player: 'g', from: 'e', location: 'red_chasm:7',
amount: 1, source: 'player',
}]);
assert.equal(balance(discovery, 'e'), 1);
assert.equal(balance(discovery, 'g'), 1);
assert.deepEqual({ game, ordinary }, before);
});
void test('two allied Orgiz occupants leave ordinary collection playable without uncertain theft', () => {
const game = fixture(false, [
['e', 'ecaz'], ['a', 'atreides'], ['g', 'guild'],
]);
game.discoveryEnabled = true;
reveal(game, ORGIZ_PROCESSING_STATION);
game.players[0].ally = 'a';
game.players[1].ally = 'e';
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
game.players[1].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
game.players[2].forces = { 'hagga_basin:12': 1 };
game.players.forEach(player => {
Object.assign(player, {
hand: [], traitors: [], traitorChoices: [], shipped: true,
moved: 1, reserves: 19,
});
});
game.spice = { 'hagga_basin:12': 2 };
const ordinary = quoteSpiceCollection(game);
assert.deepEqual(quoteDiscoveryCollection(game, ordinary), {
receipts: ordinary.receipts, effects: [],
});
Object.assign(game, {
phase: 5, active: 'g', movementRemaining: ['g'], phaseOpening: null,
});
const collected = applyAction(game, 'g', { type: 'endMovement' });
assert.equal(collected.phase, 7);
assert.deepEqual(collected.players.map(player => player.spice), [0, 0, 2]);
assert.equal(collected.spice['hagga_basin:12'], 0);
});
void test('a contested Cistern retains the unresolved bonus without blocking collection', () => {
const game = fixture(false, [
['e', 'ecaz'],
['a', 'atreides'],
['g', 'guild'],
]);
reveal(game, CISTERN);
game.players[0].ally = 'a';
game.players[1].ally = 'e';
game.players[0].forces = { [`${CISTERN}:0`]: 1 };
game.players[1].forces = { [`${CISTERN}:0`]: 1 };
const ordinary = quoteSpiceCollection(game);
const before = structuredClone({ game, ordinary });
assert.deepEqual(quoteDiscoveryCollection(game, ordinary), {
receipts: ordinary.receipts,
effects: [],
});
assert.deepEqual({ game, ordinary }, before);
});
void test('the real collection phase credits Cistern once and preserves the receipt through JSON recovery', () => {
const game = fixture(false);
game.discoveryEnabled = true;
reveal(game, CISTERN);
Object.assign(game, {
phase: 5,
active: 'g',
movementRemaining: ['g'],
phaseOpening: null,
});
for (const player of game.players)
Object.assign(player, {
hand: [],
traitors: [],
traitorChoices: [],
shipped: true,
moved: 1,
reserves: 20,
});
game.players[0].forces = { [`${CISTERN}:0`]: 1 };
game.players[0].forces['red_chasm:7'] = 1;
game.players[0].reserves = 18;
game.players[0].spice = 4;
game.spice = { 'red_chasm:7': 2 };
const before = structuredClone(game);
const ordinary = quoteSpiceCollection(game);
assert.equal(
ordinary.receipts.find((receipt) => receipt.player === 'a')!.balance,
6,
);
let collected = applyAction(game, 'g', { type: 'endMovement' });
assert.deepEqual(game, before);
assert.equal(collected.phase, 7);
assert.equal(collected.players.find((player) => player.id === 'a')!.spice, 8);
assert.equal(collected.spice['red_chasm:7'], 0);
assert.equal(
collected.log.filter((entry) =>
entry.text.includes(
'received 2 spice from the bank for occupying Cistern',
),
).length,
1,
);
collected = JSON.parse(JSON.stringify(collected)) as Game;
assert.deepEqual(normalizeAutomaticGame(collected), collected);
assert.equal(collected.players.find((player) => player.id === 'a')!.spice, 8);
assert.equal(
collected.log.filter((entry) => entry.automatic?.name === 'Cistern').length,
1,
);
});
void test('a revealed sole Orgiz occupant steals one from each rival collected deposit in Basic and Advanced', () => {
for (const advanced of [false, true]) {
const game = fixture(advanced);
game.discoveryEnabled = true;
reveal(game, ORGIZ_PROCESSING_STATION);
Object.assign(game, {
phase: 5,
active: 'g',
movementRemaining: ['g'],
phaseOpening: null,
});
for (const player of game.players)
Object.assign(player, {
hand: [], traitors: [], traitorChoices: [], shipped: true,
moved: 1, reserves: 20,
});
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1, 'red_chasm:7': 1 };
game.players[0].reserves = 18;
game.players[1].forces = { 'hagga_basin:12': 1 };
game.players[1].reserves = 19;
game.players[2].forces = { 'cielago_south:2': 1 };
game.players[2].reserves = 19;
game.spice = { 'red_chasm:7': 2, 'hagga_basin:12': 4, 'cielago_south:2': 3 };
const before = structuredClone(game);
const collected = applyAction(game, 'g', { type: 'endMovement' });
assert.deepEqual(game, before);
assert.equal(collected.phase, 7);
assert.deepEqual(collected.players.map(player => player.spice), [4, 1, 1]);
assert.equal(collected.spice['red_chasm:7'], 0);
assert.equal(collected.spice['hagga_basin:12'], 2);
assert.equal(collected.spice['cielago_south:2'], 1);
assert.equal(
collected.players.reduce((total, player) => total + player.spice, 0) +
Object.values(collected.spice).reduce((total, spice) => total + spice, 0),
9,
);
assert.equal(collected.log.filter(entry => entry.automatic?.name === 'Orgiz Processing Station').length, 2);
const restored = JSON.parse(JSON.stringify(collected)) as Game;
assert.deepEqual(normalizeAutomaticGame(restored), restored);
assert.deepEqual(restored.players.map(player => player.spice), [4, 1, 1]);
}
});
void test('a saved Ecaz response commits Cistern once only after the collection response resolves', () => {
const game = createGame(
'DISCOVERYECAZCOLLECTION',
newPlayer('ec', 'Ecaz', 'ecaz'),
true,
['ecaz'],
);
game.players.push(
newPlayer('al', 'Ally', 'atreides'),
newPlayer('en', 'Rival', 'emperor'),
);
Object.assign(game, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
order: ['ec', 'al', 'en'],
active: 'en',
movementRemaining: ['en'],
phaseOpening: null,
spice: {},
deck: baseDeck(),
discard: [],
discoveryEnabled: true,
discoveries: createDiscoveryState(() => 0),
});
reveal(game, CISTERN);
for (const player of game.players)
Object.assign(player, {
spice: 10,
hand: [],
traitors: [],
traitorChoices: [],
forces: {},
reserves: 20,
shipped: true,
moved: 1,
});
game.players[0].ally = 'al';
game.players[1].ally = 'ec';
game.players[0].allySinceTurn = 1;
game.players[1].allySinceTurn = 1;
game.players[0].forces = { 'arrakeen:10': 1 };
game.players[1].forces = { 'arrakeen:10': 1 };
game.players[2].forces = { [`${CISTERN}:0`]: 1 };
for (const player of game.players) player.reserves = 19;
const karama = game.deck.findIndex((card) => card.name === 'Karama');
assert.ok(karama >= 0);
game.players[2].hand.push(game.deck.splice(karama, 1)[0]);
let collected = applyAction(game, 'en', { type: 'endMovement' });
assert.equal(collected.response?.kind, 'ecazCollection');
assert.equal(collected.players[2].spice, 10);
assert.equal(
collected.log.filter((entry) => entry.automatic?.name === 'Cistern').length,
0,
);
collected = JSON.parse(JSON.stringify(collected)) as Game;
for (let count = 0; collected.response && count < 10; count++) {
const responder = collected.players.find(
(player) => !collected.response!.passed.includes(player.id),
)!;
collected = applyAction(collected, responder.id, { type: 'passResponse' });
}
assert.equal(collected.response, null);
assert.equal(collected.players[2].spice, 12);
assert.equal(
collected.log.filter((entry) => entry.automatic?.name === 'Cistern').length,
1,
);
collected = JSON.parse(JSON.stringify(collected)) as Game;
assert.deepEqual(normalizeAutomaticGame(collected), collected);
assert.equal(collected.players[2].spice, 12);
assert.equal(
collected.log.filter((entry) => entry.automatic?.name === 'Cistern').length,
1,
);
});