Connect all fourteen physical skills and private setup to five card-role battle modifiers, public posture, Harkonnen skilled capture, death return and optional own-leader revival through controls, legal AI and persisted room actions. Nine skill effects and combined-mode/publication acceptance remain unfinished. Captured replacement entitlement and foreign ghola integration stay guarded. Fresh publisher/designer research supports skilled-capture publicity through its unique known physical card; this is recorded as a component inference. Independent review found and fixed Voice/must-use-leader and invalid saved assignment/offer cases. A pre-existing Discovery privacy assertion confused card ID prefixes; the corrected deterministic physical-pair scenario was independently reviewed. The first broad run was 4410/4411; corrected final verification passes types/lint, all 4411 offline tests, build and 43 HTTP tests. Final durations: check 149741ms, build 6659ms, integration 28274ms. Source fingerprint: aef655ac1ebc0eb2f64294e13d3d27337167312cf8ab17568a166ee9c39f6a85. Private final report: 20260913-leader-skills/checks-final/report.json. Three seeded prototype games completed with no rejected bot candidates. Browser setup/card inspection/refresh passed. Another user-reported power outage stopped the server; backup and necessary restart restored the same private seat. All 292 games at restart and their original application records remain unchanged; 33 isolated HTTP-test rooms bring the current total to 325. No database reset. Efficiency: one shared setup/lifecycle implementation supports five connected role effects; selective existing agents handled catalog/UI/tests/research and independent reviews. One repeated broad check was required by the reproduced ID-prefix assertion failure. Aggregate goal counter rose from 65529766 to 67313673 through this checkpoint (1783907), including inherited context and coordination; this is not a billing or savings estimate. Full goal remains active.
240 lines
8.4 KiB
TypeScript
240 lines
8.4 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
initializeDiscoveryGameForAudit,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { baseDeck, spiceDeck } from '../game/cards';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { botActions } from '../game/bots';
|
|
import {
|
|
discoveryFixture,
|
|
discoveryLobby,
|
|
enterDiscoveryCollection,
|
|
} from './fixture-discovery';
|
|
import {
|
|
DISCOVERY_SPICE_CARDS,
|
|
validateDiscoveryState,
|
|
} from '../game/discoveries';
|
|
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
function custody(g: Game) {
|
|
assert.deepEqual(
|
|
[...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
|
|
.map((c) => c.id)
|
|
.sort(),
|
|
baseDeck()
|
|
.map((c) => c.id)
|
|
.sort(),
|
|
);
|
|
for (const p of g.players)
|
|
assert.equal(
|
|
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
|
20,
|
|
);
|
|
validateDiscoveryState(g.discoveries!);
|
|
}
|
|
void test('Discovery real setup deals seven spice cards and eight opaque tokens 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');
|
|
// Exercise the previously flaky ID prefix collision with two distinct cards.
|
|
const take = (id: string) => {
|
|
const pile = [g.deck, g.discard, ...g.players.map((p) => p.hand)]
|
|
.find((cards) => cards.some((card) => card.id === id))!;
|
|
return pile.splice(pile.findIndex((card) => card.id === id), 1)[0];
|
|
};
|
|
const privateCard = take('treachery-3');
|
|
const visibleSibling = take('treachery-32');
|
|
g.players.find((p) => p.id === 'f')!.hand.push(visibleSibling);
|
|
while (g.players[0].hand.length < 4) g.players[0].hand.push(g.deck.shift()!);
|
|
g.deck.unshift(privateCard);
|
|
const original = g.players[0].hand.map((c) => c.id),
|
|
drawn = g.deck[0];
|
|
g = applyAction(g, 'a', {
|
|
type: 'discovery',
|
|
token: token.id,
|
|
reveal: false,
|
|
});
|
|
g = applyAction(g, 'a', { type: 'discovery', token: token.id, reveal: true });
|
|
assert.equal(g.decision?.kind, 'discoveryDiscard');
|
|
assert.equal(g.players[0].hand.length, 5);
|
|
const otherView = JSON.stringify(viewGame(g, 'f'));
|
|
assert.equal(otherView.includes(JSON.stringify(visibleSibling.id)), true);
|
|
assert.equal(otherView.includes(JSON.stringify(drawn.id)), false);
|
|
assert.deepEqual(
|
|
g.players[0].hand.map((c) => c.id),
|
|
[...original, drawn.id],
|
|
);
|
|
for (const card of g.players[0].hand) {
|
|
const done = applyAction(reload(g), 'a', {
|
|
type: 'decision',
|
|
event: g.discoveryStash!.event,
|
|
card: card.id,
|
|
});
|
|
assert.equal(done.players[0].hand.length, 4);
|
|
assert.equal(done.discoveryStash!.stage, 'complete');
|
|
assert.ok(done.discard.some((c) => c.id === card.id));
|
|
custody(done);
|
|
assert.deepEqual(normalizeAutomaticGame(done), done);
|
|
}
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const v = viewGame(g, 'a');
|
|
v.players[0].bot = difficulty;
|
|
const actions = botActions(v);
|
|
assert.ok(actions.length);
|
|
for (const action of actions)
|
|
assert.doesNotThrow(() => applyAction(g, 'a', action));
|
|
}
|
|
});
|
|
void test('a revealed location is initially empty and becomes an ordinary stronghold-price destination', () => {
|
|
let g = discoveryFixture();
|
|
const token = enterDiscoveryCollection(g, 'cistern');
|
|
g = applyAction(g, 'a', {
|
|
type: 'discovery',
|
|
token: token.id,
|
|
reveal: false,
|
|
});
|
|
g = applyAction(g, 'a', { type: 'discovery', token: token.id, reveal: true });
|
|
assert.equal(
|
|
g.discoveries!.tokens.find((t) => t.id === token.id)!.revealedTurn,
|
|
g.turn,
|
|
);
|
|
assert.ok(g.players.every((p) => !p.forces['cistern:0']));
|
|
assert.equal(
|
|
viewGame(g, 'f').discoveries!.tokens.find((t) => t.id === token.id)!.face,
|
|
'cistern',
|
|
);
|
|
custody(g);
|
|
});
|
|
|
|
void test('each physical Discovery blow destroys the whole parent territory including Fremen before placing six new spice and a private token', () => {
|
|
for (const printed of DISCOVERY_SPICE_CARDS) {
|
|
let g = discoveryFixture();
|
|
const index = g.spiceDeck.findIndex(
|
|
(card) => 'territory' in card && card.discovery === printed.discovery,
|
|
);
|
|
assert.ok(index >= 0);
|
|
const card = g.spiceDeck.splice(index, 1)[0];
|
|
g.spiceDeck.unshift(card);
|
|
Object.assign(g, { phase: 1, ready: [], storm: 18, stormPending: null });
|
|
const key = `${printed.territory}:${printed.sector}`;
|
|
for (const p of [g.players[0], g.players[2]]) {
|
|
p.reserves -= 2;
|
|
p.forces[key] = 2;
|
|
}
|
|
g.spice[key] = 4;
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
assert.equal(g.spiceWindow?.territory, printed.territory);
|
|
assert.equal(g.spice[key], 6);
|
|
for (const p of [g.players[0], g.players[2]]) {
|
|
assert.equal(p.forces[key], undefined);
|
|
assert.equal(p.tanks, 2);
|
|
}
|
|
assert.equal(
|
|
g.discoveries!.tokens.filter((token) => token.status === 'placed').length,
|
|
1,
|
|
);
|
|
assert.equal(viewGame(g, 'a').discoveries!.tokens[0].face, null);
|
|
custody(g);
|
|
}
|
|
});
|
|
|
|
void test('Discovery spice still destroys old forces under the storm and does not discard the separately placed token', () => {
|
|
let g = discoveryFixture();
|
|
const printed = DISCOVERY_SPICE_CARDS[0];
|
|
const index = g.spiceDeck.findIndex(
|
|
(card) => 'territory' in card && card.discovery === printed.discovery,
|
|
);
|
|
g.spiceDeck.unshift(g.spiceDeck.splice(index, 1)[0]);
|
|
Object.assign(g, {
|
|
phase: 1,
|
|
ready: [],
|
|
storm: printed.sector,
|
|
stormPending: null,
|
|
});
|
|
const key = `${printed.territory}:${printed.sector}`;
|
|
g.players[0].reserves--;
|
|
g.players[0].forces[key] = 1;
|
|
g.spice[key] = 3;
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
assert.equal(g.spice[key], undefined);
|
|
assert.equal(g.players[0].tanks, 1);
|
|
assert.equal(
|
|
g.discoveries!.tokens.filter((token) => token.status === 'placed').length,
|
|
1,
|
|
);
|
|
custody(g);
|
|
});
|