Prototype Discovery spice encounters, private tokens and stash rewards
Connect real Basic/Advanced base-faction audit setup to all seven Discovery Spice Cards and eight physical tokens. Add Great Maker's ordered majority vote and Fremen reserve ride, destructive Discovery blows, private faction peeks and Collection inspection/reveal, stash rewards and mandatory saved card overflow, and revealed nested board locations. Share private legal choices with human controls and all four AI profiles. Preserve normal gates. Independent review repairs orphaned worm/vote/ride controls, retargeted reserve arrivals, actor bindings and incomplete overflow discard receipts. Harden room-aware admission against unrevealed nested locations. Extend the backed-up local prototype starter with a Discovery profile; preserve seats. Still missing: next-turn free entry, five special location benefits and Ornithopter movement spending. Current prototype playthroughs do not certify those effects or complete module/expansion compliance. Verification: npm run check passed 4264 offline tests plus types/lint; npm run build passed; npm run test:integration passed 40 tests. The single source-bound broad run took 129358ms / 6782ms / 23918ms respectively. Report: /home/quintine/.local/share/dune/checkpoints/20260913-discovery-prototype/verification/report.json Source fingerprint: 4ea7b5209a58c369b0b1f8dc239b5dd2f3302bb759d83c32ba5406e80d8ae154 Two seeded Basic/Advanced games complete with component custody intact. SQLite races pay stashes once and resume committed discards without replay. Browser 3RJBH26Y verifies genuine human Fremen setup, a Discovery blow from one reordered physical card, native private Hiereg visibility, enlarged card/rules reading and refresh. Room remains paused at spice acceptance. All 125 opening saved rooms unchanged; 31 added, 156 total. No server restart. Efficiency: three reused bounded helpers; aggregate counter 60591430 to 61812513 after verification, 1221083 tokens before final Git/push overhead. 47 added offline tests. One stable broad run after focused review fixes; no calibration or exhaustive optional-module interaction matrix.
This commit is contained in:
1 parent
4af6e08b3a
commit
f0256c1b38
44 files changed
+4924
-104
No files matched your search
@@ -0,0 +1,462 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DISCOVERY_CARD_PLACEMENTS,
|
||||
DISCOVERY_LOCATION_BY_ID,
|
||||
DISCOVERY_LOCATION_IDS,
|
||||
DISCOVERY_LOCATIONS,
|
||||
DISCOVERY_SPICE_CARDS,
|
||||
DISCOVERY_TOKEN_BY_ID,
|
||||
DISCOVERY_TOKENS,
|
||||
JACURUTU_SIETCH,
|
||||
clearNewlyRevealed,
|
||||
consumeOrnithopter,
|
||||
createDiscoveryState,
|
||||
isDiscoveryLocationId,
|
||||
isDiscoveryTokenId,
|
||||
placeDiscovery,
|
||||
projectDiscoveryState,
|
||||
rememberDiscoveryFace,
|
||||
revealDiscoveryToken,
|
||||
validateDiscoveryState,
|
||||
type DiscoveryState,
|
||||
type DiscoveryTokenFace,
|
||||
} from '../game/discoveries';
|
||||
|
||||
const ordered = () => createDiscoveryState(() => 0.999);
|
||||
const reload = (state: DiscoveryState) =>
|
||||
JSON.parse(JSON.stringify(state)) as DiscoveryState;
|
||||
const tokenWithFace = (state: DiscoveryState, face: DiscoveryTokenFace) =>
|
||||
state.tokens.find((token) => token.face === face)!;
|
||||
|
||||
void test('printed cards and immutable token metadata preserve the sourced inventory and placements', () => {
|
||||
assert.deepEqual(DISCOVERY_SPICE_CARDS, [
|
||||
{
|
||||
territory: 'hagga_basin',
|
||||
amount: 6,
|
||||
sector: 12,
|
||||
discovery: 'discovery-hagga-basin',
|
||||
},
|
||||
{
|
||||
territory: 'rock_outcroppings',
|
||||
amount: 6,
|
||||
sector: 13,
|
||||
discovery: 'discovery-rock-outcroppings',
|
||||
},
|
||||
{
|
||||
territory: 'sihaya_ridge',
|
||||
amount: 6,
|
||||
sector: 9,
|
||||
discovery: 'discovery-sihaya-ridge',
|
||||
},
|
||||
{
|
||||
territory: 'wind_pass_north',
|
||||
amount: 6,
|
||||
sector: 17,
|
||||
discovery: 'discovery-wind-pass-north',
|
||||
},
|
||||
{
|
||||
territory: 'funeral_plain',
|
||||
amount: 6,
|
||||
sector: 15,
|
||||
discovery: 'discovery-funeral-plain',
|
||||
},
|
||||
{
|
||||
territory: 'oh_gap',
|
||||
amount: 6,
|
||||
sector: 10,
|
||||
discovery: 'discovery-old-gap',
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(DISCOVERY_CARD_PLACEMENTS, {
|
||||
'discovery-hagga-basin': {
|
||||
type: 'hiereg',
|
||||
territory: 'gara_kulon',
|
||||
sector: 8,
|
||||
},
|
||||
'discovery-rock-outcroppings': {
|
||||
type: 'hiereg',
|
||||
territory: 'meridian',
|
||||
sector: 1,
|
||||
},
|
||||
'discovery-sihaya-ridge': {
|
||||
type: 'hiereg',
|
||||
territory: 'cielago_east',
|
||||
sector: 3,
|
||||
},
|
||||
'discovery-wind-pass-north': {
|
||||
type: 'smuggler',
|
||||
territory: 'plastic_basin',
|
||||
sector: 13,
|
||||
},
|
||||
'discovery-funeral-plain': {
|
||||
type: 'smuggler',
|
||||
territory: 'pasty_mesa',
|
||||
sector: 7,
|
||||
},
|
||||
'discovery-old-gap': {
|
||||
type: 'smuggler',
|
||||
territory: 'false_wall_west',
|
||||
sector: 17,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
DISCOVERY_TOKENS.map(({ id, type, kind }) => ({ id, type, kind })),
|
||||
[
|
||||
{ id: 'jacurutu-sietch', type: 'hiereg', kind: 'location' },
|
||||
{ id: 'cistern', type: 'hiereg', kind: 'location' },
|
||||
{
|
||||
id: 'ecological-testing-station',
|
||||
type: 'hiereg',
|
||||
kind: 'location',
|
||||
},
|
||||
{ id: 'shrine', type: 'hiereg', kind: 'location' },
|
||||
{
|
||||
id: 'orgiz-processing-station',
|
||||
type: 'smuggler',
|
||||
kind: 'location',
|
||||
},
|
||||
{ id: 'treachery-card-stash', type: 'smuggler', kind: 'instant' },
|
||||
{ id: 'spice-stash', type: 'smuggler', kind: 'instant' },
|
||||
{ id: 'ornithopter', type: 'smuggler', kind: 'carried' },
|
||||
],
|
||||
);
|
||||
assert.equal(JACURUTU_SIETCH, 'jacurutu-sietch');
|
||||
assert.deepEqual(DISCOVERY_LOCATION_IDS, [
|
||||
'jacurutu-sietch',
|
||||
'cistern',
|
||||
'ecological-testing-station',
|
||||
'shrine',
|
||||
'orgiz-processing-station',
|
||||
]);
|
||||
assert.equal(DISCOVERY_LOCATION_BY_ID[JACURUTU_SIETCH].stronghold, true);
|
||||
assert.equal(DISCOVERY_TOKEN_BY_ID.ornithopter.location, null);
|
||||
assert.equal(isDiscoveryLocationId('cistern'), true);
|
||||
assert.equal(isDiscoveryLocationId('ornithopter'), false);
|
||||
assert.equal(isDiscoveryTokenId('discovery-token-8'), true);
|
||||
assert.equal(isDiscoveryTokenId('discovery-token-9'), false);
|
||||
assert.ok(Object.isFrozen(DISCOVERY_SPICE_CARDS));
|
||||
assert.ok(DISCOVERY_SPICE_CARDS.every(Object.isFrozen));
|
||||
assert.ok(Object.isFrozen(DISCOVERY_TOKENS));
|
||||
assert.ok(DISCOVERY_TOKENS.every(Object.isFrozen));
|
||||
assert.ok(Object.isFrozen(DISCOVERY_LOCATIONS));
|
||||
assert.ok(DISCOVERY_LOCATIONS.every(Object.isFrozen));
|
||||
});
|
||||
|
||||
void test('setup shuffles eight secret faces behind stable opaque physical IDs and survives JSON', () => {
|
||||
let calls = 0;
|
||||
const state = createDiscoveryState(() => {
|
||||
calls++;
|
||||
return 0.999;
|
||||
});
|
||||
assert.equal(calls, 7);
|
||||
assert.deepEqual(
|
||||
state.tokens.map((token) => token.id),
|
||||
Array.from({ length: 8 }, (_, index) => `discovery-token-${index + 1}`),
|
||||
);
|
||||
assert.deepEqual(
|
||||
state.tokens.map((token) => token.face),
|
||||
DISCOVERY_TOKENS.map((token) => token.id),
|
||||
);
|
||||
assert.ok(
|
||||
state.tokens.every(
|
||||
(token) =>
|
||||
token.status === 'supply' &&
|
||||
token.territory === null &&
|
||||
token.sector === null &&
|
||||
token.revealedTurn === null &&
|
||||
token.owner === null &&
|
||||
token.known.length === 0,
|
||||
),
|
||||
);
|
||||
assert.notDeepEqual(
|
||||
createDiscoveryState(() => 0).tokens.map((token) => token.face),
|
||||
state.tokens.map((token) => token.face),
|
||||
);
|
||||
validateDiscoveryState(reload(state));
|
||||
for (const draw of [-1, 1, NaN, Infinity])
|
||||
assert.throws(() => createDiscoveryState(() => draw), /random draws/);
|
||||
});
|
||||
|
||||
void test('placement draws only the required type and uses the printed token destination without mutation', () => {
|
||||
const initial = ordered();
|
||||
const before = reload(initial);
|
||||
const hiereg = placeDiscovery(initial, 'discovery-rock-outcroppings', () => 0);
|
||||
const placedHiereg = hiereg.tokens.find((token) => token.status === 'placed')!;
|
||||
assert.deepEqual(
|
||||
{
|
||||
id: placedHiereg.id,
|
||||
type: placedHiereg.type,
|
||||
face: placedHiereg.face,
|
||||
territory: placedHiereg.territory,
|
||||
sector: placedHiereg.sector,
|
||||
},
|
||||
{
|
||||
id: 'discovery-token-1',
|
||||
type: 'hiereg',
|
||||
face: 'jacurutu-sietch',
|
||||
territory: 'meridian',
|
||||
sector: 1,
|
||||
},
|
||||
);
|
||||
const smuggler = placeDiscovery(initial, 'discovery-funeral-plain', () => 0);
|
||||
const placedSmuggler = smuggler.tokens.find(
|
||||
(token) => token.status === 'placed',
|
||||
)!;
|
||||
assert.deepEqual(
|
||||
[
|
||||
placedSmuggler.id,
|
||||
placedSmuggler.type,
|
||||
placedSmuggler.face,
|
||||
placedSmuggler.territory,
|
||||
placedSmuggler.sector,
|
||||
],
|
||||
['discovery-token-5', 'smuggler', 'orgiz-processing-station', 'pasty_mesa', 7],
|
||||
);
|
||||
assert.deepEqual(initial, before);
|
||||
assert.notEqual(hiereg.tokens, initial.tokens);
|
||||
assert.notEqual(hiereg.tokens[0].known, initial.tokens[0].known);
|
||||
});
|
||||
|
||||
void test('unavailable types and bad randomness reject before consuming custody', () => {
|
||||
let state = ordered();
|
||||
for (const card of [
|
||||
'discovery-hagga-basin',
|
||||
'discovery-rock-outcroppings',
|
||||
'discovery-sihaya-ridge',
|
||||
'discovery-hagga-basin',
|
||||
] as const)
|
||||
state = placeDiscovery(state, card, () => 0);
|
||||
const before = reload(state);
|
||||
let calls = 0;
|
||||
assert.throws(
|
||||
() =>
|
||||
placeDiscovery(state, 'discovery-hagga-basin', () => {
|
||||
calls++;
|
||||
return 0;
|
||||
}),
|
||||
/No hiereg/,
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
assert.deepEqual(state, before);
|
||||
const initial = ordered();
|
||||
for (const draw of [-0.1, 1, NaN, Infinity]) {
|
||||
assert.throws(
|
||||
() => placeDiscovery(initial, 'discovery-hagga-basin', () => draw),
|
||||
/random draws/,
|
||||
);
|
||||
assert.deepEqual(initial, ordered());
|
||||
}
|
||||
});
|
||||
|
||||
void test('private projection hides supply order and hidden faces except for eligible or informed factions', () => {
|
||||
let state = placeDiscovery(
|
||||
ordered(),
|
||||
'discovery-rock-outcroppings',
|
||||
() => 0,
|
||||
);
|
||||
state = placeDiscovery(state, 'discovery-funeral-plain', () => 0);
|
||||
const hiereg = state.tokens.find(
|
||||
(token) => token.status === 'placed' && token.type === 'hiereg',
|
||||
)!;
|
||||
const smuggler = state.tokens.find(
|
||||
(token) => token.status === 'placed' && token.type === 'smuggler',
|
||||
)!;
|
||||
const publicView = projectDiscoveryState(state);
|
||||
assert.equal(publicView.supplyCount, 6);
|
||||
assert.deepEqual(
|
||||
publicView.tokens.map((token) => token.face),
|
||||
[null, null],
|
||||
);
|
||||
assert.equal('known' in publicView.tokens[0], false);
|
||||
assert.equal(projectDiscoveryState(state, 'fremen').tokens[0].face, hiereg.face);
|
||||
assert.equal(projectDiscoveryState(state, 'fremen').tokens[1].face, null);
|
||||
assert.equal(projectDiscoveryState(state, 'guild').tokens[0].face, null);
|
||||
assert.equal(
|
||||
projectDiscoveryState(state, 'guild').tokens[1].face,
|
||||
smuggler.face,
|
||||
);
|
||||
const informed = rememberDiscoveryFace(state, hiereg.id, 'atreides');
|
||||
assert.equal(
|
||||
projectDiscoveryState(informed, 'atreides').tokens[0].face,
|
||||
hiereg.face,
|
||||
);
|
||||
assert.equal(projectDiscoveryState(informed, 'harkonnen').tokens[0].face, null);
|
||||
const reordered = reload(state);
|
||||
reordered.tokens.reverse();
|
||||
assert.deepEqual(
|
||||
projectDiscoveryState(reordered).tokens
|
||||
.map((token) => ({ ...token, id: 'opaque' }))
|
||||
.sort((a, b) => String(a.type).localeCompare(String(b.type))),
|
||||
publicView.tokens
|
||||
.map((token) => ({ ...token, id: 'opaque' }))
|
||||
.sort((a, b) => String(a.type).localeCompare(String(b.type))),
|
||||
);
|
||||
});
|
||||
|
||||
void test('location reveal remains on the board, becomes public, and tracks one migration window', () => {
|
||||
const placed = placeDiscovery(
|
||||
ordered(),
|
||||
'discovery-rock-outcroppings',
|
||||
() => 0,
|
||||
);
|
||||
const token = placed.tokens.find((candidate) => candidate.status === 'placed')!;
|
||||
const before = reload(placed);
|
||||
const revealed = revealDiscoveryToken(placed, token.id, 4);
|
||||
assert.deepEqual(revealed.outcome, {
|
||||
kind: 'location',
|
||||
location: 'jacurutu-sietch',
|
||||
});
|
||||
const face = revealed.state.tokens.find((candidate) => candidate.id === token.id)!;
|
||||
assert.equal(face.status, 'placed');
|
||||
assert.equal(face.territory, 'meridian');
|
||||
assert.equal(face.sector, 1);
|
||||
assert.equal(face.revealedTurn, 4);
|
||||
assert.deepEqual(revealed.state.newlyRevealed, [token.id]);
|
||||
assert.equal(projectDiscoveryState(revealed.state).tokens[0].face, token.face);
|
||||
assert.deepEqual(placed, before);
|
||||
assert.throws(
|
||||
() => revealDiscoveryToken(revealed.state, token.id, 4),
|
||||
/hidden placed/,
|
||||
);
|
||||
const cleared = clearNewlyRevealed(revealed.state);
|
||||
assert.deepEqual(cleared.newlyRevealed, []);
|
||||
assert.equal(cleared.tokens.find((candidate) => candidate.id === token.id)!.revealedTurn, 4);
|
||||
validateDiscoveryState(reload(cleared));
|
||||
});
|
||||
|
||||
void test('stash outcomes remove one face while Ornithopter transfers and consumes exact custody', () => {
|
||||
const cases = [
|
||||
{
|
||||
face: 'treachery-card-stash' as const,
|
||||
draw: 0.3,
|
||||
outcome: { kind: 'treachery-card-stash' },
|
||||
},
|
||||
{
|
||||
face: 'spice-stash' as const,
|
||||
draw: 0.6,
|
||||
outcome: { kind: 'spice-stash', amount: 7 },
|
||||
},
|
||||
];
|
||||
for (const entry of cases) {
|
||||
const placed = placeDiscovery(
|
||||
ordered(),
|
||||
'discovery-funeral-plain',
|
||||
() => entry.draw,
|
||||
);
|
||||
const token = tokenWithFace(placed, entry.face);
|
||||
const result = revealDiscoveryToken(placed, token.id, 6);
|
||||
assert.deepEqual(result.outcome, entry.outcome);
|
||||
const removed = tokenWithFace(result.state, entry.face);
|
||||
assert.equal(removed.status, 'removed');
|
||||
assert.equal(removed.territory, null);
|
||||
assert.equal(removed.sector, null);
|
||||
assert.equal(removed.revealedTurn, 6);
|
||||
assert.equal(removed.acquiredTurn, 6);
|
||||
validateDiscoveryState(reload(result.state));
|
||||
}
|
||||
|
||||
const placed = placeDiscovery(
|
||||
ordered(),
|
||||
'discovery-funeral-plain',
|
||||
() => 0.999,
|
||||
);
|
||||
const ornithopter = tokenWithFace(placed, 'ornithopter');
|
||||
const before = reload(placed);
|
||||
assert.throws(
|
||||
() => revealDiscoveryToken(placed, ornithopter.id, 7),
|
||||
/receiving player/,
|
||||
);
|
||||
assert.deepEqual(placed, before);
|
||||
const carried = revealDiscoveryToken(placed, ornithopter.id, 7, 'player-a');
|
||||
assert.deepEqual(carried.outcome, {
|
||||
kind: 'ornithopter',
|
||||
owner: 'player-a',
|
||||
});
|
||||
assert.deepEqual(
|
||||
{
|
||||
status: tokenWithFace(carried.state, 'ornithopter').status,
|
||||
owner: tokenWithFace(carried.state, 'ornithopter').owner,
|
||||
acquiredTurn: tokenWithFace(carried.state, 'ornithopter').acquiredTurn,
|
||||
},
|
||||
{ status: 'carried', owner: 'player-a', acquiredTurn: 7 },
|
||||
);
|
||||
const consumed = consumeOrnithopter(carried.state, 'player-a');
|
||||
assert.equal(tokenWithFace(consumed, 'ornithopter').status, 'removed');
|
||||
assert.equal(tokenWithFace(consumed, 'ornithopter').owner, null);
|
||||
assert.throws(
|
||||
() => consumeOrnithopter(consumed, 'player-a'),
|
||||
/carry exactly one Ornithopter/,
|
||||
);
|
||||
validateDiscoveryState(reload(consumed));
|
||||
});
|
||||
|
||||
void test('malformed census, private knowledge, placement and migration state reject unchanged', () => {
|
||||
const mutations: ((state: DiscoveryState) => void)[] = [
|
||||
(state) => {
|
||||
state.tokens.pop();
|
||||
},
|
||||
(state) => {
|
||||
state.tokens[1].id = state.tokens[0].id;
|
||||
},
|
||||
(state) => {
|
||||
state.tokens[1].face = state.tokens[0].face;
|
||||
},
|
||||
(state) => {
|
||||
state.tokens[0].type = 'smuggler';
|
||||
},
|
||||
(state) => {
|
||||
state.tokens[0].known = ['unknown' as never];
|
||||
},
|
||||
(state) => {
|
||||
state.tokens[0].status = 'placed';
|
||||
},
|
||||
(state) => {
|
||||
state.tokens[0].owner = 'player-a';
|
||||
},
|
||||
(state) => {
|
||||
state.newlyRevealed.push('discovery-token-1');
|
||||
},
|
||||
(state) => {
|
||||
Object.assign(state.tokens[0], { leakedFace: 'secret' });
|
||||
},
|
||||
(state) => {
|
||||
state.version = 2 as 1;
|
||||
},
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const state = ordered();
|
||||
mutate(state);
|
||||
const before = JSON.stringify(state);
|
||||
assert.throws(() => validateDiscoveryState(state));
|
||||
let calls = 0;
|
||||
assert.throws(() =>
|
||||
placeDiscovery(state, 'discovery-hagga-basin', () => {
|
||||
calls++;
|
||||
return 0;
|
||||
}),
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(JSON.stringify(state), before);
|
||||
}
|
||||
|
||||
const frozen = placeDiscovery(
|
||||
ordered(),
|
||||
'discovery-rock-outcroppings',
|
||||
() => 0,
|
||||
);
|
||||
frozen.tokens.forEach((token) => {
|
||||
Object.freeze(token.known);
|
||||
Object.freeze(token);
|
||||
});
|
||||
Object.freeze(frozen.tokens);
|
||||
Object.freeze(frozen.newlyRevealed);
|
||||
Object.freeze(frozen);
|
||||
const next = rememberDiscoveryFace(
|
||||
frozen,
|
||||
frozen.tokens.find((token) => token.status === 'placed')!.id,
|
||||
'atreides',
|
||||
);
|
||||
validateDiscoveryState(next);
|
||||
assert.notEqual(next.tokens, frozen.tokens);
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
createDiscoveryState,
|
||||
JACURUTU_SIETCH,
|
||||
type DiscoveryState,
|
||||
} from '../game/discoveries';
|
||||
import {
|
||||
quoteGuildAmbassadorShipment,
|
||||
GuildAmbassadorShipmentError,
|
||||
} from '../game/guild-ambassador';
|
||||
import {
|
||||
quoteFremenAmbassadorMove,
|
||||
FremenAmbassadorMoveError,
|
||||
} from '../game/fremen-ambassador';
|
||||
import {
|
||||
quoteJunctionTransport,
|
||||
type JunctionTransportContext,
|
||||
} from '../game/junction-transport';
|
||||
import {
|
||||
quoteGuildHomeworldShipment,
|
||||
type GuildHomeworldShipmentContext,
|
||||
} from '../game/guild-homeworld-shipment';
|
||||
import {
|
||||
HomeworldCustodyError,
|
||||
type HomeworldCustody,
|
||||
} from '../game/homeworld-custody';
|
||||
import { createRicheseNoField } from '../game/richese-no-field';
|
||||
import {
|
||||
quoteNoFieldCancellation,
|
||||
NoFieldCancellationError,
|
||||
} from '../game/karama-no-field-cancellation';
|
||||
import { guildTransportQuote } from '../game/transport-quote';
|
||||
|
||||
const discoveryKey = `${JACURUTU_SIETCH}:0`;
|
||||
|
||||
function discoveries(revealed: boolean): DiscoveryState {
|
||||
const state = createDiscoveryState(() => 0);
|
||||
const token = state.tokens.find((entry) => entry.face === JACURUTU_SIETCH)!;
|
||||
Object.assign(token, {
|
||||
status: 'placed',
|
||||
territory: 'meridian',
|
||||
sector: 1,
|
||||
revealedTurn: revealed ? 2 : null,
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
function ambassadorGame(): Game {
|
||||
const g = createGame(
|
||||
'DISCOVERYADMISSION',
|
||||
newPlayer('p', 'Beneficiary', 'ecaz'),
|
||||
true,
|
||||
);
|
||||
g.players.push(
|
||||
newPlayer('q', 'Other', 'atreides'),
|
||||
newPlayer('r', 'Third', 'emperor'),
|
||||
);
|
||||
Object.assign(g, {
|
||||
status: 'playing',
|
||||
turn: 2,
|
||||
phase: 5,
|
||||
storm: 18,
|
||||
active: 'q',
|
||||
order: ['q', 'p', 'r'],
|
||||
});
|
||||
for (const player of g.players)
|
||||
Object.assign(player, { forces: {}, reserves: 20, hand: [] });
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('Ambassador shipments and relocations admit a Discovery location only after its public reveal', () => {
|
||||
const shipment: Action = {
|
||||
type: 'decision',
|
||||
event: 'ambassador-event',
|
||||
amount: 1,
|
||||
territory: JACURUTU_SIETCH,
|
||||
sector: 0,
|
||||
};
|
||||
const hiddenShipment = ambassadorGame();
|
||||
hiddenShipment.discoveries = discoveries(false);
|
||||
assert.throws(
|
||||
() => quoteGuildAmbassadorShipment(hiddenShipment, 'p', shipment),
|
||||
GuildAmbassadorShipmentError,
|
||||
);
|
||||
const publicShipment = ambassadorGame();
|
||||
publicShipment.discoveries = discoveries(true);
|
||||
assert.equal(
|
||||
quoteGuildAmbassadorShipment(publicShipment, 'p', shipment).territory,
|
||||
JACURUTU_SIETCH,
|
||||
);
|
||||
|
||||
const relocation: Action = {
|
||||
type: 'decision',
|
||||
event: 'ambassador-event',
|
||||
forces: { 'wind_pass:14': 1 },
|
||||
territory: JACURUTU_SIETCH,
|
||||
sector: 0,
|
||||
};
|
||||
const hiddenMove = ambassadorGame();
|
||||
hiddenMove.players[0].forces = { 'wind_pass:14': 1 };
|
||||
hiddenMove.players[0].reserves = 19;
|
||||
hiddenMove.discoveries = discoveries(false);
|
||||
assert.throws(
|
||||
() => quoteFremenAmbassadorMove(hiddenMove, 'p', relocation),
|
||||
FremenAmbassadorMoveError,
|
||||
);
|
||||
const publicMove = ambassadorGame();
|
||||
publicMove.players[0].forces = { 'wind_pass:14': 1 };
|
||||
publicMove.players[0].reserves = 19;
|
||||
publicMove.discoveries = discoveries(true);
|
||||
assert.equal(
|
||||
quoteFremenAmbassadorMove(publicMove, 'p', relocation).to,
|
||||
JACURUTU_SIETCH,
|
||||
);
|
||||
});
|
||||
|
||||
function junctionFixture(revealed: boolean): {
|
||||
context: JunctionTransportContext;
|
||||
custody: HomeworldCustody;
|
||||
} {
|
||||
return {
|
||||
context: {
|
||||
advanced: true,
|
||||
storm: 12,
|
||||
discoveries: discoveries(revealed),
|
||||
players: [
|
||||
{
|
||||
id: 'g',
|
||||
faction: 'guild',
|
||||
reserves: 15,
|
||||
eliteReserves: 0,
|
||||
ally: null,
|
||||
},
|
||||
{
|
||||
id: 'e',
|
||||
faction: 'emperor',
|
||||
reserves: 12,
|
||||
eliteReserves: 3,
|
||||
ally: null,
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
faction: 'atreides',
|
||||
reserves: 20,
|
||||
eliteReserves: 0,
|
||||
ally: null,
|
||||
},
|
||||
],
|
||||
board: {
|
||||
e: {
|
||||
forces: { 'false_wall_south:4': 3 },
|
||||
eliteForces: { 'false_wall_south:4': 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
custody: {
|
||||
visitors: { 'homeworld:atreides': { e: { normal: 2, elite: 1 } } },
|
||||
salusa: { normal: 0, elite: 3 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void test('Junction rejects a forged hidden Discovery destination and accepts its public projection', () => {
|
||||
const intent = {
|
||||
player: 'e',
|
||||
sponsor: 'g',
|
||||
destination: discoveryKey,
|
||||
rate: 'half' as const,
|
||||
sources: { 'false_wall_south:4': { normal: 2, elite: 1 } },
|
||||
};
|
||||
const hidden = junctionFixture(false);
|
||||
assert.throws(
|
||||
() => quoteJunctionTransport(hidden.context, hidden.custody, intent),
|
||||
HomeworldCustodyError,
|
||||
);
|
||||
const revealed = junctionFixture(true);
|
||||
assert.equal(
|
||||
quoteJunctionTransport(revealed.context, revealed.custody, intent)
|
||||
.destinationKind,
|
||||
'arrakis',
|
||||
);
|
||||
});
|
||||
|
||||
function guildFixture(revealed: boolean): {
|
||||
context: GuildHomeworldShipmentContext;
|
||||
custody: HomeworldCustody;
|
||||
} {
|
||||
return {
|
||||
context: {
|
||||
advanced: true,
|
||||
storm: 12,
|
||||
discoveries: discoveries(revealed),
|
||||
players: [
|
||||
{
|
||||
id: 'g',
|
||||
faction: 'guild',
|
||||
reserves: 13,
|
||||
eliteReserves: 0,
|
||||
ally: null,
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
faction: 'atreides',
|
||||
reserves: 20,
|
||||
eliteReserves: 0,
|
||||
ally: null,
|
||||
},
|
||||
],
|
||||
board: {
|
||||
g: {
|
||||
forces: { [discoveryKey]: 5 },
|
||||
eliteForces: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
custody: {
|
||||
visitors: { 'homeworld:atreides': {} },
|
||||
salusa: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void test('Guild Homeworld transport rejects hidden-location custody and accepts the revealed source', () => {
|
||||
const intent = {
|
||||
player: 'g',
|
||||
destination: 'homeworld:atreides',
|
||||
sources: { [discoveryKey]: { normal: 2, elite: 0 } },
|
||||
};
|
||||
const hidden = guildFixture(false);
|
||||
assert.throws(
|
||||
() => quoteGuildHomeworldShipment(hidden.context, hidden.custody, intent),
|
||||
HomeworldCustodyError,
|
||||
);
|
||||
const revealed = guildFixture(true);
|
||||
assert.equal(
|
||||
quoteGuildHomeworldShipment(revealed.context, revealed.custody, intent)
|
||||
.origin,
|
||||
JACURUTU_SIETCH,
|
||||
);
|
||||
});
|
||||
|
||||
void test('Guild transport preview uses the projected public reveal when validating its destination', () => {
|
||||
function preview(revealed: boolean) {
|
||||
const g = ambassadorGame();
|
||||
g.players[0].faction = 'guild';
|
||||
g.active = 'p';
|
||||
g.players[0].spice = 20;
|
||||
g.players[0].forces = { 'imperial_basin:10': 1 };
|
||||
g.players[0].reserves = 19;
|
||||
g.discoveryEnabled = true;
|
||||
g.discoveries = discoveries(revealed);
|
||||
return guildTransportQuote(viewGame(g, 'p'), {
|
||||
type: 'guildShip',
|
||||
from: 'imperial_basin:10',
|
||||
amount: 1,
|
||||
territory: JACURUTU_SIETCH,
|
||||
sector: 0,
|
||||
});
|
||||
}
|
||||
assert.ok(
|
||||
preview(false).unavailableReasons.includes(
|
||||
'Choose a sector belonging to the destination.',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
!preview(true).unavailableReasons.includes(
|
||||
'Choose a sector belonging to the destination.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
void test('No-Field cancellation cannot authenticate a declaration into an unrevealed Discovery location', () => {
|
||||
function declaration(revealed: boolean) {
|
||||
const g = createGame(
|
||||
'DISCOVERYNOFIELD',
|
||||
newPlayer('r', 'Richese', 'richese'),
|
||||
true,
|
||||
);
|
||||
Object.assign(g, {
|
||||
status: 'playing',
|
||||
phase: 5,
|
||||
turn: 2,
|
||||
active: 'r',
|
||||
discoveries: discoveries(revealed),
|
||||
pendingShipment: {
|
||||
turn: 2,
|
||||
player: 'r',
|
||||
territory: JACURUTU_SIETCH,
|
||||
sector: 0,
|
||||
amount: 1,
|
||||
elite: 0,
|
||||
cost: 0,
|
||||
allyPayment: 0,
|
||||
advisors: false,
|
||||
noField: { tokenId: 'opaque-zero', event: 'selection-2' },
|
||||
},
|
||||
response: { kind: 'richeseNoField', owner: 'r', passed: [] },
|
||||
});
|
||||
g.players[0].noField = createRicheseNoField([
|
||||
'opaque-zero',
|
||||
'opaque-three',
|
||||
'opaque-five',
|
||||
]);
|
||||
g.players[0].noFieldEvent = 'selection-2';
|
||||
return g;
|
||||
}
|
||||
const hidden = declaration(false);
|
||||
assert.throws(
|
||||
() => quoteNoFieldCancellation(hidden, hidden.response!),
|
||||
NoFieldCancellationError,
|
||||
);
|
||||
const revealed = declaration(true);
|
||||
assert.deepEqual(quoteNoFieldCancellation(revealed, revealed.response!), {
|
||||
kind: 'own',
|
||||
player: 'r',
|
||||
blockedTurn: 2,
|
||||
pendingShipment: null,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DISCOVERY_LOCATION_IDS,
|
||||
JACURUTU_SIETCH,
|
||||
type DiscoveryLocationId,
|
||||
} from '../game/discoveries';
|
||||
import {
|
||||
MOBILE_LOCATION,
|
||||
TERRITORIES,
|
||||
distance,
|
||||
gameDistance,
|
||||
gameTerritories,
|
||||
location,
|
||||
territory,
|
||||
validGameLocation,
|
||||
validLocation,
|
||||
type MobileBoard,
|
||||
} from '../game/board';
|
||||
import {
|
||||
BoardResolutionError,
|
||||
quoteBattleBoard,
|
||||
type BoardContext,
|
||||
} from '../game/board-resolution-quote';
|
||||
import { territoryEntryBlock } from '../game/occupancy';
|
||||
import { strongholdProgress } from '../game/victory-progress';
|
||||
import { quoteVictory, type VictoryContext } from '../game/victory-quote';
|
||||
|
||||
const INSIDE_JACURUTU = location(JACURUTU_SIETCH, 0);
|
||||
|
||||
function boardWithLocation(
|
||||
face: DiscoveryLocationId | null = JACURUTU_SIETCH,
|
||||
revealedTurn: number | null = 2,
|
||||
parent = 'meridian',
|
||||
sector = 1,
|
||||
): MobileBoard {
|
||||
return {
|
||||
discoveries: {
|
||||
tokens: [
|
||||
{
|
||||
face,
|
||||
status: 'placed',
|
||||
territory: parent,
|
||||
sector,
|
||||
revealedTurn,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void test('all five location identities are static board addresses without mutating the printed territory list', () => {
|
||||
const printed = TERRITORIES.length;
|
||||
for (const id of DISCOVERY_LOCATION_IDS) {
|
||||
assert.equal(territory(id).id, id);
|
||||
assert.equal(territory(id).type, 'stronghold');
|
||||
assert.deepEqual(territory(id).sectors, [0]);
|
||||
assert.equal(validLocation(id, 0), true);
|
||||
assert.equal(validLocation(id, 1), false);
|
||||
assert.equal(
|
||||
TERRITORIES.some((entry) => entry.id === id),
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert.equal(TERRITORIES.length, printed);
|
||||
});
|
||||
|
||||
void test('only publicly revealed placed locations join a room board', () => {
|
||||
const hidden = boardWithLocation(JACURUTU_SIETCH, null);
|
||||
Object.defineProperty(hidden.discoveries!.tokens[0], 'known', {
|
||||
get() {
|
||||
throw new Error('Private Discovery knowledge was read.');
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
gameTerritories(hidden).some((t) => t.id === JACURUTU_SIETCH),
|
||||
false,
|
||||
);
|
||||
assert.equal(validGameLocation(hidden, JACURUTU_SIETCH, 0), false);
|
||||
|
||||
const redacted = boardWithLocation(null, 2);
|
||||
assert.equal(
|
||||
gameTerritories(redacted).some((t) => t.id === JACURUTU_SIETCH),
|
||||
false,
|
||||
);
|
||||
|
||||
const revealed = boardWithLocation();
|
||||
assert.equal(
|
||||
gameTerritories(revealed).some((t) => t.id === JACURUTU_SIETCH),
|
||||
true,
|
||||
);
|
||||
assert.equal(validGameLocation(revealed, JACURUTU_SIETCH, 0), true);
|
||||
assert.equal(
|
||||
TERRITORIES.some((t) => t.id === JACURUTU_SIETCH),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
void test('a revealed location is one extra territory from every sector of its parent and remains a dead end', () => {
|
||||
const g = boardWithLocation();
|
||||
assert.equal(gameDistance(g, 'meridian:1', INSIDE_JACURUTU), 1);
|
||||
assert.equal(gameDistance(g, 'meridian:2', INSIDE_JACURUTU), 1);
|
||||
assert.equal(gameDistance(g, INSIDE_JACURUTU, 'meridian:1'), 1);
|
||||
assert.equal(gameDistance(g, INSIDE_JACURUTU, INSIDE_JACURUTU), 0);
|
||||
|
||||
const outside = 'polar_sink:0';
|
||||
const throughParent = Math.min(
|
||||
distance(outside, 'meridian:1'),
|
||||
distance(outside, 'meridian:2'),
|
||||
);
|
||||
assert.equal(gameDistance(g, outside, INSIDE_JACURUTU), throughParent + 1);
|
||||
assert.equal(gameDistance(g, INSIDE_JACURUTU, outside), throughParent + 1);
|
||||
assert.equal(
|
||||
gameDistance(
|
||||
boardWithLocation(JACURUTU_SIETCH, null),
|
||||
INSIDE_JACURUTU,
|
||||
INSIDE_JACURUTU,
|
||||
),
|
||||
Infinity,
|
||||
);
|
||||
});
|
||||
|
||||
void test('storm obstruction applies outside a Discovery endpoint and existing mobile routes compose with it', () => {
|
||||
const g = {
|
||||
...boardWithLocation(),
|
||||
mobileStronghold: { location: 'polar_sink:0' },
|
||||
};
|
||||
const stormOne = (key: string) => key.endsWith(':1');
|
||||
assert.equal(
|
||||
gameDistance(g, 'meridian:1', INSIDE_JACURUTU, stormOne),
|
||||
Infinity,
|
||||
);
|
||||
assert.equal(gameDistance(g, 'meridian:2', INSIDE_JACURUTU, stormOne), 1);
|
||||
assert.equal(gameDistance(g, INSIDE_JACURUTU, 'meridian:2', stormOne), 1);
|
||||
assert.equal(
|
||||
gameDistance(g, INSIDE_JACURUTU, INSIDE_JACURUTU, () => true),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
gameDistance(g, MOBILE_LOCATION, INSIDE_JACURUTU),
|
||||
2 +
|
||||
Math.min(
|
||||
distance('polar_sink:0', 'meridian:1'),
|
||||
distance('polar_sink:0', 'meridian:2'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
function battleBoard(revealedTurn: number | null): BoardContext {
|
||||
return {
|
||||
...boardWithLocation(JACURUTU_SIETCH, revealedTurn),
|
||||
advanced: false,
|
||||
storm: 18,
|
||||
order: ['a', 'e'],
|
||||
players: [
|
||||
{
|
||||
id: 'a',
|
||||
faction: 'atreides',
|
||||
ally: null,
|
||||
forces: { [INSIDE_JACURUTU]: 1 },
|
||||
},
|
||||
{
|
||||
id: 'e',
|
||||
faction: 'emperor',
|
||||
ally: null,
|
||||
forces: { [INSIDE_JACURUTU]: 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
void test('public board resolution accepts revealed forces, rejects hidden-location forces and reuses stronghold occupancy', () => {
|
||||
assert.deepEqual(quoteBattleBoard(battleBoard(2)).battles, [
|
||||
{ territory: JACURUTU_SIETCH, attacker: 'a', defender: 'e' },
|
||||
]);
|
||||
assert.throws(
|
||||
() => quoteBattleBoard(battleBoard(null)),
|
||||
BoardResolutionError,
|
||||
);
|
||||
const nestedPointer = battleBoard(2);
|
||||
nestedPointer.mobileStronghold = { location: INSIDE_JACURUTU };
|
||||
assert.throws(() => quoteBattleBoard(nestedPointer), BoardResolutionError);
|
||||
|
||||
const players = [
|
||||
{
|
||||
id: 'a',
|
||||
faction: 'atreides' as const,
|
||||
ally: null,
|
||||
forces: { [INSIDE_JACURUTU]: 1 },
|
||||
},
|
||||
{
|
||||
id: 'e',
|
||||
faction: 'emperor' as const,
|
||||
ally: null,
|
||||
forces: { [INSIDE_JACURUTU]: 1 },
|
||||
},
|
||||
{ id: 'h', faction: 'harkonnen' as const, ally: null, forces: {} },
|
||||
];
|
||||
assert.match(
|
||||
territoryEntryBlock(players, 'h', JACURUTU_SIETCH) ?? '',
|
||||
/three occupying factions/,
|
||||
);
|
||||
});
|
||||
|
||||
type ProgressContext = Parameters<typeof strongholdProgress>[0];
|
||||
function progressWith(face: DiscoveryLocationId): ProgressContext {
|
||||
const parent =
|
||||
face === 'orgiz-processing-station' ? 'plastic_basin' : 'meridian';
|
||||
const sector = face === 'orgiz-processing-station' ? 13 : 1;
|
||||
return {
|
||||
...boardWithLocation(face, 2, parent, sector),
|
||||
advanced: false,
|
||||
storm: 18,
|
||||
order: ['a', 'e', 'h'],
|
||||
players: [
|
||||
{
|
||||
id: 'a',
|
||||
faction: 'atreides',
|
||||
ally: null,
|
||||
forces: {
|
||||
'arrakeen:10': 1,
|
||||
'carthag:11': 1,
|
||||
[location(face, 0)]: 1,
|
||||
},
|
||||
},
|
||||
{ id: 'e', faction: 'emperor', ally: null, forces: {} },
|
||||
{ id: 'h', faction: 'harkonnen', ally: null, forces: {} },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
void test('Jacurutu alone counts as a Discovery stronghold for normal and Ecaz victory progress', () => {
|
||||
for (const id of DISCOVERY_LOCATION_IDS) {
|
||||
const ordinary = progressWith(id);
|
||||
const row = strongholdProgress(ordinary).progress[0];
|
||||
assert.equal(row.strongholds.includes(id), id === JACURUTU_SIETCH, id);
|
||||
assert.equal(row.qualifies, id === JACURUTU_SIETCH, id);
|
||||
|
||||
const ecaz = progressWith(id);
|
||||
ecaz.players[0].faction = 'ecaz';
|
||||
ecaz.players[0].ally = 'e';
|
||||
ecaz.players[1].ally = 'a';
|
||||
ecaz.players[1].forces = { ...ecaz.players[0].forces };
|
||||
const joint = strongholdProgress(ecaz).progress[0];
|
||||
assert.equal(
|
||||
joint.jointlyOccupied.includes(id),
|
||||
id === JACURUTU_SIETCH,
|
||||
id,
|
||||
);
|
||||
assert.equal(joint.qualifies, id === JACURUTU_SIETCH, id);
|
||||
}
|
||||
});
|
||||
|
||||
function finalTurnWith(face: DiscoveryLocationId): VictoryContext {
|
||||
const progress = progressWith(face);
|
||||
progress.order = ['a', 'e'];
|
||||
progress.players = progress.players.slice(0, 2);
|
||||
progress.players[1].forces = {
|
||||
'sietch_tabr:14': 1,
|
||||
'tueks_sietch:5': 1,
|
||||
};
|
||||
return {
|
||||
...progress,
|
||||
turn: 10,
|
||||
phase: 8,
|
||||
status: 'playing',
|
||||
winner: [],
|
||||
techTokens: null,
|
||||
strongholdCards: null,
|
||||
};
|
||||
}
|
||||
|
||||
void test('final-turn hold scoring includes Jacurutu and excludes the other four locations', () => {
|
||||
assert.deepEqual(quoteVictory(finalTurnWith('cistern')).winner, ['a', 'e']);
|
||||
assert.deepEqual(quoteVictory(finalTurnWith(JACURUTU_SIETCH)).winner, ['a']);
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import {
|
||||
applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Game, type GameView,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
DISCOVERY_TOKENS, placeDiscovery, rememberDiscoveryFace, revealDiscoveryToken,
|
||||
type DiscoveryTokenFace,
|
||||
} from '../game/discoveries';
|
||||
import { discoveryAction, discoveryBotActions, discoveryDiscardAction } from '../game/discovery-options';
|
||||
import { DiscoveryDiscardDecision, DiscoveryPanel } from '../components/discoveries';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
const html = (game: GameView, busy = false) => renderToStaticMarkup(createElement(DiscoveryPanel, { game, busy, act() {} }));
|
||||
const discardHtml = (game: GameView, busy = false) => renderToStaticMarkup(createElement(DiscoveryDiscardDecision, { game, busy, act() {} }));
|
||||
|
||||
/** Genuine setup, then conserved board/card staging for Collection controls. */
|
||||
function fixture() {
|
||||
let game = createGame('DISCOVERYCONTROLS', newPlayer('p', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer('f', 'Fremen', 'fremen'));
|
||||
joinGame(game, newPlayer('g', 'Guild', 'guild'));
|
||||
game.discoveryEnabled = true;
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = initializeDiscoveryGameForAudit(game);
|
||||
for (let count = 0; game.status === 'setup' && count < 50; count++) {
|
||||
const owner = viewGame(game, 'p').setupPending[0];
|
||||
assert.ok(owner);
|
||||
const view = viewGame(game, owner);
|
||||
view.players.find(player => player.id === owner)!.bot = 'Hard';
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action);
|
||||
game = applyAction(game, owner, action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
Object.assign(game, { phase: 7, turn: 2, active: null, ready: [], response: null, decision: null, phaseOpening: null });
|
||||
for (const player of game.players) {
|
||||
game.deck.push(...player.hand.splice(0));
|
||||
player.forces = {};
|
||||
player.reserves = 20;
|
||||
player.tanks = 0;
|
||||
}
|
||||
game.players[0].forces = { 'gara_kulon:8': 1, 'pasty_mesa:7': 1 };
|
||||
game.players[0].reserves = 18;
|
||||
return game;
|
||||
}
|
||||
|
||||
function place(game: Game, face: DiscoveryTokenFace, known = false) {
|
||||
const token = game.discoveries!.tokens.find(token => token.face === face)!;
|
||||
const pool = game.discoveries!.tokens.filter(candidate => candidate.type === token.type && candidate.status === 'supply');
|
||||
const index = pool.findIndex(candidate => candidate.id === token.id);
|
||||
assert.ok(index >= 0);
|
||||
game.discoveries = placeDiscovery(game.discoveries!,
|
||||
token.type === 'hiereg' ? 'discovery-hagga-basin' : 'discovery-funeral-plain',
|
||||
() => (index + 0.5) / pool.length);
|
||||
if (known) game.discoveries = rememberDiscoveryFace(game.discoveries, token.id, 'atreides');
|
||||
return token.id;
|
||||
}
|
||||
|
||||
void test('unknown backs expose only their type and placement; shared choices bind the quoted opaque token', () => {
|
||||
const game = fixture(), hidden = place(game, 'spice-stash'), known = place(game, 'cistern', true);
|
||||
const view = viewGame(game, 'p'), before = structuredClone(view);
|
||||
assert.deepEqual(discoveryAction(view, hidden, false), { type: 'discovery', token: hidden, reveal: false });
|
||||
assert.equal(discoveryAction(view, hidden, true), null);
|
||||
assert.equal(discoveryAction(view, known, false), null);
|
||||
assert.deepEqual(discoveryAction(view, known, true), { type: 'discovery', token: known, reveal: true });
|
||||
assert.equal(discoveryAction(view, 'discovery-token-99', false), null);
|
||||
const markup = html(view);
|
||||
assert.match(markup, /Smuggler discovery/);
|
||||
assert.match(markup, /Face down · unknown to you/);
|
||||
assert.match(markup, /Pasty Mesa · sector 7/);
|
||||
assert.doesNotMatch(markup, /Spice Stash|Receive seven spice/);
|
||||
assert.match(markup, /Read Cistern rules/);
|
||||
assert.match(markup, /Inspect Smuggler token privately/);
|
||||
assert.match(markup, /Reveal Cistern to everyone/);
|
||||
assert.deepEqual(discoveryBotActions(view).map(action => action.reveal), [false, true]);
|
||||
assert.deepEqual(view, before);
|
||||
});
|
||||
|
||||
void test('Fremen and Guild privileged faces remain distinct from another seat’s private inspection', () => {
|
||||
const game = fixture();
|
||||
place(game, 'cistern');
|
||||
place(game, 'spice-stash');
|
||||
const fremen = viewGame(game, 'f'), guild = viewGame(game, 'g'), atreides = viewGame(game, 'p');
|
||||
assert.match(html(fremen), /Read Cistern rules/);
|
||||
assert.doesNotMatch(html(fremen), /Read Spice Stash rules/);
|
||||
assert.match(html(guild), /Read Spice Stash rules/);
|
||||
assert.doesNotMatch(html(guild), /Read Cistern rules/);
|
||||
assert.doesNotMatch(html(atreides), /Read (Cistern|Spice Stash) rules/);
|
||||
assert.deepEqual(discoveryBotActions(fremen), []);
|
||||
assert.deepEqual(discoveryBotActions(guild), []);
|
||||
});
|
||||
|
||||
void test('all eight authorized faces have readable rules while shared location rules preserve parent-territory meaning', () => {
|
||||
const game = fixture();
|
||||
for (const definition of DISCOVERY_TOKENS) place(game, definition.id, true);
|
||||
const markup = html(viewGame(game, 'p'));
|
||||
for (const definition of DISCOVERY_TOKENS) assert.ok(markup.includes(`Read ${definition.name} rules`), definition.name);
|
||||
for (const text of ['opposing undialed force', 'two spice from the bank', 'Weather Control',
|
||||
'use a Truthtrance card as Karama', 'each spice blow that is collected', 'including the new card',
|
||||
'Receive seven spice', 'On a later turn', 'separate territory inside', 'At most two factions',
|
||||
'protected from storms and sandworms', 'before the storm and the mobile stronghold move']) assert.ok(markup.includes(text), text);
|
||||
assert.doesNotMatch(markup, /href="https?:/);
|
||||
assert.match(markup, /still in development/);
|
||||
});
|
||||
|
||||
void test('all four AI profiles inspect and then reveal through fresh private views and actual saved actions', () => {
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
let game = fixture();
|
||||
const token = place(game, 'spice-stash'), before = game.players[0].spice;
|
||||
for (const reveal of [false, true]) {
|
||||
const view = viewGame(game, 'p');
|
||||
view.players[0].bot = difficulty;
|
||||
const preserved = structuredClone(view), actions = discoveryBotActions(view);
|
||||
assert.deepEqual(actions, [{ type: 'discovery', token, reveal }]);
|
||||
for (const action of actions) assert.doesNotThrow(() => applyAction(reload(game), 'p', action));
|
||||
assert.deepEqual(view, preserved);
|
||||
game = applyAction(reload(game), 'p', actions[0]);
|
||||
if (!reveal) {
|
||||
assert.equal(game.players[0].spice, before);
|
||||
assert.doesNotMatch(html(viewGame(game, 'f')), /Read Spice Stash rules/);
|
||||
}
|
||||
}
|
||||
assert.equal(game.players[0].spice, before + 7);
|
||||
assert.equal(game.discoveries!.tokens.find(candidate => candidate.id === token)!.status, 'removed');
|
||||
assert.deepEqual(discoveryBotActions(viewGame(game, 'p')), []);
|
||||
assert.throws(() => applyAction(game, 'p', { type: 'discovery', token, reveal: true }));
|
||||
}
|
||||
});
|
||||
|
||||
void test('carried and resolved tokens show public custody without a repeated reveal action', () => {
|
||||
const game = fixture(), carried = place(game, 'ornithopter'), removed = place(game, 'spice-stash');
|
||||
game.discoveries = revealDiscoveryToken(game.discoveries!, carried, game.turn, 'p').state;
|
||||
game.discoveries = revealDiscoveryToken(game.discoveries, removed, game.turn, 'p').state;
|
||||
assert.match(html(viewGame(game, 'p')), /Carried by you/);
|
||||
const observer = html(viewGame(game, 'f'));
|
||||
assert.match(observer, /Carried by Atreides/);
|
||||
assert.match(observer, /Resolved and removed/);
|
||||
assert.doesNotMatch(observer, /Reveal Ornithopter to everyone|Reveal Spice Stash to everyone/);
|
||||
});
|
||||
|
||||
void test('a full-hand stash offers every owned card after its real draw, with private inspection and Worthless-first AI', () => {
|
||||
let game = fixture();
|
||||
const stash = place(game, 'treachery-card-stash', true), owner = game.players[0];
|
||||
for (const kind of ['shield', 'projectile', 'poison', 'worthless']) {
|
||||
const index = game.deck.findIndex(card => card.kind === kind);
|
||||
assert.ok(index >= 0);
|
||||
owner.hand.push(game.deck.splice(index, 1)[0]);
|
||||
}
|
||||
const newCard = game.deck[0].id, worthless = owner.hand.find(card => card.kind === 'worthless')!.id;
|
||||
game = applyAction(game, 'p', discoveryAction(viewGame(game, 'p'), stash, true)!);
|
||||
assert.equal(game.decision?.kind, 'discoveryDiscard');
|
||||
assert.equal(game.players[0].hand.length, 5);
|
||||
const view = viewGame(game, 'p'), actions = discoveryBotActions(view), before = structuredClone(view);
|
||||
assert.equal(actions.length, 5);
|
||||
assert.equal(actions[0].card, worthless);
|
||||
assert.equal(discoveryDiscardAction(view, 'not-owned'), null);
|
||||
assert.ok(discoveryDiscardAction(view, newCard));
|
||||
for (const action of actions) {
|
||||
const next = applyAction(reload(game), 'p', action);
|
||||
assert.equal(next.players[0].hand.length, 4);
|
||||
assert.equal(next.discard.filter(card => card.id === action.card).length, 1);
|
||||
assert.equal(next.discoveryStash?.stage, 'complete');
|
||||
assert.equal(discoveryDiscardAction(viewGame(next, 'p'), String(action.card)), null);
|
||||
}
|
||||
const markup = discardHtml(view);
|
||||
for (const card of game.players[0].hand) assert.ok(markup.includes(card.name), card.name);
|
||||
assert.match(markup, /Card to discard/);
|
||||
assert.match(markup, /Inspect card/);
|
||||
assert.match(markup, /new card is already in your hand/);
|
||||
const observer = viewGame(game, 'f');
|
||||
assert.equal(discardHtml(observer), '');
|
||||
assert.deepEqual(discoveryBotActions(observer), []);
|
||||
assert.match(discardHtml(view, true).match(/<select[^>]*>/)![0], /disabled=""/);
|
||||
const autopilot = structuredClone(view); autopilot.players[0].autopilot = 'Hard';
|
||||
assert.match(discardHtml(autopilot).match(/<select[^>]*>/)![0], /disabled=""/);
|
||||
assert.deepEqual(view, before);
|
||||
});
|
||||
|
||||
void test('quoted blocking and human busy or autopilot controls retain readable tokens without offering actions', () => {
|
||||
const game = fixture(), token = place(game, 'cistern', true), view = viewGame(game, 'p');
|
||||
for (const busy of [true, false]) {
|
||||
const locked = structuredClone(view);
|
||||
if (!busy) locked.players[0].autopilot = 'Medium';
|
||||
const button = html(locked, busy).match(/<button[^>]*>Reveal Cistern to everyone<\/button>/)![0];
|
||||
assert.match(button, /disabled=""/);
|
||||
assert.match(html(locked, busy), /Read Cistern rules/);
|
||||
}
|
||||
const blocked = structuredClone(view);
|
||||
blocked.discoveries!.blocked = 'Finish the current interaction first.';
|
||||
assert.equal(discoveryAction(blocked, token, true), null);
|
||||
assert.deepEqual(discoveryBotActions(blocked), []);
|
||||
assert.match(html(blocked), /Finish the current interaction first/);
|
||||
assert.doesNotMatch(html(blocked), /Reveal Cistern to everyone/);
|
||||
const later = structuredClone(view); later.phase = 8;
|
||||
assert.equal(discoveryAction(later, token, true), null);
|
||||
assert.deepEqual(discoveryBotActions(later), []);
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runBots } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { baseDeck, spiceDeck } from '../game/cards';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import {
|
||||
DISCOVERY_SPICE_CARDS,
|
||||
DISCOVERY_TOKENS,
|
||||
validateDiscoveryState,
|
||||
} from '../game/discoveries';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeDiscoveryGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
|
||||
const ROSTER: [string, FactionId][] = [
|
||||
['a', 'atreides'],
|
||||
['h', 'harkonnen'],
|
||||
['g', 'guild'],
|
||||
['f', 'fremen'],
|
||||
];
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
|
||||
|
||||
function generator(seed: number) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let word = Math.imul(state ^ (state >>> 15), state | 1);
|
||||
word ^= word + Math.imul(word ^ (word >>> 7), word | 61);
|
||||
return (word ^ (word >>> 14)) >>> 0;
|
||||
};
|
||||
}
|
||||
|
||||
function withEngineSeed<T>(seed: number, run: () => T): T {
|
||||
const cryptoObject = globalThis.crypto;
|
||||
const originalRandom = Object.getOwnPropertyDescriptor(
|
||||
cryptoObject,
|
||||
'getRandomValues',
|
||||
);
|
||||
const originalUuid = Object.getOwnPropertyDescriptor(
|
||||
cryptoObject,
|
||||
'randomUUID',
|
||||
);
|
||||
const randomWord = generator(seed);
|
||||
const uuidWord = generator(seed ^ 0xa5a5a5a5);
|
||||
Object.defineProperty(cryptoObject, 'getRandomValues', {
|
||||
configurable: true,
|
||||
value: <T extends ArrayBufferView | null>(array: T): T => {
|
||||
if (!(array instanceof Uint32Array))
|
||||
throw new Error(
|
||||
`Discovery playthrough seed ${seed} supports only the engine Uint32Array RNG contract.`,
|
||||
);
|
||||
for (let index = 0; index < array.length; index++)
|
||||
array[index] = randomWord();
|
||||
return array;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(cryptoObject, 'randomUUID', {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
const hex = Array.from({ length: 4 }, () =>
|
||||
uuidWord().toString(16).padStart(8, '0'),
|
||||
)
|
||||
.join('')
|
||||
.split('');
|
||||
hex[12] = '4';
|
||||
hex[16] = ((parseInt(hex[16], 16) & 3) | 8).toString(16);
|
||||
const id = hex.join('');
|
||||
return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
|
||||
},
|
||||
});
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
if (originalRandom)
|
||||
Object.defineProperty(cryptoObject, 'getRandomValues', originalRandom);
|
||||
else Reflect.deleteProperty(cryptoObject, 'getRandomValues');
|
||||
if (originalUuid)
|
||||
Object.defineProperty(cryptoObject, 'randomUUID', originalUuid);
|
||||
else Reflect.deleteProperty(cryptoObject, 'randomUUID');
|
||||
}
|
||||
}
|
||||
|
||||
function discoveryLobby(advanced: boolean): Game {
|
||||
const [first, ...rest] = ROSTER;
|
||||
let game = createGame(
|
||||
'DISCOVERYPLAYTHROUGH',
|
||||
newPlayer(first[0], first[0], first[1]),
|
||||
advanced,
|
||||
);
|
||||
for (const [id, faction] of rest) joinGame(game, newPlayer(id, id, faction));
|
||||
for (const player of game.players)
|
||||
game = applyAction(game, player.id, { type: 'ready' });
|
||||
game.discoveryEnabled = true;
|
||||
return game;
|
||||
}
|
||||
|
||||
function finishGame(initial: Game, seed: number): Game {
|
||||
let game = initial;
|
||||
for (let batch = 0; batch < 240 && game.status !== 'finished'; batch++) {
|
||||
game = runBots(reload(game));
|
||||
assert.ok(
|
||||
game.status === 'finished' || game.botsPending,
|
||||
`Discovery playthrough seed ${seed} stalled at turn ${game.turn}, phase ${game.phase}, setup ${game.setupStage ?? 'complete'}, decision ${game.decision?.kind ?? 'none'}.`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
game.status,
|
||||
'finished',
|
||||
`Discovery playthrough seed ${seed} did not finish.`,
|
||||
);
|
||||
assert.ok(
|
||||
game.winner.length > 0,
|
||||
`Discovery playthrough seed ${seed} has no winner.`,
|
||||
);
|
||||
return reload(game);
|
||||
}
|
||||
|
||||
function assertTreacheryInventory(game: Game, seed: number) {
|
||||
const cards = [
|
||||
...game.deck,
|
||||
...game.discard,
|
||||
...game.players.flatMap((player) => player.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? []),
|
||||
];
|
||||
const expected = baseDeck()
|
||||
.map((card) => card.id)
|
||||
.sort();
|
||||
assert.equal(
|
||||
cards.length,
|
||||
expected.length,
|
||||
`Treachery cards in seed ${seed}`,
|
||||
);
|
||||
assert.equal(
|
||||
new Set(cards.map((card) => card.id)).size,
|
||||
expected.length,
|
||||
`Distinct Treachery cards in seed ${seed}`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
cards.map((card) => card.id).sort(),
|
||||
expected,
|
||||
`Treachery identities in seed ${seed}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertForceInventory(game: Game, seed: number) {
|
||||
for (const player of game.players)
|
||||
assert.equal(
|
||||
player.reserves +
|
||||
player.tanks +
|
||||
Object.values(player.forces).reduce((sum, count) => sum + count, 0),
|
||||
20,
|
||||
`${player.id} force inventory in seed ${seed}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertDiscoveryInventory(game: Game, seed: number) {
|
||||
validateDiscoveryState(game.discoveries!);
|
||||
assert.equal(
|
||||
game.discoveries!.tokens.length,
|
||||
DISCOVERY_TOKENS.length,
|
||||
`Discovery tokens in seed ${seed}`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
game.discoveries!.tokens.map((token) => token.face).sort(),
|
||||
DISCOVERY_TOKENS.map((token) => token.id).sort(),
|
||||
`Discovery token faces in seed ${seed}`,
|
||||
);
|
||||
|
||||
const physicalSpiceCards = [
|
||||
...game.spiceDeck,
|
||||
...game.spiceDiscard.flat(),
|
||||
].filter((card) => !('worm' in card && card.thumper));
|
||||
assert.equal(
|
||||
physicalSpiceCards.length + Number(game.sandtrout),
|
||||
spiceDeck().length + 7,
|
||||
`Spice Card inventory in seed ${seed}`,
|
||||
);
|
||||
const discoveryCards = physicalSpiceCards.flatMap((card) =>
|
||||
'territory' in card && card.discovery ? [card.discovery] : [],
|
||||
);
|
||||
assert.deepEqual(
|
||||
discoveryCards.sort(),
|
||||
DISCOVERY_SPICE_CARDS.map((card) => card.discovery).sort(),
|
||||
`Discovery Spice Cards in seed ${seed}`,
|
||||
);
|
||||
assert.equal(
|
||||
physicalSpiceCards.filter((card) => 'worm' in card && card.greatMaker)
|
||||
.length,
|
||||
1,
|
||||
`Great Maker card in seed ${seed}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [advanced, seed] of [
|
||||
[false, 2026091303],
|
||||
[true, 2026091304],
|
||||
] as const)
|
||||
void test(`genuine ${advanced ? 'Advanced' : 'Basic'} Discovery seed ${seed} completes through JSON bot batches with conserved pieces`, () =>
|
||||
withEngineSeed(seed, () => {
|
||||
const lobby = discoveryLobby(advanced);
|
||||
const publicBefore = structuredClone(lobby);
|
||||
assert.throws(() => applyAction(lobby, lobby.host, { type: 'start' }));
|
||||
assert.deepEqual(lobby, publicBefore);
|
||||
|
||||
let game = initializeDiscoveryGameForAudit(lobby);
|
||||
game.players.forEach((player, index) => {
|
||||
player.bot = DIFFICULTIES[index];
|
||||
});
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.equal(game.expansions.length, 0);
|
||||
assert.equal(game.discoveryEnabled, true);
|
||||
assert.equal(game.discoveries?.tokens.length, DISCOVERY_TOKENS.length);
|
||||
assert.equal(game.spiceDeck.length, spiceDeck().length + 7);
|
||||
|
||||
game = finishGame(game, seed);
|
||||
assertTreacheryInventory(game, seed);
|
||||
assertForceInventory(game, seed);
|
||||
assertDiscoveryInventory(game, seed);
|
||||
}));
|
||||
@@ -0,0 +1,182 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import {
|
||||
discoveryFixture,
|
||||
enterDiscoveryCollection,
|
||||
} from './fixture-discovery';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { discoveryStashSignature } from '../game/discovery-actions';
|
||||
import { startPrototypeRoom } from '../tools/prototype-room';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
void test('SQLite Discovery prototype preserves seats and competing stash reveals pay once; saved overflow discard resumes exactly once', async () => {
|
||||
const store = unitStore();
|
||||
try {
|
||||
const made = await store.rooms.createRoom(
|
||||
'Discovery SQL',
|
||||
'atreides',
|
||||
false,
|
||||
[],
|
||||
),
|
||||
code = made.view.code;
|
||||
const tokens = [made.token];
|
||||
for (const faction of ['guild', 'fremen'] as const)
|
||||
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
const auths = await Promise.all(
|
||||
tokens.map((token) => store.rooms.authenticate(code, token)),
|
||||
);
|
||||
const ids = auths.map((auth) => auth.playerId) as [string, string, string];
|
||||
let lobby = await store.rooms.readRoom(code);
|
||||
for (const p of lobby.players)
|
||||
lobby = applyAction(lobby, p.id, { type: 'ready' });
|
||||
store.sqlite
|
||||
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
||||
.run(JSON.stringify(lobby), code);
|
||||
const seats = store.sqlite
|
||||
.prepare('SELECT * FROM seats ORDER BY player_id')
|
||||
.all();
|
||||
const begun = startPrototypeRoom(
|
||||
store.sqlite,
|
||||
code,
|
||||
lobby.version,
|
||||
'discovery',
|
||||
);
|
||||
assert.equal(begun.status, 'setup');
|
||||
assert.equal(begun.version, lobby.version + 1);
|
||||
assert.throws(() =>
|
||||
startPrototypeRoom(store.sqlite, code, begun.version, 'discovery'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
|
||||
seats,
|
||||
);
|
||||
let g = discoveryFixture(false, ids);
|
||||
g.code = code;
|
||||
g.version = begun.version;
|
||||
const stash = enterDiscoveryCollection(g, 'spice-stash');
|
||||
g = applyAction(g, ids[0], {
|
||||
type: 'discovery',
|
||||
token: stash.id,
|
||||
reveal: false,
|
||||
});
|
||||
store.sqlite
|
||||
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(g), g.version, code);
|
||||
let arrivals = 0,
|
||||
release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
store.hooks.beforeWrite = async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await gate;
|
||||
};
|
||||
const timer = setTimeout(release, 2000);
|
||||
const action = { type: 'discovery', token: stash.id, reveal: true };
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
[0, 1].map(() =>
|
||||
store.restart().act(code, auths[0], g.version, action, clock),
|
||||
),
|
||||
);
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
delete store.hooks.beforeWrite;
|
||||
}
|
||||
const done = await store.restart().readRoom(code);
|
||||
assert.equal(done.players[0].spice, g.players[0].spice + 7);
|
||||
assert.equal(done.version, g.version + 1);
|
||||
await assert.rejects(
|
||||
store.restart().act(code, auths[0], done.version, action, clock),
|
||||
);
|
||||
for (const [i, token] of tokens.entries()) {
|
||||
const rooms = store.restart(),
|
||||
auth = await rooms.authenticate(code, token);
|
||||
assert.deepEqual(
|
||||
await rooms.readSeatView(code, auth),
|
||||
viewGame(done, ids[i]),
|
||||
);
|
||||
}
|
||||
g = done;
|
||||
const cardToken = enterDiscoveryCollection(g, 'treachery-card-stash');
|
||||
while (g.players[0].hand.length < 4)
|
||||
g.players[0].hand.push(g.deck.shift()!);
|
||||
g = applyAction(g, ids[0], {
|
||||
type: 'discovery',
|
||||
token: cardToken.id,
|
||||
reveal: false,
|
||||
});
|
||||
g = applyAction(g, ids[0], {
|
||||
type: 'discovery',
|
||||
token: cardToken.id,
|
||||
reveal: true,
|
||||
});
|
||||
store.sqlite
|
||||
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
||||
.run(JSON.stringify(g), code);
|
||||
const restored = await store.restart().readRoom(code);
|
||||
assert.equal(restored.players[0].hand.length, 5);
|
||||
assert.equal(restored.decision?.kind, 'discoveryDiscard');
|
||||
const discard = restored.players[0].hand.at(-1)!;
|
||||
await store
|
||||
.restart()
|
||||
.act(
|
||||
code,
|
||||
auths[0],
|
||||
restored.version,
|
||||
{
|
||||
type: 'decision',
|
||||
event: restored.discoveryStash!.event,
|
||||
card: discard.id,
|
||||
},
|
||||
clock,
|
||||
);
|
||||
const finished = await store.restart().readRoom(code);
|
||||
assert.equal(finished.discoveryStash!.stage, 'complete');
|
||||
assert.equal(finished.players[0].hand.length, 4);
|
||||
const paused = JSON.parse(JSON.stringify(finished)) as Game,
|
||||
record = paused.discoveryStash!;
|
||||
record.stage = 'discard';
|
||||
record.signature = discoveryStashSignature(record);
|
||||
const sequence = paused.treacheryDiscardSequence!;
|
||||
paused.resolvedTreacheryDiscardSequence = sequence - 1;
|
||||
paused.pendingTreacheryDiscard = {
|
||||
sequence,
|
||||
batch: {
|
||||
event: `discard:${paused.turn}:${paused.phase}:${sequence}`,
|
||||
turn: paused.turn,
|
||||
phase: paused.phase,
|
||||
cause: 'discovery:stash',
|
||||
entries: [{ card: discard, discardedBy: ids[0], publicFace: true }],
|
||||
},
|
||||
continuation: {
|
||||
kind: 'discoveryStash',
|
||||
event: record.event,
|
||||
owner: ids[0],
|
||||
card: discard.id,
|
||||
},
|
||||
};
|
||||
store.sqlite
|
||||
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
||||
.run(JSON.stringify(paused), code);
|
||||
await store.restart().continueRoomAutomatic(code, clock);
|
||||
const resumed = await store.restart().readRoom(code);
|
||||
assert.equal(resumed.discoveryStash!.stage, 'complete');
|
||||
assert.deepEqual(resumed.players, finished.players);
|
||||
assert.deepEqual(resumed.deck, finished.deck);
|
||||
assert.deepEqual(resumed.discard, finished.discard);
|
||||
const settled = JSON.stringify(resumed);
|
||||
await store.restart().continueRoomAutomatic(code, clock);
|
||||
assert.equal(JSON.stringify(await store.restart().readRoom(code)), settled);
|
||||
assert.deepEqual(
|
||||
store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
|
||||
seats,
|
||||
);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,478 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeDiscoveryGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import type { Card, SpiceCard } from '../game/cards';
|
||||
import { discoveryStashSignature } from '../game/discovery-actions';
|
||||
import { greatMakerSignature } from '../game/great-maker';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import {
|
||||
discoveryFixture,
|
||||
enterDiscoveryCollection,
|
||||
} from './fixture-discovery';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
|
||||
function rejectUnchanged(game: Game, player: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, player, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
function takeSpice(
|
||||
game: Game,
|
||||
predicate: (card: SpiceCard) => boolean,
|
||||
): SpiceCard {
|
||||
const index = game.spiceDeck.findIndex(predicate);
|
||||
assert.ok(index >= 0, 'the physical spice card must remain in the deck');
|
||||
return game.spiceDeck.splice(index, 1)[0];
|
||||
}
|
||||
|
||||
function giveCard(
|
||||
game: Game,
|
||||
owner: string,
|
||||
predicate: (card: Card) => boolean,
|
||||
): Card {
|
||||
const zones = [
|
||||
game.deck,
|
||||
game.discard,
|
||||
...game.players.map((player) => player.hand),
|
||||
];
|
||||
for (const zone of zones) {
|
||||
const index = zone.findIndex(predicate);
|
||||
if (index < 0) continue;
|
||||
const [card] = zone.splice(index, 1);
|
||||
game.players.find((player) => player.id === owner)!.hand.push(card);
|
||||
return card;
|
||||
}
|
||||
assert.fail('the physical Treachery card must remain in a live zone');
|
||||
}
|
||||
|
||||
function ready(game: Game): Game {
|
||||
let next = game;
|
||||
for (const player of next.players)
|
||||
next = applyAction(next, player.id, { type: 'ready' });
|
||||
return next;
|
||||
}
|
||||
|
||||
function greatMakerSurvival(allied = false): { game: Game; karama: Card; source: string } {
|
||||
const game = discoveryFixture();
|
||||
const greatMaker = takeSpice(
|
||||
game,
|
||||
(card) => 'worm' in card && card.greatMaker === true,
|
||||
);
|
||||
const sourceCard = takeSpice(
|
||||
game,
|
||||
(card) =>
|
||||
'territory' in card &&
|
||||
card.territory === 'broken_land' &&
|
||||
card.discovery === undefined,
|
||||
);
|
||||
assert.ok('territory' in sourceCard);
|
||||
const replacement = takeSpice(
|
||||
game,
|
||||
(card) => 'territory' in card && card.discovery === undefined,
|
||||
);
|
||||
const karama = giveCard(game, 'a', (card) => card.effect === 'karama');
|
||||
Object.assign(game, {
|
||||
turn: 2,
|
||||
phase: 1,
|
||||
storm: 18,
|
||||
nexus: false,
|
||||
summonedBeforeBlow: false,
|
||||
wormRides: [],
|
||||
spiceSequence: null,
|
||||
spiceResolution: null,
|
||||
spiceWindow: null,
|
||||
response: null,
|
||||
decision: null,
|
||||
phaseOpening: null,
|
||||
ready: [],
|
||||
});
|
||||
for (const player of game.players)
|
||||
Object.assign(player, { forces: {}, reserves: 20, tanks: 0, ally: null });
|
||||
const source = `${sourceCard.territory}:${sourceCard.sector}`;
|
||||
const fremen = game.players.find((player) => player.id === 'f')!;
|
||||
fremen.forces = { [source]: 4 };
|
||||
fremen.reserves = 16;
|
||||
if (allied) {
|
||||
const atreides = game.players.find((player) => player.id === 'a')!;
|
||||
fremen.ally = atreides.id;
|
||||
atreides.ally = fremen.id;
|
||||
atreides.forces = { [source]: 2 };
|
||||
atreides.reserves = 18;
|
||||
}
|
||||
game.spice[source] = 5;
|
||||
game.spiceDiscard = [[sourceCard], []];
|
||||
game.spiceDeck = [greatMaker, replacement, ...game.spiceDeck];
|
||||
const pending = ready(game);
|
||||
assert.equal(pending.greatMaker?.stage, 'worm');
|
||||
assert.equal(
|
||||
allied ? pending.decision?.kind : pending.response?.kind,
|
||||
allied ? 'wormProtection' : 'wormSurvival',
|
||||
);
|
||||
return { game: pending, karama, source };
|
||||
}
|
||||
|
||||
function passCurrentResponse(game: Game, kind: NonNullable<Game['response']>['kind']): Game {
|
||||
let next = game;
|
||||
while (next.response?.kind === kind) {
|
||||
const player = next.players.find(
|
||||
(candidate) => !next.response!.passed.includes(candidate.id),
|
||||
);
|
||||
assert.ok(player);
|
||||
next = applyAction(next, player.id, { type: 'passResponse' });
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function advancedAdvisorGame(): Game {
|
||||
let game = createGame(
|
||||
'GMADVISOR',
|
||||
newPlayer('a', 'Atreides', 'atreides'),
|
||||
true,
|
||||
);
|
||||
joinGame(game, newPlayer('g', 'Guild', 'guild'));
|
||||
joinGame(game, newPlayer('f', 'Fremen', 'fremen'));
|
||||
joinGame(game, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
|
||||
game.discoveryEnabled = true;
|
||||
game = ready(game);
|
||||
game = initializeDiscoveryGameForAudit(game);
|
||||
game = applyAction(game, 'b', {
|
||||
type: 'predict',
|
||||
faction: 'atreides',
|
||||
turn: 4,
|
||||
});
|
||||
while (game.setupStage === 'traitors') {
|
||||
const player = game.players.find(
|
||||
(candidate) => candidate.traitorChoices.length,
|
||||
);
|
||||
assert.ok(player);
|
||||
game = applyAction(game, player.id, {
|
||||
type: 'traitor',
|
||||
leader: player.traitorChoices[0],
|
||||
});
|
||||
}
|
||||
game = applyAction(game, 'f', {
|
||||
type: 'fremenSetup',
|
||||
placements: { sietch_tabr: 10 },
|
||||
});
|
||||
game = applyAction(game, 'b', {
|
||||
type: 'advisorSetup',
|
||||
territory: 'imperial_basin',
|
||||
sector: 10,
|
||||
});
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.equal(
|
||||
isAdvisor(
|
||||
game.players.find((player) => player.id === 'b')!,
|
||||
'imperial_basin',
|
||||
),
|
||||
false,
|
||||
);
|
||||
return game;
|
||||
}
|
||||
|
||||
function voteAll(game: Game, yes = false): Game {
|
||||
let next = game;
|
||||
for (let count = 0; count < next.players.length; count++) {
|
||||
assert.ok(next.decision?.kind === 'greatMakerVote');
|
||||
next = applyAction(next, next.decision.player, {
|
||||
type: 'decision',
|
||||
event: next.decision.event,
|
||||
yes,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
void test('canceling Fremen worm survival destroys the board group but retains the Great Maker reserve ride', () => {
|
||||
const encounter = greatMakerSurvival();
|
||||
let game = applyAction(encounter.game, 'a', {
|
||||
type: 'card',
|
||||
card: encounter.karama.id,
|
||||
mode: 'cancel',
|
||||
});
|
||||
const fremen = game.players.find((player) => player.id === 'f')!;
|
||||
assert.equal(fremen.forces[encounter.source], undefined);
|
||||
assert.equal(fremen.tanks, 4);
|
||||
assert.equal(fremen.reserves, 16);
|
||||
assert.equal(game.spice[encounter.source], undefined);
|
||||
assert.deepEqual(game.wormRides, []);
|
||||
assert.equal(game.greatMaker?.stage, 'vote');
|
||||
|
||||
game = voteAll(reload(game));
|
||||
assert.equal(game.decision?.kind, 'greatMakerRide');
|
||||
const event = game.greatMaker!.event;
|
||||
game = applyAction(game, 'f', {
|
||||
type: 'decision',
|
||||
event,
|
||||
accept: true,
|
||||
territory: 'polar_sink',
|
||||
sector: 0,
|
||||
amount: 3,
|
||||
elite: 0,
|
||||
});
|
||||
const rider = game.players.find((player) => player.id === 'f')!;
|
||||
assert.equal(rider.tanks, 4);
|
||||
assert.equal(rider.reserves, 13);
|
||||
assert.equal(rider.forces['polar_sink:0'], 3);
|
||||
assert.equal(game.greatMaker?.stage, 'complete');
|
||||
});
|
||||
|
||||
void test('Fremen ally protection returns through survival to the Great Maker vote', () => {
|
||||
const encounter = greatMakerSurvival(true);
|
||||
let game = encounter.game;
|
||||
assert.equal(game.decision?.kind, 'wormProtection');
|
||||
game = applyAction(game, 'f', { type: 'decision', accept: true });
|
||||
assert.equal(game.greatMaker?.stage, 'worm');
|
||||
assert.equal(game.response?.kind, 'wormAllyProtection');
|
||||
assert.doesNotThrow(() => viewGame(reload(game), 'a'));
|
||||
|
||||
game = passCurrentResponse(game, 'wormAllyProtection');
|
||||
assert.equal(game.response?.kind, 'wormSurvival');
|
||||
assert.equal(game.response.recipient, 'a');
|
||||
assert.doesNotThrow(() => viewGame(reload(game), 'f'));
|
||||
|
||||
game = passCurrentResponse(game, 'wormSurvival');
|
||||
const atreides = game.players.find((player) => player.id === 'a')!;
|
||||
const fremen = game.players.find((player) => player.id === 'f')!;
|
||||
assert.equal(atreides.forces[encounter.source], 2);
|
||||
assert.equal(atreides.reserves, 18);
|
||||
assert.equal(atreides.tanks, 0);
|
||||
assert.equal(fremen.forces[encounter.source], 4);
|
||||
assert.equal(fremen.reserves, 16);
|
||||
assert.equal(fremen.tanks, 0);
|
||||
assert.equal(game.spice[encounter.source], undefined);
|
||||
assert.equal(game.greatMaker?.stage, 'vote');
|
||||
assert.equal(game.decision?.kind, 'greatMakerVote');
|
||||
});
|
||||
|
||||
void test('an Advanced Bene Gesserit intrusion returns from a restored Great Maker ride before spice continues', () => {
|
||||
let game = advancedAdvisorGame();
|
||||
const greatMaker = takeSpice(
|
||||
game,
|
||||
(card) => 'worm' in card && card.greatMaker === true,
|
||||
);
|
||||
const source = takeSpice(
|
||||
game,
|
||||
(card) =>
|
||||
'territory' in card &&
|
||||
card.territory === 'broken_land' &&
|
||||
card.discovery === undefined,
|
||||
);
|
||||
const replacement = takeSpice(
|
||||
game,
|
||||
(card) => 'territory' in card && card.discovery === undefined,
|
||||
);
|
||||
assert.ok('territory' in replacement);
|
||||
giveCard(game, 'a', (card) => card.effect === 'karama');
|
||||
Object.assign(game, {
|
||||
turn: 2,
|
||||
phase: 1,
|
||||
storm: 18,
|
||||
nexus: false,
|
||||
summonedBeforeBlow: false,
|
||||
wormRides: [],
|
||||
spiceSequence: null,
|
||||
spiceResolution: null,
|
||||
spiceWindow: null,
|
||||
response: null,
|
||||
decision: null,
|
||||
phaseOpening: null,
|
||||
ready: [],
|
||||
});
|
||||
game.spiceDiscard = [[source], []];
|
||||
game.spiceDeck = [greatMaker, replacement, ...game.spiceDeck];
|
||||
game = voteAll(ready(game));
|
||||
assert.ok(game.decision?.kind === 'greatMakerRide');
|
||||
game = applyAction(game, 'f', {
|
||||
type: 'decision',
|
||||
event: game.decision.event,
|
||||
accept: true,
|
||||
territory: 'imperial_basin',
|
||||
sector: 10,
|
||||
amount: 1,
|
||||
elite: 0,
|
||||
});
|
||||
game = reload(game);
|
||||
assert.equal(game.greatMaker?.stage, 'arrival');
|
||||
assert.equal(game.decision?.kind, 'intrusion');
|
||||
assert.doesNotThrow(() => viewGame(game, 'b'));
|
||||
const misbound = reload(game);
|
||||
assert.ok(misbound.decision?.kind === 'intrusion');
|
||||
misbound.decision.territory = 'carthag';
|
||||
assert.throws(
|
||||
() => viewGame(misbound, 'b'),
|
||||
/Great Maker.*arrival|arrival.*territory/i,
|
||||
);
|
||||
game = applyAction(game, 'b', { type: 'decision', accept: true });
|
||||
game = reload(game);
|
||||
assert.equal(game.response?.kind, 'advisorFlip');
|
||||
assert.equal(game.response?.advisorResume, 'wormRide');
|
||||
assert.doesNotThrow(() => viewGame(game, 'f'));
|
||||
while (game.response) {
|
||||
const player = game.players.find(
|
||||
(candidate) => !game.response!.passed.includes(candidate.id),
|
||||
);
|
||||
assert.ok(player);
|
||||
game = applyAction(game, player.id, { type: 'passResponse' });
|
||||
}
|
||||
const beneGesserit = game.players.find((player) => player.id === 'b')!;
|
||||
assert.equal(isAdvisor(beneGesserit, 'imperial_basin'), true);
|
||||
assert.equal(game.greatMaker?.stage, 'complete');
|
||||
assert.equal(game.spiceWindow?.territory, replacement.territory);
|
||||
assert.equal(game.decision, null);
|
||||
});
|
||||
|
||||
void test('Great Maker integrity rejects orphaned, moduleless, and post-completion controls', () => {
|
||||
const pending = greatMakerSurvival().game;
|
||||
const wrongSurvivalOwner = structuredClone(pending);
|
||||
assert.equal(wrongSurvivalOwner.response?.kind, 'wormSurvival');
|
||||
wrongSurvivalOwner.response.owner = 'a';
|
||||
assert.throws(
|
||||
() => viewGame(wrongSurvivalOwner, 'a'),
|
||||
/original Fremen response|pending worm survival/i,
|
||||
);
|
||||
const vote = structuredClone(pending);
|
||||
vote.response = null;
|
||||
vote.greatMaker!.stage = 'vote';
|
||||
vote.greatMaker!.signature = greatMakerSignature(vote.greatMaker!);
|
||||
vote.decision = {
|
||||
kind: 'greatMakerVote',
|
||||
player: vote.greatMaker!.order[0],
|
||||
event: vote.greatMaker!.event,
|
||||
};
|
||||
|
||||
const orphan = structuredClone(vote);
|
||||
delete orphan.greatMaker;
|
||||
assert.throws(() => viewGame(orphan, 'a'), /lost its original encounter/i);
|
||||
const moduleless = structuredClone(vote);
|
||||
delete moduleless.discoveryEnabled;
|
||||
delete moduleless.discoveries;
|
||||
assert.throws(() => viewGame(moduleless, 'a'), /physical Discovery module/i);
|
||||
|
||||
let complete = voteAll(vote);
|
||||
assert.ok(complete.decision?.kind === 'greatMakerRide');
|
||||
complete = applyAction(complete, 'f', {
|
||||
type: 'decision',
|
||||
event: complete.decision.event,
|
||||
accept: false,
|
||||
});
|
||||
assert.equal(complete.greatMaker?.stage, 'complete');
|
||||
const retained = structuredClone(complete);
|
||||
retained.decision = {
|
||||
kind: 'greatMakerRide',
|
||||
player: 'f',
|
||||
event: retained.greatMaker!.event,
|
||||
};
|
||||
assert.throws(() => viewGame(retained, 'a'), /completed Great Maker/i);
|
||||
});
|
||||
|
||||
void test('a pending Treachery Card Stash binds its exact private hand and rejects stale discard actions atomically', () => {
|
||||
let game = discoveryFixture();
|
||||
const token = enterDiscoveryCollection(game, 'treachery-card-stash');
|
||||
while (game.players[0].hand.length < 4)
|
||||
game.players[0].hand.push(game.deck.shift()!);
|
||||
game = applyAction(game, 'a', {
|
||||
type: 'discovery',
|
||||
token: token.id,
|
||||
reveal: false,
|
||||
});
|
||||
game = applyAction(game, 'a', {
|
||||
type: 'discovery',
|
||||
token: token.id,
|
||||
reveal: true,
|
||||
});
|
||||
assert.ok(game.decision?.kind === 'discoveryDiscard');
|
||||
const decision = game.decision;
|
||||
const selected = game.players[0].hand[0];
|
||||
rejectUnchanged(game, 'g', {
|
||||
type: 'decision',
|
||||
event: decision.event,
|
||||
card: selected.id,
|
||||
});
|
||||
rejectUnchanged(game, 'a', {
|
||||
type: 'decision',
|
||||
event: 'stale',
|
||||
card: selected.id,
|
||||
});
|
||||
rejectUnchanged(game, 'a', {
|
||||
type: 'decision',
|
||||
event: decision.event,
|
||||
card: selected.id,
|
||||
extra: true,
|
||||
});
|
||||
|
||||
const lostControl = reload(game);
|
||||
lostControl.decision = null;
|
||||
assert.throws(
|
||||
() => viewGame(lostControl, 'a'),
|
||||
/lost its owned discard choice/i,
|
||||
);
|
||||
const changedHand = reload(game);
|
||||
const removed = changedHand.players[0].hand.pop()!;
|
||||
changedHand.deck.push(removed);
|
||||
assert.throws(() => viewGame(changedHand, 'a'), /finish the original.*hand/i);
|
||||
const bypassedDiscard = reload(game);
|
||||
bypassedDiscard.decision = null;
|
||||
bypassedDiscard.discoveryStash!.stage = 'complete';
|
||||
bypassedDiscard.discoveryStash!.signature = discoveryStashSignature(
|
||||
bypassedDiscard.discoveryStash!,
|
||||
);
|
||||
assert.throws(
|
||||
() => viewGame(bypassedDiscard, 'a'),
|
||||
/stash.*discard|full.*hand/i,
|
||||
);
|
||||
|
||||
const done = applyAction(reload(game), 'a', {
|
||||
type: 'decision',
|
||||
event: decision.event,
|
||||
card: selected.id,
|
||||
});
|
||||
assert.equal(done.discoveryStash?.stage, 'complete');
|
||||
assert.equal(done.players[0].hand.length, 4);
|
||||
assert.equal(
|
||||
done.discard.filter((card) => card.id === selected.id).length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(normalizeAutomaticGame(done), done);
|
||||
});
|
||||
|
||||
void test('pre-Discovery saved games remain valid without synthesized component state', () => {
|
||||
let game = createGame('OLDROOM1', newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer('g', 'Guild', 'guild'));
|
||||
for (const player of game.players)
|
||||
game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = applyAction(game, 'a', { type: 'start' });
|
||||
while (game.status === 'setup') {
|
||||
const player = game.players.find(
|
||||
(candidate) => candidate.traitorChoices.length,
|
||||
);
|
||||
assert.ok(player);
|
||||
game = applyAction(game, player.id, {
|
||||
type: 'traitor',
|
||||
leader: player.traitorChoices[0],
|
||||
});
|
||||
}
|
||||
const restored = reload(game);
|
||||
assert.equal(restored.discoveryEnabled, undefined);
|
||||
assert.equal(restored.discoveries, undefined);
|
||||
assert.equal(restored.discoveryStash, undefined);
|
||||
assert.equal(restored.greatMaker, undefined);
|
||||
const view = viewGame(restored, 'a');
|
||||
assert.equal(view.discoveries, null);
|
||||
assert.equal(view.greatMaker, null);
|
||||
assert.doesNotThrow(() => normalizeAutomaticGame(restored));
|
||||
assert.equal(restored.discoveries, undefined);
|
||||
assert.equal(restored.greatMaker, undefined);
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
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 while normal starts remain gated', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const lobby = discoveryLobby(advanced),
|
||||
before = structuredClone(lobby);
|
||||
assert.throws(() => applyAction(lobby, lobby.host, { type: 'start' }));
|
||||
assert.deepEqual(lobby, before);
|
||||
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');
|
||||
while (g.players[0].hand.length < 4) g.players[0].hand.push(g.deck.shift()!);
|
||||
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);
|
||||
assert.equal(JSON.stringify(viewGame(g, 'f')).includes(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);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeDiscoveryGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
placeDiscovery,
|
||||
type DiscoverySpiceCardId,
|
||||
type DiscoveryTokenFace,
|
||||
} from '../game/discoveries';
|
||||
|
||||
export function discoveryLobby(
|
||||
advanced = false,
|
||||
ids: [string, string, string] = ['a', 'g', 'f'],
|
||||
): Game {
|
||||
let g = createGame(
|
||||
'DISCQA01',
|
||||
newPlayer(ids[0], 'Atreides', 'atreides'),
|
||||
advanced,
|
||||
[],
|
||||
);
|
||||
joinGame(g, newPlayer(ids[1], 'Guild', 'guild'));
|
||||
joinGame(g, newPlayer(ids[2], 'Fremen', 'fremen'));
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
g.discoveryEnabled = true;
|
||||
return g;
|
||||
}
|
||||
export function discoveryFixture(
|
||||
advanced = false,
|
||||
ids: [string, string, string] = ['a', 'g', 'f'],
|
||||
): Game {
|
||||
let g = initializeDiscoveryGameForAudit(discoveryLobby(advanced, ids));
|
||||
for (let step = 0; step < 80 && g.status === 'setup'; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
const v = viewGame(g, p.id);
|
||||
v.players.find((seat) => seat.id === p.id)!.bot = 'Easy';
|
||||
const action = botActions(v)[0];
|
||||
if (action) {
|
||||
next = applyAction(g, p.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
next,
|
||||
`Discovery setup stalled at ${g.setupStage}/${g.decision?.kind}`,
|
||||
);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
return g;
|
||||
}
|
||||
export function putDiscovery(g: Game, face: DiscoveryTokenFace) {
|
||||
const source = g.discoveries!.tokens.find((token) => token.face === face)!;
|
||||
const card: DiscoverySpiceCardId =
|
||||
source.type === 'hiereg'
|
||||
? 'discovery-hagga-basin'
|
||||
: 'discovery-wind-pass-north';
|
||||
const eligible = g.discoveries!.tokens.filter(
|
||||
(token) => token.type === source.type && token.status === 'supply',
|
||||
);
|
||||
const index = eligible.findIndex((token) => token.face === face);
|
||||
assert.ok(index >= 0);
|
||||
g.discoveries = placeDiscovery(
|
||||
g.discoveries!,
|
||||
card,
|
||||
() => (index + 0.1) / eligible.length,
|
||||
);
|
||||
return g.discoveries.tokens.find((token) => token.face === face)!;
|
||||
}
|
||||
export function enterDiscoveryCollection(
|
||||
g: Game,
|
||||
face: DiscoveryTokenFace,
|
||||
owner = g.players[0].id,
|
||||
) {
|
||||
const token = putDiscovery(g, face),
|
||||
p = g.players.find((player) => player.id === owner)!;
|
||||
const key = `${token.territory}:${token.sector}`;
|
||||
p.reserves -= 2;
|
||||
p.forces[key] = (p.forces[key] ?? 0) + 2;
|
||||
Object.assign(g, {
|
||||
phase: 7,
|
||||
ready: [],
|
||||
decision: null,
|
||||
response: null,
|
||||
phaseOpening: null,
|
||||
storm: 18,
|
||||
stormPending: null,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeDiscoveryGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { spiceDeck } from '../game/cards';
|
||||
|
||||
const LAND = spiceDeck().filter((card) => 'territory' in card);
|
||||
const SOURCE = LAND.find((card) => card.territory === 'broken_land')!;
|
||||
|
||||
function fixture(): Game {
|
||||
let g = createGame('GREATMAKER', newPlayer('f', 'Fremen', 'fremen'));
|
||||
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(g, newPlayer('g', 'Guild', 'guild'));
|
||||
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
|
||||
g.discoveryEnabled = true;
|
||||
for (const player of g.players)
|
||||
g = applyAction(g, player.id, { type: 'ready' });
|
||||
g = initializeDiscoveryGameForAudit(g);
|
||||
while (g.setupStage === 'traitors') {
|
||||
const player = g.players.find(
|
||||
(candidate) => candidate.traitorChoices.length,
|
||||
);
|
||||
assert.ok(player);
|
||||
g = applyAction(g, player.id, {
|
||||
type: 'traitor',
|
||||
leader: player.traitorChoices[0],
|
||||
});
|
||||
}
|
||||
g = applyAction(g, 'f', {
|
||||
type: 'fremenSetup',
|
||||
placements: { sietch_tabr: 10 },
|
||||
});
|
||||
assert.equal(g.status, 'playing');
|
||||
return g;
|
||||
}
|
||||
|
||||
function ready(state: Game): Game {
|
||||
let g = state;
|
||||
for (const player of g.players)
|
||||
g = applyAction(g, player.id, { type: 'ready' });
|
||||
return g;
|
||||
}
|
||||
|
||||
function encounter(priorNexus = false): Game {
|
||||
const g = fixture();
|
||||
const greatMakerIndex = g.spiceDeck.findIndex(
|
||||
(card) => 'worm' in card && card.greatMaker,
|
||||
);
|
||||
const sourceIndex = g.spiceDeck.findIndex(
|
||||
(card) =>
|
||||
'territory' in card &&
|
||||
card.territory === SOURCE.territory &&
|
||||
card.discovery === undefined,
|
||||
);
|
||||
assert.ok(greatMakerIndex >= 0);
|
||||
assert.ok(sourceIndex >= 0);
|
||||
const [greatMaker] = g.spiceDeck.splice(greatMakerIndex, 1);
|
||||
const adjustedSourceIndex = g.spiceDeck.findIndex(
|
||||
(card) =>
|
||||
'territory' in card &&
|
||||
card.territory === SOURCE.territory &&
|
||||
card.discovery === undefined,
|
||||
);
|
||||
const [source] = g.spiceDeck.splice(adjustedSourceIndex, 1);
|
||||
const replacementIndex = g.spiceDeck.findIndex(
|
||||
(card) => 'territory' in card && card.discovery === undefined,
|
||||
);
|
||||
assert.ok(replacementIndex >= 0);
|
||||
const [replacement] = g.spiceDeck.splice(replacementIndex, 1);
|
||||
assert.ok(
|
||||
greatMaker,
|
||||
'the Discovery setup must create the physical Great Maker card',
|
||||
);
|
||||
Object.assign(g, {
|
||||
turn: 2,
|
||||
phase: 1,
|
||||
storm: 18,
|
||||
nexus: false,
|
||||
summonedBeforeBlow: priorNexus,
|
||||
wormRides: priorNexus ? ['red_chasm'] : [],
|
||||
spiceSequence: null,
|
||||
spiceResolution: null,
|
||||
spiceWindow: null,
|
||||
response: null,
|
||||
decision: null,
|
||||
ready: [],
|
||||
});
|
||||
for (const player of g.players) {
|
||||
g.deck.push(...player.hand);
|
||||
Object.assign(player, {
|
||||
hand: [],
|
||||
forces: {},
|
||||
reserves: 20,
|
||||
tanks: 0,
|
||||
ally: null,
|
||||
});
|
||||
}
|
||||
g.players.find((player) => player.id === 'f')!.forces = {
|
||||
[`${SOURCE.territory}:${SOURCE.sector}`]: 4,
|
||||
};
|
||||
g.players.find((player) => player.id === 'f')!.reserves = 16;
|
||||
g.players.find((player) => player.id === 'a')!.forces = {
|
||||
[`${SOURCE.territory}:${SOURCE.sector}`]: 3,
|
||||
};
|
||||
g.players.find((player) => player.id === 'a')!.reserves = 17;
|
||||
g.spice = { [`${SOURCE.territory}:${SOURCE.sector}`]: 7 };
|
||||
g.spiceDiscard = [[source], []];
|
||||
g.spiceDeck = [greatMaker, replacement, ...g.spiceDeck];
|
||||
const result = ready(g);
|
||||
assert.equal(result.turn, 2);
|
||||
assert.equal(result.phase, 1);
|
||||
assert.equal(result.greatMaker?.stage, 'vote');
|
||||
assert.equal(result.decision?.kind, 'greatMakerVote');
|
||||
return result;
|
||||
}
|
||||
|
||||
function vote(state: Game, choices: readonly boolean[]): Game {
|
||||
let g = state;
|
||||
for (const yes of choices) {
|
||||
assert.equal(g.decision?.kind, 'greatMakerVote');
|
||||
const decision = g.decision;
|
||||
g = applyAction(g, decision.player, {
|
||||
type: 'decision',
|
||||
event: decision.event,
|
||||
yes,
|
||||
});
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function rejectUnchanged(
|
||||
state: Game,
|
||||
player: string,
|
||||
action: Action,
|
||||
message?: RegExp,
|
||||
) {
|
||||
const before = structuredClone(state);
|
||||
if (message) assert.throws(() => applyAction(state, player, action), message);
|
||||
else assert.throws(() => applyAction(state, player, action));
|
||||
assert.deepEqual(state, before);
|
||||
}
|
||||
|
||||
void test('Great Maker waits for every storm-order vote, requires a strict majority, and a tie does not erase an existing Nexus', () => {
|
||||
let g = encounter(true);
|
||||
const order = [...g.order];
|
||||
assert.deepEqual(g.greatMaker?.order, order);
|
||||
assert.equal(g.decision?.player, order[0]);
|
||||
|
||||
rejectUnchanged(g, order[1], {
|
||||
type: 'decision',
|
||||
event: g.greatMaker!.event,
|
||||
yes: true,
|
||||
});
|
||||
g = vote(g, [true, false, true]);
|
||||
assert.equal(g.greatMaker?.stage, 'vote');
|
||||
assert.equal(g.decision?.player, order[3]);
|
||||
assert.equal(
|
||||
g.nexus,
|
||||
true,
|
||||
'the Nexus that existed before the blow remains active',
|
||||
);
|
||||
g = vote(g, [false]);
|
||||
assert.deepEqual(
|
||||
g.greatMaker?.votes,
|
||||
order.map((player, index) => ({
|
||||
player,
|
||||
yes: [true, false, true, false][index],
|
||||
})),
|
||||
);
|
||||
assert.equal(
|
||||
g.nexus,
|
||||
true,
|
||||
'a tied Great Maker vote must not erase an earlier Nexus',
|
||||
);
|
||||
assert.equal(g.greatMaker?.stage, 'ride');
|
||||
assert.equal(g.decision?.kind, 'greatMakerRide');
|
||||
});
|
||||
|
||||
void test('Great Maker creates a Nexus only after the complete ordered majority vote', () => {
|
||||
let g = encounter();
|
||||
g = vote(g, [true, true, true]);
|
||||
assert.equal(
|
||||
g.nexus,
|
||||
false,
|
||||
'even a secured majority waits for the remaining voter',
|
||||
);
|
||||
assert.equal(g.greatMaker?.stage, 'vote');
|
||||
g = vote(g, [false]);
|
||||
assert.equal(g.nexus, true);
|
||||
assert.equal(g.greatMaker?.stage, 'ride');
|
||||
|
||||
let tied = encounter();
|
||||
tied = vote(tied, [true, false, true, false]);
|
||||
assert.equal(tied.nexus, false);
|
||||
});
|
||||
|
||||
void test('Great Maker resolves ordinary worm devastation, then rides only reserve Fremen forces for free', () => {
|
||||
let g = encounter();
|
||||
const source = `${SOURCE.territory}:${SOURCE.sector}`;
|
||||
const fremen = g.players.find((player) => player.id === 'f')!;
|
||||
const atreides = g.players.find((player) => player.id === 'a')!;
|
||||
assert.equal(
|
||||
fremen.forces[source],
|
||||
4,
|
||||
'Fremen survive the ordinary worm effect',
|
||||
);
|
||||
assert.equal(fremen.tanks, 0);
|
||||
assert.equal(atreides.forces[source], undefined);
|
||||
assert.equal(atreides.tanks, 3);
|
||||
assert.equal(g.spice[source], undefined);
|
||||
assert.deepEqual(
|
||||
g.wormRides,
|
||||
[],
|
||||
'the board army is not queued as this reserve ride',
|
||||
);
|
||||
|
||||
g = vote(g, [false, false, false, false]);
|
||||
assert.equal(g.decision?.kind, 'greatMakerRide');
|
||||
const event = g.greatMaker!.event;
|
||||
const before = {
|
||||
reserves: fremen.reserves,
|
||||
spice: fremen.spice,
|
||||
shipped: fremen.shipped,
|
||||
moved: fremen.moved,
|
||||
source: fremen.forces[source],
|
||||
};
|
||||
g = applyAction(g, 'f', {
|
||||
type: 'decision',
|
||||
event,
|
||||
accept: true,
|
||||
territory: 'polar_sink',
|
||||
sector: 0,
|
||||
amount: 2,
|
||||
elite: 0,
|
||||
});
|
||||
const after = g.players.find((player) => player.id === 'f')!;
|
||||
assert.equal(after.reserves, before.reserves - 2);
|
||||
assert.equal(after.forces['polar_sink:0'], 2);
|
||||
assert.equal(
|
||||
after.forces[source],
|
||||
before.source,
|
||||
'board forces stay at the worm territory',
|
||||
);
|
||||
assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.shipped, before.shipped);
|
||||
assert.equal(after.moved, before.moved);
|
||||
assert.deepEqual(g.wormRides, []);
|
||||
assert.equal(g.greatMaker?.stage, 'complete');
|
||||
assert.notEqual(g.decision?.kind, 'wormRide');
|
||||
});
|
||||
|
||||
void test('Great Maker rejects malformed, out-of-order, reserve-source and stale decisions without mutation', () => {
|
||||
let g = encounter();
|
||||
assert.ok(g.decision?.kind === 'greatMakerVote');
|
||||
const current = g.decision;
|
||||
const next = g.greatMaker!.order[1];
|
||||
rejectUnchanged(g, next, {
|
||||
type: 'decision',
|
||||
event: current.event,
|
||||
yes: true,
|
||||
});
|
||||
rejectUnchanged(g, current.player, {
|
||||
type: 'decision',
|
||||
event: 'stale',
|
||||
yes: true,
|
||||
});
|
||||
rejectUnchanged(g, current.player, {
|
||||
type: 'decision',
|
||||
event: current.event,
|
||||
yes: true,
|
||||
extra: true,
|
||||
});
|
||||
rejectUnchanged(g, current.player, {
|
||||
type: 'decision',
|
||||
event: current.event,
|
||||
yes: 'yes',
|
||||
});
|
||||
|
||||
g = applyAction(g, current.player, {
|
||||
type: 'decision',
|
||||
event: current.event,
|
||||
yes: true,
|
||||
});
|
||||
rejectUnchanged(g, current.player, {
|
||||
type: 'decision',
|
||||
event: current.event,
|
||||
yes: false,
|
||||
});
|
||||
g = vote(g, [false, false, false]);
|
||||
assert.ok(g.decision?.kind === 'greatMakerRide');
|
||||
const ride = g.decision;
|
||||
rejectUnchanged(g, 'a', {
|
||||
type: 'decision',
|
||||
event: ride.event,
|
||||
accept: false,
|
||||
});
|
||||
rejectUnchanged(g, 'f', {
|
||||
type: 'decision',
|
||||
event: 'stale',
|
||||
accept: false,
|
||||
});
|
||||
rejectUnchanged(g, 'f', {
|
||||
type: 'decision',
|
||||
event: ride.event,
|
||||
accept: false,
|
||||
territory: 'polar_sink',
|
||||
});
|
||||
rejectUnchanged(g, 'f', {
|
||||
type: 'decision',
|
||||
event: ride.event,
|
||||
accept: true,
|
||||
from: `${SOURCE.territory}:${SOURCE.sector}`,
|
||||
territory: 'polar_sink',
|
||||
sector: 0,
|
||||
amount: 1,
|
||||
elite: 0,
|
||||
});
|
||||
const declined = applyAction(g, 'f', {
|
||||
type: 'decision',
|
||||
event: ride.event,
|
||||
accept: false,
|
||||
});
|
||||
rejectUnchanged(declined, 'f', {
|
||||
type: 'decision',
|
||||
event: ride.event,
|
||||
accept: false,
|
||||
});
|
||||
});
|
||||
|
||||
void test('Great Maker survives JSON restoration, exposes only public progress, detaches projections, and gives every AI profile legal choices', () => {
|
||||
const restored = JSON.parse(JSON.stringify(encounter())) as Game;
|
||||
const decision = restored.decision!;
|
||||
for (const player of restored.players) {
|
||||
const view = viewGame(restored, player.id);
|
||||
assert.ok(view.greatMaker);
|
||||
assert.equal(view.greatMaker.event, restored.greatMaker!.event);
|
||||
assert.deepEqual(view.greatMaker.order, restored.order);
|
||||
assert.deepEqual(view.greatMaker.votes, []);
|
||||
assert.equal(view.greatMaker.ride, null);
|
||||
assert.equal('territory' in view.greatMaker, false);
|
||||
assert.equal('ridesBefore' in view.greatMaker, false);
|
||||
assert.equal('signature' in view.greatMaker, false);
|
||||
assert.equal('pile' in view.greatMaker, false);
|
||||
assert.equal('index' in view.greatMaker, false);
|
||||
}
|
||||
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(restored, decision.player);
|
||||
view.players.find((player) => player.id === decision.player)!.bot =
|
||||
difficulty;
|
||||
const before = structuredClone(view);
|
||||
const actions = botActions(view);
|
||||
assert.deepEqual(view, before);
|
||||
assert.equal(actions.length, 1);
|
||||
assert.doesNotThrow(() =>
|
||||
applyAction(restored, decision.player, actions[0]),
|
||||
);
|
||||
}
|
||||
|
||||
const projected = viewGame(restored, decision.player);
|
||||
const authoritative = structuredClone(restored);
|
||||
projected.greatMaker!.votes.push({ player: 'outside', yes: true });
|
||||
projected.greatMaker!.order.reverse();
|
||||
assert.deepEqual(
|
||||
restored,
|
||||
authoritative,
|
||||
'a projected vote history must not alias authoritative state',
|
||||
);
|
||||
|
||||
let ride = vote(restored, [true, false, true, false]);
|
||||
ride = JSON.parse(JSON.stringify(ride)) as Game;
|
||||
assert.equal(ride.decision?.kind, 'greatMakerRide');
|
||||
for (const player of ride.players) {
|
||||
const view = viewGame(ride, player.id);
|
||||
assert.equal(view.greatMaker?.ride !== null, player.faction === 'fremen');
|
||||
}
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(ride, 'f');
|
||||
view.players.find((player) => player.id === 'f')!.bot = difficulty;
|
||||
const before = structuredClone(view);
|
||||
const actions = botActions(view);
|
||||
assert.deepEqual(view, before);
|
||||
assert.ok(actions.some((action) => action.accept === true));
|
||||
assert.ok(actions.some((action) => action.accept === false));
|
||||
for (const action of actions)
|
||||
assert.doesNotThrow(
|
||||
() => applyAction(ride, 'f', action),
|
||||
`${difficulty}: ${JSON.stringify(action)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user