An unallied player holding CHOAM Nexus while CHOAM is absent can now discard one Worthless card during Spice Collection for two bank spice. The server spends both physical cards once, exposes only private legal card choices, and persists an already-paid discard receipt. Human controls and all four AI profiles share the action. The after-victory inspection alternative remains missing; normal unfinished modes and publication stay gated. Independent review caught and fixed a queued Grumman Collection lock mismatch. Focused tests cover Basic/Advanced, actual card custody, private controls, all AI profiles, stale and wrong-mode rejection, Truthtrance and Grumman locks, JSON corruption/replay, competing production SQLite writes and private seat restoration. A separate bounded acquisition helper recorded all seven Discovery Spice Cards, eight tokens and exact physical card placements from publisher-authored sources. Discovery runtime remains missing. Validation: types, lint, 4,217 offline tests, production build and 40 HTTP tests passed. Durations: check 139298 ms; build 6617 ms; HTTP 24329 ms. Source fingerprint dfdaaf8bafe69acd31c9d0afc13f7a0d070da43deb544f091e95fe840b399493; private report under ~/.local/share/dune/checkpoints/20260913-choam-nexus-trade/verification/report.json. Browser reference search, Prototyped label and related Collection link checked. All 95 opening saved rooms unchanged; 30 HTTP test rooms added, 125 total. No server restart or database reset. Efficiency counter: 59,982,405 at batch start; 60,581,959 after broad verification, delta 599,554 aggregate tokens before final preservation/Git/usage checks. One newly playable function plus the acquired Discovery inventory; this is not full CHOAM or Nexus compliance.
208 lines
5.9 KiB
TypeScript
208 lines
5.9 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
initializeHomeworldGameForAudit,
|
|
initializeNexusGameForAudit,
|
|
joinGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { baseDeck, type Card } from '../game/cards';
|
|
import { botActions } from '../game/bots';
|
|
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
|
import { placeTerror, type TerrorKind } from '../game/moritani-terror';
|
|
import {
|
|
enterNexusSpice,
|
|
finishNexusSpice,
|
|
orderNexusSpice,
|
|
} from './fixture-nexus-cards';
|
|
|
|
export const grummanPlayer = (g: Game, id: string) =>
|
|
g.players.find((p) => p.id === id)!;
|
|
export const grummanReload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
|
|
/** Real Homeworld audit initialization and faction setup. Only the conserved
|
|
* pre-Collection board position is staged; no signed phase/arrival is fabricated.
|
|
* Runtime Moritani support here does not lift its public expansion-start gate. */
|
|
export function grummanCollectionFixture(
|
|
options: {
|
|
native?: number;
|
|
advanced?: boolean;
|
|
nexus?: boolean;
|
|
seatIds?: [string, string, string];
|
|
} = {},
|
|
): Game {
|
|
const [m, a, h] = options.seatIds ?? ['m', 'a', 'g'];
|
|
let g = createGame(
|
|
'GRUMMANCOLLECTION',
|
|
newPlayer(m, 'Moritani', 'moritani'),
|
|
options.advanced ?? false,
|
|
[],
|
|
);
|
|
joinGame(g, newPlayer(a, 'Atreides', 'atreides'));
|
|
joinGame(g, newPlayer(h, 'Harkonnen', 'harkonnen'));
|
|
g = applyAction(g, m, { type: 'homeworlds', enabled: true });
|
|
if (options.nexus) g.nexusCards = { cards: null, phase: null };
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
g = options.nexus
|
|
? initializeNexusGameForAudit(g)
|
|
: initializeHomeworldGameForAudit(g);
|
|
for (let n = 0; g.status === 'setup' && n < 60; n++) {
|
|
let next: Game | undefined;
|
|
for (const p of g.players) {
|
|
const view = viewGame(g, p.id);
|
|
view.players.find((seat) => seat.id === p.id)!.bot = 'Easy';
|
|
const action = botActions(view)[0];
|
|
if (action) {
|
|
next = applyAction(g, p.id, action);
|
|
break;
|
|
}
|
|
}
|
|
assert.ok(next, `Setup stalled at ${g.setupStage}/${g.decision?.kind}`);
|
|
g = next;
|
|
}
|
|
assert.equal(g.status, 'playing');
|
|
for (const p of g.players) {
|
|
g.deck.push(...p.hand.splice(0));
|
|
Object.assign(p, {
|
|
spice: 20,
|
|
forces: {},
|
|
reserves: 20,
|
|
tanks: 0,
|
|
shipped: true,
|
|
moved: 0,
|
|
});
|
|
}
|
|
if (options.nexus) {
|
|
g = enterNexusSpice(g);
|
|
orderNexusSpice(g, ['land', 'land']);
|
|
g = finishNexusSpice(g);
|
|
}
|
|
const owner = grummanPlayer(g, m);
|
|
owner.reserves = options.native ?? 8;
|
|
owner.forces = { 'polar_sink:0': 20 - owner.reserves };
|
|
assert.ok(g.moritaniTerror);
|
|
const robbery = g.moritaniTerror.tokens.find(
|
|
(token) => token.kind === 'robbery',
|
|
)!;
|
|
g.moritaniTerror = placeTerror(g.moritaniTerror, robbery.id, 'arrakeen', 1);
|
|
Object.assign(g, {
|
|
phase: 5,
|
|
turn: 2,
|
|
active: a,
|
|
movementRemaining: [a],
|
|
order: [a, m, h],
|
|
ready: [],
|
|
phaseOpening: null,
|
|
response: null,
|
|
decision: null,
|
|
storm: 18,
|
|
spice: {},
|
|
});
|
|
grummanInventory(g);
|
|
return g;
|
|
}
|
|
|
|
export function enterGrummanCollection(g: Game): Game {
|
|
const before = grummanReload(g);
|
|
const next = applyAction(g, g.active!, { type: 'endMovement' });
|
|
assert.deepEqual(g, before);
|
|
assert.equal(next.phase, 7);
|
|
grummanInventory(next);
|
|
return next;
|
|
}
|
|
export function grummanToken(g: Game, kind: TerrorKind) {
|
|
return g.moritaniTerror!.tokens.find((token) => token.kind === kind)!;
|
|
}
|
|
export function addGrummanToken(
|
|
g: Game,
|
|
kind: TerrorKind = 'sabotage',
|
|
territory = 'arrakeen',
|
|
): Game {
|
|
assert.equal(g.decision?.kind, 'grummanCollection');
|
|
return applyAction(g, g.grummanCollection!.player, {
|
|
type: 'decision',
|
|
event: g.grummanCollection!.event,
|
|
mode: 'add',
|
|
token: grummanToken(g, kind).id,
|
|
territory,
|
|
});
|
|
}
|
|
export function holdGrummanCard(g: Game, player: string, kind: string): Card {
|
|
const index = g.deck.findIndex(
|
|
(card) => card.kind === kind || card.effect === kind,
|
|
);
|
|
assert.ok(index >= 0, `Missing ${kind}`);
|
|
const card = g.deck.splice(index, 1)[0];
|
|
grummanPlayer(g, player).hand.push(card);
|
|
return card;
|
|
}
|
|
/** Later movement checkpoint preserves the actual Collection receipt/stack.
|
|
* IDs derive from factions so SQL fixtures can bind seats before signing. */
|
|
export function stageGrummanArrival(g: Game): Game {
|
|
const next = grummanReload(g);
|
|
const entrant = next.players.find((p) => p.faction === 'atreides')!;
|
|
Object.assign(next, {
|
|
turn: g.turn + 1,
|
|
phase: 5,
|
|
active: entrant.id,
|
|
movementRemaining: [entrant.id],
|
|
ready: [],
|
|
decision: null,
|
|
response: null,
|
|
phaseOpening: null,
|
|
});
|
|
entrant.shipped = false;
|
|
entrant.moved = 0;
|
|
grummanInventory(next);
|
|
return next;
|
|
}
|
|
export function enterGrummanStack(g: Game, amount = 1): Game {
|
|
return applyAction(g, g.active!, {
|
|
type: 'ship',
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
amount,
|
|
});
|
|
}
|
|
export function grummanReject(
|
|
g: Game,
|
|
id: string,
|
|
action: Action,
|
|
match: RegExp = /./,
|
|
): void {
|
|
const before = grummanReload(g);
|
|
assert.throws(() => applyAction(g, id, action), match);
|
|
assert.deepEqual(g, before);
|
|
}
|
|
export function grummanInventory(g: Game): void {
|
|
homeworldGameIntegrity(g);
|
|
for (const p of g.players)
|
|
assert.equal(
|
|
p.reserves +
|
|
p.tanks +
|
|
Object.values(p.forces).reduce((sum, n) => sum + n, 0),
|
|
20,
|
|
);
|
|
assert.deepEqual(
|
|
[...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
|
|
.map((card) => card.id)
|
|
.sort(),
|
|
baseDeck()
|
|
.map((card) => card.id)
|
|
.sort(),
|
|
);
|
|
assert.equal(g.moritaniTerror!.tokens.length, 6);
|
|
assert.equal(
|
|
new Set(g.moritaniTerror!.tokens.map((token) => token.id)).size,
|
|
6,
|
|
);
|
|
assert.equal(
|
|
new Set(g.moritaniTerror!.tokens.map((token) => token.kind)).size,
|
|
6,
|
|
);
|
|
}
|