735 lines
28 KiB
TypeScript
735 lines
28 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,
|
|
DiscoveryCollectionError,
|
|
quoteDiscoveryCollection,
|
|
quoteSharedOrgiz,
|
|
type DiscoveryCollectionContext,
|
|
type DiscoveryCollectionQuote,
|
|
} 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: DiscoveryCollectionQuote,
|
|
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('Basic Orgiz retains 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('Basic charges one rival per collected pile, not 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('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);
|
|
|
|
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 once from each rival territory with one deposit in either mode', () => {
|
|
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,
|
|
);
|
|
});
|
|
|
|
void test('Advanced counts each territory once across positive partial sector collections; Basic retains both deposits', () => {
|
|
for (const advanced of [false, true]) {
|
|
const game = fixture(advanced);
|
|
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, 'hagga_basin:13': 1, 'cielago_south:3': 1 };
|
|
game.spice = {
|
|
'hagga_basin:12': 8, 'hagga_basin:13': 1, 'cielago_south:3': 3,
|
|
'red_chasm:7': 2, 'cielago_south:2': 4, 'funeral_plain:15': 0,
|
|
};
|
|
const before = structuredClone(game);
|
|
const { ordinary, discovery } = composed(game);
|
|
assert.deepEqual(ordinary.spice, {
|
|
'hagga_basin:12': 6, 'hagga_basin:13': 0, 'cielago_south:3': 1,
|
|
'red_chasm:7': 0, 'cielago_south:2': 4, 'funeral_plain:15': 0,
|
|
[`${ORGIZ_PROCESSING_STATION}:0`]: 0,
|
|
});
|
|
assert.deepEqual(discovery.effects.map(effect =>
|
|
effect.kind === 'orgiz' ? [effect.from, effect.location] : effect.kind),
|
|
advanced
|
|
? [['h', 'cielago_south:3'], ['h', 'hagga_basin:12']]
|
|
: [['h', 'cielago_south:3'], ['h', 'hagga_basin:12'], ['h', 'hagga_basin:13']]);
|
|
const count = advanced ? 2 : 3;
|
|
assert.equal(balance(discovery, 'a'), ordinary.receipts[0].balance + count);
|
|
assert.equal(balance(discovery, 'h'), ordinary.receipts[1].balance - count);
|
|
assert.deepEqual(discovery.receipts.map(({ balance: _, ...facts }) => facts),
|
|
ordinary.receipts.map(({ balance: _, ...facts }) => facts));
|
|
assert.equal(discovery.receipts.reduce((sum, receipt) => sum + receipt.balance, 0),
|
|
ordinary.receipts.reduce((sum, receipt) => sum + receipt.balance, 0));
|
|
assert.deepEqual(game, before);
|
|
}
|
|
});
|
|
|
|
void test('Advanced selects one actual foreign territory collector in storm order, skipping own collections', () => {
|
|
for (const second of ['a', 'g']) {
|
|
const game = fixture(true);
|
|
reveal(game, ORGIZ_PROCESSING_STATION);
|
|
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
|
game.players[1].forces = { 'hagga_basin:12': 1, 'red_chasm:7': 1 };
|
|
game.players.find(player => player.id === second)!.forces['hagga_basin:13'] = 1;
|
|
game.order = ['a', 'g', 'h'];
|
|
game.spice = { 'hagga_basin:12': 3, 'hagga_basin:13': 2, 'red_chasm:7': 2 };
|
|
const { ordinary, discovery } = composed(game);
|
|
const payer = second === 'a' ? 'h' : 'g';
|
|
assert.deepEqual(discovery.effects, [{
|
|
kind: 'orgiz', player: 'a', from: payer,
|
|
location: second === 'a' ? 'hagga_basin:12' : 'hagga_basin:13',
|
|
amount: 1, source: 'player',
|
|
}, {
|
|
kind: 'orgiz', player: 'a', from: 'h', location: 'red_chasm:7',
|
|
amount: 1, source: 'player',
|
|
}]);
|
|
assert.equal(balance(discovery, 'a'), ordinary.receipts[0].balance + 2);
|
|
assert.equal(balance(discovery, 'h'), ordinary.receipts.find(r => r.player === 'h')!.balance - (payer === 'h' ? 2 : 1));
|
|
assert.equal(balance(discovery, 'g'), ordinary.receipts.find(r => r.player === 'g')!.balance - (payer === 'g' ? 1 : 0));
|
|
assert.equal(ordinary.spice['hagga_basin:13'], 0);
|
|
}
|
|
});
|
|
|
|
void test('Advanced falls through to the next actual collector when an earlier Orgiz owner exhausts the first purse', () => {
|
|
const game = fixture(true, [['a', 'atreides'], ['h', 'harkonnen'], ['g', 'guild'], ['e', 'emperor']]);
|
|
reveal(game, ORGIZ_PROCESSING_STATION);
|
|
game.order = ['a', 'g', 'h', 'e'];
|
|
game.players.find(p => p.id === 'a')!.forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
|
game.players.find(p => p.id === 'g')!.forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
|
game.players.find(p => p.id === 'h')!.forces = { 'hagga_basin:12': 1 };
|
|
game.players.find(p => p.id === 'e')!.forces = { 'hagga_basin:13': 1 };
|
|
game.spice = { 'hagga_basin:12': 1, 'hagga_basin:13': 2 };
|
|
const { discovery } = composed(game);
|
|
assert.deepEqual(discovery.effects, [
|
|
{ kind: 'orgiz', player: 'a', from: 'h', location: 'hagga_basin:12', amount: 1, source: 'player' },
|
|
{ kind: 'orgiz', player: 'g', from: 'e', location: 'hagga_basin:13', amount: 1, source: 'player' },
|
|
]);
|
|
assert.equal(balance(discovery, 'h'), 0);
|
|
assert.equal(balance(discovery, 'e'), 1);
|
|
});
|
|
|
|
void test('Advanced uses actual shared-sector receipts rather than withholding an entire territory for co-present forces', () => {
|
|
const game = fixture(true);
|
|
reveal(game, ORGIZ_PROCESSING_STATION);
|
|
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
|
game.players[1].forces = { 'hagga_basin:12': 1, 'hagga_basin:13': 1, 'red_chasm:7': 1 };
|
|
game.players[2].forces = { 'hagga_basin:13': 1 };
|
|
game.spice = { 'hagga_basin:12': 4, 'hagga_basin:13': 4, 'red_chasm:7': 2 };
|
|
const { ordinary, discovery } = composed(game);
|
|
assert.equal(ordinary.spice['hagga_basin:12'], 2);
|
|
assert.equal(ordinary.spice['hagga_basin:13'], 0);
|
|
assert.deepEqual(discovery.effects, [{
|
|
kind: 'orgiz', player: 'a', from: 'h', location: 'hagga_basin:12',
|
|
amount: 1, source: 'player',
|
|
}, {
|
|
kind: 'orgiz', player: 'a', from: 'h', location: 'red_chasm:7',
|
|
amount: 1, source: 'player',
|
|
}]);
|
|
assert.equal(balance(discovery, 'g'), ordinary.receipts[2].balance);
|
|
});
|
|
|
|
void test('Orgiz never charges a co-present faction that collected zero, even with an old wallet', () => {
|
|
for (const advanced of [false, true])
|
|
for (const pile of [2, 4]) {
|
|
const game = fixture(advanced);
|
|
reveal(game, ORGIZ_PROCESSING_STATION);
|
|
game.players[1].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1, 'hagga_basin:12': 1 };
|
|
game.players[2].forces = { 'hagga_basin:12': 1 };
|
|
game.players[2].spice = 20;
|
|
const { ordinary, discovery } = composed({ ...game, spice: { 'hagga_basin:12': pile } });
|
|
assert.equal(ordinary.deposits.some(entry => entry.player === 'g'), pile === 4);
|
|
assert.equal(balance(discovery, 'g'), 20 + (pile === 4 ? 1 : 0));
|
|
assert.equal(balance(discovery, 'h'), 2 + (pile === 4 ? 1 : 0));
|
|
assert.equal(discovery.effects.length, pile === 4 ? 1 : 0);
|
|
}
|
|
});
|
|
|
|
void test('Advanced shared territories remain unpaid across all their source keys while independent Collection proceeds', () => {
|
|
const game = fixture(true, [['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:13': 1 };
|
|
game.players[2].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
|
game.spice = { 'hagga_basin:12': 4, 'hagga_basin:13': 4, 'red_chasm:7': 2 };
|
|
const { ordinary, discovery } = composed(game);
|
|
assert.deepEqual(ordinary.shared, [{ territory: 'hagga_basin', ecaz: 'e', ally: 'a', amount: 4 }]);
|
|
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);
|
|
});
|
|
|
|
void test('Advanced retains public effective No-Field presence for the Orgiz occupant and collected source', () => {
|
|
const game = fixture(true, [['r', 'richese'], ['h', 'harkonnen']]);
|
|
reveal(game, ORGIZ_PROCESSING_STATION);
|
|
game.players[0].noField = {
|
|
deployed: { location: { territory: ORGIZ_PROCESSING_STATION, sector: 0 } },
|
|
} as NonNullable<Game['players'][number]['noField']>;
|
|
game.players[1].forces = { 'hagga_basin:12': 1 };
|
|
game.spice = { 'hagga_basin:12': 2 };
|
|
assert.equal(composed(game).discovery.effects.length, 1);
|
|
game.players[0].noField!.deployed!.location = { territory: 'hagga_basin', sector: 13 };
|
|
game.players[1].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
|
game.spice = { 'hagga_basin:12': 2, 'hagga_basin:13': 4 };
|
|
const { ordinary, discovery } = composed(game);
|
|
assert.equal(ordinary.spice['hagga_basin:13'], 2);
|
|
assert.deepEqual(discovery.effects, [{
|
|
kind: 'orgiz', player: 'h', from: 'r', location: 'hagga_basin:13',
|
|
amount: 1, source: 'player',
|
|
}]);
|
|
});
|
|
|
|
void test('Discovery Collection requires an explicit mode rather than treating missing Advanced as authoritative', () => {
|
|
const game = fixture(true);
|
|
const ordinary = quoteSpiceCollection(game);
|
|
const { advanced: _, ...missingMode } = game;
|
|
assert.throws(() => quoteDiscoveryCollection(
|
|
missingMode as unknown as DiscoveryCollectionContext, ordinary,
|
|
), DiscoveryCollectionError);
|
|
});
|
|
|
|
void test('each Cistern faction earns two, while a lone advisor earns none', () => {
|
|
const game=fixture(true,[['a','atreides'],['b','beneGesserit'],['g','guild']]);
|
|
reveal(game,CISTERN);
|
|
game.players[0].forces={[`${CISTERN}:0`]:1};
|
|
game.players[1].forces={[`${CISTERN}:0`]:1};
|
|
game.players[1].advisors={[CISTERN]:{}};
|
|
game.players[2].forces={[`${CISTERN}:0`]:1};
|
|
const before=structuredClone(game);
|
|
const {ordinary,discovery}=composed(game);
|
|
assert.deepEqual(discovery.effects,[
|
|
{kind:'cistern',player:'a',amount:2,source:'bank'},
|
|
{kind:'cistern',player:'g',amount:2,source:'bank'},
|
|
]);
|
|
assert.deepEqual(discovery.receipts.map(r=>r.balance),[2,0,2]);
|
|
assert.deepEqual(ordinary.receipts.map(r=>r.balance),[0,0,0]);
|
|
assert.deepEqual(game,before);
|
|
});
|
|
|
|
void test('two Orgiz owners resolve available collector spice in original storm order without creating debt', () => {
|
|
for(const first of ['a','g']) {
|
|
const game=fixture(true);
|
|
reveal(game,ORGIZ_PROCESSING_STATION);
|
|
game.order=first==='a'?['a','h','g']:['g','h','a'];
|
|
game.players[0].forces={[`${ORGIZ_PROCESSING_STATION}:0`]:1};
|
|
game.players[2].forces={[`${ORGIZ_PROCESSING_STATION}:0`]:1};
|
|
game.players[1].forces={'hagga_basin:12':1};
|
|
game.spice={'hagga_basin:12':1};
|
|
const {discovery}=composed(game);
|
|
assert.deepEqual(discovery.effects,[{kind:'orgiz',player:first,from:'h',
|
|
location:'hagga_basin:12',amount:1,source:'player'}]);
|
|
assert.equal(balance(discovery,first),1);
|
|
assert.equal(balance(discovery,first==='a'?'g':'a'),0);
|
|
assert.equal(balance(discovery,'h'),0);
|
|
}
|
|
});
|
|
|
|
function sharedOrgizBoundary(advanced:boolean) {
|
|
const game=fixture(advanced,[['a','atreides'],['e','ecaz'],['g','guild']]);
|
|
reveal(game,ORGIZ_PROCESSING_STATION);
|
|
game.players[0].forces={[`${ORGIZ_PROCESSING_STATION}:0`]:1};
|
|
game.players[1].forces={'hagga_basin:12':1};
|
|
game.players[2].forces={'hagga_basin:13':1,'red_chasm:7':1};
|
|
game.players[1].ally='g';game.players[2].ally='e';
|
|
game.spice={'hagga_basin:12':2,'hagga_basin:13':2,'imperial_basin:9':0,'red_chasm:7':3};
|
|
const ordinary=quoteSpiceCollection(game);
|
|
return {game,context:{...game,spice:ordinary.spice},originalSpice:game.spice,
|
|
allocation:{territory:'hagga_basin',ecaz:'e',ally:'g',ecazAmount:2,allyAmount:2,method:'equal' as const}};
|
|
}
|
|
|
|
void test('shared Orgiz counts actual positive source deposits in Basic, one territory in Advanced, excluding unrelated and empty keys',()=>{
|
|
for(const advanced of [false,true]) {
|
|
const f=sharedOrgizBoundary(advanced);
|
|
const before=structuredClone(f);
|
|
const effects=quoteSharedOrgiz(f.context,f.allocation,{a:0,e:2,g:4},f.originalSpice);
|
|
assert.deepEqual(effects.map(effect=>[effect.player,effect.from,effect.location]),
|
|
advanced?[['a','e','hagga_basin']]:
|
|
[['a','e','hagga_basin:12'],['a','e','hagga_basin:13']]);
|
|
assert.deepEqual(f,before);
|
|
}
|
|
});
|
|
|
|
void test('Basic shared Orgiz exhausts the first recipient then considers the actual ally, never self',()=>{
|
|
const f=sharedOrgizBoundary(false);
|
|
f.context.players[2].forces[`${ORGIZ_PROCESSING_STATION}:0`]=1;
|
|
const allocation={...f.allocation,ecazAmount:1,allyAmount:3,method:'agreement' as const};
|
|
const effects=quoteSharedOrgiz(f.context,allocation,{a:0,e:1,g:5},f.originalSpice);
|
|
assert.deepEqual(effects.map(effect=>[effect.player,effect.from,effect.location]),
|
|
[['a','e','hagga_basin:12'],['a','g','hagga_basin:13']]);
|
|
});
|
|
|
|
void test('Basic shared Orgiz skips a zero-share Ecaz wallet and charges only the positive foreign ally',()=>{
|
|
const f=sharedOrgizBoundary(false);
|
|
const allocation={...f.allocation,ecazAmount:0,allyAmount:4,method:'agreement' as const};
|
|
const balances={a:0,e:20,g:6},before=structuredClone(balances);
|
|
const effects=quoteSharedOrgiz(f.context,allocation,balances,f.originalSpice);
|
|
assert.deepEqual(effects.map(effect=>effect.from),['g','g']);
|
|
assert.deepEqual(balances,before);
|
|
});
|
|
|
|
void test('shared Orgiz checks cumulative income overflow without mutating either purse or original source',()=>{
|
|
const f=sharedOrgizBoundary(false);
|
|
const allocation={...f.allocation,ecazAmount:4,allyAmount:0,method:'agreement' as const};
|
|
const balances={a:Number.MAX_SAFE_INTEGER-1,e:4,g:2};
|
|
const before=structuredClone({f,balances});
|
|
assert.throws(()=>quoteSharedOrgiz(f.context,allocation,balances,f.originalSpice),DiscoveryCollectionError);
|
|
assert.deepEqual({f,balances},before);
|
|
f.context.advanced=true;
|
|
assert.deepEqual(quoteSharedOrgiz(f.context,allocation,balances,f.originalSpice),
|
|
[{kind:'orgiz',player:'a',from:'e',location:'hagga_basin',amount:1,source:'player'}]);
|
|
});
|