Implement Grumman collection and private stacked Terror choices

Add conserved token collection with four bank spice, owner-only stack selection, recovery proofs, controls and AI. Preserve unresolved removal and expansion release gates. Verified 3712 offline cases, types, lint, production build, 40 HTTP cases, three-seat desktop/mobile play, and unchanged 3081 preexisting saved games.
This commit is contained in:
Quintine committed 2026-09-10 08:38:06 +10:00
1 parent 28ba1618ba
commit 1db11a0187
27 files changed
+2451 -43

No files matched your search

+2
View File
@@ -12,6 +12,7 @@ import { baseDeck, treacheryDeck, spiceDeck, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { cashInCards } from '../game/choam-karama';
import { createHomeworldCustody } from '../game/homeworld-custody';
import { createTerrorState } from '../game/moritani-terror';
import {
homeworldContext,
homeworldGameIntegrity,
@@ -465,6 +466,7 @@ void test('Moritani delayed Chemistry cleanup preserves its actual weapon or def
const moritani = newPlayer('mo', 'Moritani', 'moritani');
moritani.spice = 20;
g.players.push(moritani);
g.moritaniTerror = createTerrorState(() => 0);
g.order.push('mo');
player(g, 'other').ally = 'mo';
moritani.ally = 'other';
+187
View File
@@ -0,0 +1,187 @@
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeHomeworldGameForAudit,
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';
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 } = {},
): Game {
let g = createGame(
'GRUMMANCOLLECTION',
newPlayer('m', 'Moritani', 'moritani'),
options.advanced ?? false,
[],
);
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('g', 'Harkonnen', 'harkonnen'));
g = applyAction(g, 'm', { type: 'homeworlds', enabled: true });
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
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,
});
}
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', 'g'],
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,
);
}
+304
View File
@@ -0,0 +1,304 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { GrummanCollection } from '../components/grumman-collection';
import { MoritaniEntry } from '../components/moritani-terror';
import { TerrorBoardMarkers } from '../components/terror-board-markers';
import {
createGame,
joinGame,
newPlayer,
viewGame,
type GameView,
} from '../game/engine';
import {
grummanCollectionActions,
grummanCollectionCanAct,
grummanCollectionChoice,
} from '../game/grumman-collection-options';
/** Private projected choices isolate client behavior. Engine and recovery suites
* cover actual collection entry, token custody and paid receipts. */
function fixture(): GameView {
const g = createGame(
'GRUMMANCONTROLS',
newPlayer('m', 'Moritani', 'moritani'),
);
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
Object.assign(g, {
status: 'playing',
phase: 7,
active: 'a',
order: ['m', 'a'],
storm: 18,
});
const view = viewGame(g, 'm');
view.decision = {
kind: 'grummanCollection',
player: 'm',
event: 'collection-1',
};
view.grummanCollection = {
event: 'collection-1',
player: 'm',
blocked: null,
removeBlocked: 'Removing a token awaits a custody ruling.',
tokens: [
{ id: 'terror-1', kind: 'robbery' },
{ id: 'terror-2', kind: 'sabotage' },
],
destinations: [
{ id: 'arrakeen', name: 'Arrakeen' },
{ id: 'carthag', name: 'Carthag' },
],
};
return view;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(
createElement(GrummanCollection, { game, busy, act() {} }),
);
}
function entryMarkup(game: GameView, busy = false) {
return renderToStaticMarkup(
createElement(MoritaniEntry, { game, busy, act() {} }),
);
}
void test('public map markers group hidden Terror stacks without reading token faces', () => {
const tokens = [
{ status: 'placed', location: 'arrakeen' },
{ status: 'placed', location: 'arrakeen' },
{ status: 'placed', location: 'carthag' },
{ status: 'available', location: null },
{ status: 'removed', location: null },
];
for (const token of tokens)
Object.defineProperty(token, 'kind', {
get() {
throw new Error('Map read a hidden Terror face');
},
});
const html = renderToStaticMarkup(
createElement('svg', null, createElement(TerrorBoardMarkers, { tokens })),
);
assert.equal(
(html.match(/aria-label="2 hidden Terror tokens in Arrakeen"/g) ?? [])
.length,
1,
);
assert.equal(
(html.match(/aria-label="1 hidden Terror token in Carthag"/g) ?? []).length,
1,
);
assert.equal((html.match(/<g /g) ?? []).length, 2);
assert.match(html, />T2<\/text>/);
assert.match(html, />T<\/text>/);
assert.doesNotMatch(html, /available|removed/);
});
void test('Grumman controls require an explicit available-token addition before the four-spice reward', () => {
const view = fixture();
const before = structuredClone(view);
assert.deepEqual(grummanCollectionChoice(view, 'terror-2', 'carthag'), {
blocked: null,
action: {
type: 'decision',
event: 'collection-1',
mode: 'add',
token: 'terror-2',
territory: 'carthag',
},
});
const html = markup(view);
assert.match(html, /Your Terror token/);
assert.match(html, /Robbery/);
assert.match(html, /Sabotage/);
assert.match(html, /only after the addition resolves/);
assert.match(html, /Add token and collect 4 spice/);
assert.match(
html,
/Removal unavailable: Removing a token awaits a custody ruling/,
);
assert.doesNotMatch(html, /<button[^>]*>Remove/);
assert.deepEqual(view, before);
});
void test('stale tokens or destinations cannot add; blocked or exhausted collection can still be declined', () => {
const view = fixture();
assert.equal(
grummanCollectionChoice(view, 'used-token', 'arrakeen').action,
null,
);
assert.equal(
grummanCollectionChoice(view, 'terror-1', 'polar_sink').action,
null,
);
for (const patch of [
{ blocked: 'Grumman is now low.' },
{ tokens: [] },
{ destinations: [] },
]) {
const blocked = {
...view,
grummanCollection: { ...view.grummanCollection!, ...patch },
};
assert.deepEqual(grummanCollectionActions(blocked), [
{ type: 'decision', event: 'collection-1', decline: true },
]);
const html = markup(blocked);
assert.match(
html,
/<button[^>]* disabled=""[^>]*>Add token and collect 4 spice<\/button>/,
);
assert.match(
html,
/<button(?![^>]* disabled="")[^>]*>Pass Grumman collection<\/button>/,
);
}
});
void test('Grumman policy follows only the current private offer across profiles and never reads another player', () => {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = fixture();
view.players[0].bot = profile;
Object.defineProperty(view, 'players', {
get() {
throw new Error('Policy read player data');
},
});
assert.deepEqual(grummanCollectionActions(view), [
{
type: 'decision',
event: 'collection-1',
mode: 'add',
token: 'terror-1',
territory: 'arrakeen',
},
]);
}
});
void test('collection ownership, event and interruption fences prevent stale or observer controls', () => {
const view = fixture();
assert.equal(grummanCollectionCanAct(view), true);
for (const patch of [
{ me: 'a' },
{ status: 'finished' },
{ decision: null },
{
decision: {
kind: 'grummanCollection',
player: 'a',
event: 'collection-1',
},
},
{
decision: { kind: 'grummanCollection', player: 'm', event: 'old-event' },
},
{ decision: { kind: 'moritaniPlacement', player: 'm' } },
{ response: { kind: 'capture', owner: 'a', passed: [] } },
{ truthtrance: { stage: 'answer' } },
{ phaseOpening: { kind: 'ix' } },
{ automaticContinuationPending: true },
]) {
const blocked = { ...view, ...patch } as GameView;
assert.equal(grummanCollectionCanAct(blocked), false);
assert.deepEqual(grummanCollectionActions(blocked), []);
assert.equal(
grummanCollectionChoice(blocked, 'terror-1', 'arrakeen').action,
null,
);
}
assert.equal(markup({ ...view, me: 'a' }), '');
assert.equal(markup({ ...view, grummanCollection: null }), '');
assert.equal(
(markup(view, true).match(/<button[^>]* disabled=""/g) ?? []).length,
2,
);
});
void test('stacked entry requires a private token selection before reaction and never consults a selected kind or outsider candidates', () => {
const view = fixture();
view.decision = {
kind: 'moritaniTerror',
player: 'm',
entrant: 'a',
territory: 'arrakeen',
};
view.terrorEntry = {
entrant: 'a',
territory: 'arrakeen',
sector: 10,
cause: 'shipment',
stage: 'select',
candidates: [
{
token: 'terror-1',
kind: 'robbery',
canReveal: true,
canOfferAlliance: true,
revealBlocked: undefined,
},
{
token: 'terror-2',
kind: 'atomics',
canReveal: false,
canOfferAlliance: false,
revealBlocked: 'Atomics awaits implementation.',
},
],
};
Object.defineProperty(view.terrorEntry, 'kind', {
get() {
throw new Error('Selected kind is unavailable');
},
});
const html = entryMarkup(view);
assert.match(html, /Select Robbery/);
assert.match(html, /Select Atomics/);
assert.match(html, /Selecting a token keeps its face private/);
assert.match(html, /Atomics awaits implementation/);
assert.match(html, /Leave all tokens hidden/);
assert.doesNotMatch(html, /Reveal Robbery|Take half their spice/);
for (const patch of [
{},
{ automaticContinuationPending: true },
{ phaseOpening: { kind: 'ix' } },
{ response: { kind: 'capture' } },
]) {
const blocked = { ...view, ...patch } as GameView;
assert.equal(
(
entryMarkup(blocked, !Object.keys(patch).length).match(
/<button[^>]* disabled=""/g,
) ?? []
).length,
3,
);
}
const outsider = {
...view,
me: 'a',
decision: { kind: 'moritaniTerror', player: 'a' },
} as GameView;
Object.defineProperty(outsider.terrorEntry!, 'candidates', {
get() {
throw new Error('Read hidden candidates');
},
});
assert.equal(entryMarkup(outsider), '');
assert.equal(
entryMarkup({
...outsider,
decision: {
kind: 'moritaniTerror',
player: 'm',
entrant: 'a',
territory: 'arrakeen',
},
}),
'',
);
});
+334
View File
@@ -0,0 +1,334 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { grummanCollectionActions } from '../game/grumman-collection-options';
import {
grummanCollectionFixture,
enterGrummanCollection,
grummanPlayer,
grummanReload,
grummanToken,
addGrummanToken,
holdGrummanCard,
stageGrummanArrival,
enterGrummanStack,
grummanReject,
grummanInventory,
} from './fixture-grumman-collection';
function stable(g: Game) {
grummanInventory(g);
assert.deepEqual(normalizeAutomaticGame(grummanReload(g)), g);
for (const p of g.players) {
const view = viewGame(grummanReload(g), p.id);
for (const other of view.players.filter((seat) => seat.id !== p.id)) {
assert.equal(other.hand, undefined);
assert.equal(other.spice, undefined);
}
}
}
function mentat(g: Game) {
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.phase, 8);
assert.equal(g.decision?.kind, 'moritaniPlacement');
return g;
}
function decide(g: Game, extra: Record<string, unknown>) {
return applyAction(grummanReload(g), 'm', { type: 'decision', ...extra });
}
void test('actual Basic and Advanced Collection opens at native eight, adds exactly one hidden token and pays four once', () => {
for (const advanced of [false, true]) {
const initial = grummanCollectionFixture({ advanced });
const tokens = structuredClone(initial.moritaniTerror);
let g = enterGrummanCollection(initial);
assert.equal(g.grummanCollection?.stage, 'choice');
assert.equal(grummanPlayer(g, 'm').spice, 20);
assert.deepEqual(g.moritaniTerror, tokens);
const event = g.grummanCollection!.event;
const token = grummanToken(g, 'sabotage').id;
stable(g);
g = addGrummanToken(grummanReload(g));
assert.equal(grummanPlayer(g, 'm').spice, 24);
assert.equal(g.grummanCollection?.outcome, 'add');
assert.equal(g.grummanCollection?.stage, 'complete');
assert.equal(g.moritaniTerror?.placementTurn, 1);
assert.deepEqual(
g.moritaniTerror?.tokens.filter((t) => t.id !== token),
tokens!.tokens.filter((t) => t.id !== token),
);
assert.deepEqual(grummanToken(g, 'sabotage'), {
id: token,
kind: 'sabotage',
status: 'placed',
location: 'arrakeen',
});
assert.equal(
g.moritaniTerror?.tokens.filter((t) => t.location === 'arrakeen').length,
2,
);
grummanReject(g, 'm', {
type: 'decision',
event,
mode: 'add',
token,
territory: 'arrakeen',
});
stable(g);
}
});
void test('low seven waits without income and expires on Mentat; a current high choice may decline after falling low', () => {
const low = enterGrummanCollection(grummanCollectionFixture({ native: 7 }));
assert.equal(low.grummanCollection?.stage, 'waiting');
assert.equal(low.decision, null);
assert.equal(viewGame(low, 'm').grummanCollection, null);
stable(low);
const expired = mentat(low);
assert.equal(expired.grummanCollection?.outcome, 'expired');
assert.equal(grummanPlayer(expired, 'm').spice, 20);
assert.deepEqual(expired.moritaniTerror, low.moritaniTerror);
stable(expired);
const high = enterGrummanCollection(grummanCollectionFixture());
// Conserved later population change tests use-time eligibility without
// fabricating a second Collection event or its payment.
grummanPlayer(high, 'm').reserves--;
grummanPlayer(high, 'm').forces['polar_sink:0']++;
const event = high.grummanCollection!.event;
assert.match(viewGame(high, 'm').grummanCollection!.blocked!, /eight native/);
grummanReject(
high,
'm',
{
type: 'decision',
event,
mode: 'add',
token: grummanToken(high, 'sabotage').id,
territory: 'arrakeen',
},
/eight native/,
);
const declined = decide(high, { event, decline: true });
assert.equal(declined.grummanCollection?.outcome, 'decline');
assert.equal(grummanPlayer(declined, 'm').spice, 20);
assert.deepEqual(declined.moritaniTerror, high.moritaniTerror);
stable(declined);
});
void test('owner event, physical availability, destination and pending removal rulings reject before token or spice mutation', () => {
const g = enterGrummanCollection(grummanCollectionFixture());
const event = g.grummanCollection!.event;
const token = grummanToken(g, 'sabotage').id;
for (const action of [
{ event: 'old-event', mode: 'add', token, territory: 'arrakeen' },
{
event,
mode: 'add',
token: grummanToken(g, 'robbery').id,
territory: 'arrakeen',
},
{ event, mode: 'add', token, territory: 'carthag' },
{ event, mode: 'remove', token: grummanToken(g, 'robbery').id },
{ event, decline: true, token },
])
grummanReject(g, 'm', { type: 'decision', ...action });
grummanReject(g, 'a', {
type: 'decision',
event,
mode: 'add',
token,
territory: 'arrakeen',
});
for (const id of ['a', 'g']) {
const view = viewGame(g, id);
assert.deepEqual(view.grummanCollection!.tokens, []);
assert.deepEqual(view.grummanCollection!.destinations, []);
assert.deepEqual(grummanCollectionActions(view), []);
assert.ok(view.moritaniTerror!.tokens.every((t) => !('kind' in t)));
}
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(g, 'm');
view.players.find((p) => p.id === 'm')!.bot = profile;
assert.deepEqual(botActions(view), grummanCollectionActions(view));
const played = applyAction(grummanReload(g), 'm', botActions(view)[0]);
assert.equal(grummanPlayer(played, 'm').spice, 24);
stable(played);
}
});
void test('Collection addition preserves the separate actual Mentat placement and its Karama response', () => {
const initial = grummanCollectionFixture();
holdGrummanCard(initial, 'a', 'karama');
let g = mentat(addGrummanToken(enterGrummanCollection(initial)));
const token = grummanToken(g, 'assassination').id;
g = decide(g, { token, territory: 'carthag' });
assert.equal(g.response?.kind, 'moritaniPlacement');
assert.equal(grummanToken(g, 'assassination').status, 'available');
for (let n = 0; g.response && n < 8; n++) {
const responder = g.players.find(
(p) => !g.response!.passed.includes(p.id),
)!;
g = applyAction(g, responder.id, { type: 'passResponse' });
}
assert.equal(g.response, null);
assert.equal(grummanToken(g, 'assassination').location, 'carthag');
assert.equal(g.moritaniTerror!.placementTurn, 2);
assert.equal(grummanPlayer(g, 'm').spice, 24);
assert.equal(g.grummanCollection?.outcome, 'add');
stable(g);
});
void test('actual stacked entry privately selects either Robbery or Sabotage and consumes only the selected token', () => {
for (const kind of ['robbery', 'sabotage'] as const) {
const initial = grummanCollectionFixture();
const shield = holdGrummanCard(initial, 'a', 'shield');
holdGrummanCard(initial, 'm', 'worthless');
const collection = addGrummanToken(enterGrummanCollection(initial));
let g = enterGrummanStack(stageGrummanArrival(collection));
assert.equal(g.pendingTerrorEntry?.stage, 'select');
assert.equal(g.pendingTerrorEntry?.amount, 1);
assert.equal(grummanPlayer(g, 'a').reserves, 19);
assert.equal(grummanPlayer(g, 'a').spice, 19);
const owned = viewGame(g, 'm').terrorEntry!;
assert.equal(owned.kind, undefined);
assert.equal(owned.candidates?.length, 2);
for (const id of ['a', 'g']) {
assert.equal(viewGame(g, id).terrorEntry?.candidates, undefined);
assert.equal(viewGame(g, id).terrorEntry?.kind, undefined);
}
stable(g);
grummanReject(g, 'm', { type: 'decision', reveal: true }, /Choose one/);
grummanReject(
g,
'm',
{ type: 'decision', token: grummanToken(g, 'atomics').id },
/original stacked/,
);
const allHidden = decide(g, { decline: true });
assert.equal(allHidden.pendingTerrorEntry, null);
assert.deepEqual(allHidden.moritaniTerror, g.moritaniTerror);
const token = grummanToken(g, kind).id;
const others = g.moritaniTerror!.tokens.filter((t) => t.id !== token);
g = decide(g, { token });
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
assert.equal(viewGame(g, 'm').terrorEntry?.kind, kind);
stable(g);
g = decide(g, { reveal: true });
if (kind === 'robbery') g = decide(g, { choice: 'spice' });
else {
assert.equal(g.pendingTerrorEntry?.stage, 'gift');
g = decide(g, { decline: true });
}
assert.equal(g.pendingTerrorEntry, null);
assert.equal(grummanToken(g, kind).status, 'removed');
assert.deepEqual(
g.moritaniTerror!.tokens.filter((t) => t.id !== token),
others,
);
assert.equal(grummanPlayer(g, 'a').forces['arrakeen:10'], 1);
assert.equal(grummanPlayer(g, 'a').reserves, 19);
assert.equal(grummanPlayer(g, 'm').spice, kind === 'robbery' ? 34 : 24);
assert.equal(
g.discard.filter((card) => card.id === shield.id).length,
kind === 'sabotage' ? 1 : 0,
);
grummanReject(g, 'm', { type: 'decision', reveal: true });
stable(g);
}
});
void test('Karama cancellation restores the selected stacked alliance offer without changing the other hidden token', () => {
const initial = grummanCollectionFixture();
const card = holdGrummanCard(initial, 'a', 'karama');
let g = enterGrummanStack(
stageGrummanArrival(addGrummanToken(enterGrummanCollection(initial))),
);
const token = grummanToken(g, 'robbery').id;
g = decide(g, { token });
const others = structuredClone(g.moritaniTerror);
g = decide(g, { alliance: true });
assert.equal(g.response?.kind, 'moritaniAlliance');
g = applyAction(grummanReload(g), 'a', {
type: 'card',
card: card.id,
mode: 'cancel',
});
assert.equal(g.response, null);
assert.equal(g.pendingTerrorEntry?.token, token);
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
assert.equal(viewGame(g, 'm').terrorEntry?.canOfferAlliance, false);
assert.deepEqual(g.moritaniTerror, others);
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
stable(g);
g = decide(g, { reveal: true });
g = decide(g, { choice: 'spice' });
assert.equal(grummanToken(g, 'sabotage').status, 'placed');
assert.equal(grummanToken(g, 'robbery').status, 'removed');
stable(g);
});
void test('signed collection and selected stack reject corrupted phase, completion, candidate or selection on reads and actions', () => {
const collection = enterGrummanCollection(grummanCollectionFixture());
const entry = decide(
enterGrummanStack(stageGrummanArrival(addGrummanToken(collection))),
{ token: grummanToken(collection, 'sabotage').id },
);
for (const [source, corrupt] of [
[
collection,
(g: Game) => {
g.grummanCollection!.stage = 'complete';
},
],
[
collection,
(g: Game) => {
g.grummanCollection!.turn++;
},
],
[
collection,
(g: Game) => {
g.decision = null;
},
],
[
entry,
(g: Game) => {
g.pendingTerrorEntry!.token = grummanToken(g, 'robbery').id;
},
],
[
entry,
(g: Game) => {
g.pendingTerrorEntry!.candidates!.pop();
},
],
] as const) {
const g = grummanReload(source);
corrupt(g);
const before = grummanReload(g);
for (const p of g.players)
assert.throws(
() => viewGame(g, p.id),
/Grumman|Terror|original|selection/i,
);
assert.throws(
() => normalizeAutomaticGame(g),
/Grumman|Terror|original|selection/i,
);
grummanReject(g, 'm', {
type: 'decision',
event: g.grummanCollection?.event,
decline: true,
});
assert.deepEqual(g, before);
}
});
+419
View File
@@ -0,0 +1,419 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash, webcrypto } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
import * as engine from '../game/engine';
import * as bots from '../game/bots';
import type * as Rooms from '../db/rooms';
import { homeworldGameIntegrity } from '../game/homeworld-game';
/** Execute the production room module and SQL, with a hook immediately before its CAS. */
function unitStore(runBots = bots.runBots) {
const sqlite = new DatabaseSync(':memory:');
const hooks: { beforeWrite?: () => Promise<void> } = {};
const writes: { expected: number; changes: number }[] = [];
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
.filter((f) => f.endsWith('.sql'))
.sort())
sqlite.exec(
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
);
class Statement {
values: (string | number | null)[] = [];
constructor(readonly sql: string) {}
bind(...values: (string | number | null)[]) {
this.values = values;
return this;
}
async first() {
return sqlite.prepare(this.sql).get(...this.values) ?? null;
}
async run() {
const continuation = this.sql.startsWith('UPDATE rooms SET state');
if (continuation) await hooks.beforeWrite?.();
const changes = Number(
sqlite.prepare(this.sql).run(...this.values).changes,
);
if (continuation)
writes.push({ expected: Number(this.values[3]), changes });
return { meta: { changes } };
}
}
const database = {
prepare: (sql: string) => new Statement(sql),
batch: async (statements: Statement[]) => {
sqlite.exec('BEGIN');
try {
const results = [];
for (const statement of statements) results.push(await statement.run());
sqlite.exec('COMMIT');
return results;
} catch (error) {
sqlite.exec('ROLLBACK');
throw error;
}
},
};
function loadRooms() {
const exports = {};
runInNewContext(
ts.transpileModule(
readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
{
exports,
crypto: webcrypto,
TextEncoder,
structuredClone,
// Keep persisted JSON values in the test realm for strict structural assertions.
JSON,
require: (name: string) => {
if (name === 'cloudflare:workers') return { env: { DB: database } };
if (name === '@/game/engine') return engine;
if (name === '@/game/bots') return { ...bots, runBots };
throw new Error('Unexpected module ' + name);
},
},
);
return exports as typeof Rooms;
}
return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes };
}
import {
grummanCollectionFixture, grummanInventory, grummanPlayer as p,
grummanReload as reload, grummanToken as token, holdGrummanCard,
stageGrummanArrival,
} from './fixture-grumman-collection';
type Game = engine.Game;
const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} };
async function persisted() {
const store = unitStore();
const made = await store.rooms.createRoom('Grumman Collection SQL QA', 'moritani', false, []);
const code = made.view.code;
const credentials = [made.token];
for (const faction of ['atreides', 'harkonnen'] as const) {
const joined = await store.rooms.joinRoom(code, faction, faction);
credentials.push(joined.token!);
}
const auths = await Promise.all(credentials.map((value) => store.rooms.authenticate(code, value)));
const initial = await store.rooms.readRoom(code);
let encoded = JSON.stringify(grummanCollectionFixture());
for (const [index, id] of ['m', 'a', 'g'].entries())
encoded = encoded.replaceAll(JSON.stringify(id), JSON.stringify(auths[index].playerId));
const g: Game = JSON.parse(encoded);
g.code = code;
g.host = auths[0].playerId;
g.version = initial.version;
holdGrummanCard(g, auths[0].playerId, 'worthless');
holdGrummanCard(g, auths[1].playerId, 'projectile');
assert.equal(g.grummanCollection, undefined);
const save = (state: Game) => store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(state), state.version, code);
save(g);
const other = await store.rooms.createRoom('Untouched Grumman room', 'fremen', false, []);
const otherBefore = await store.rooms.readRoom(other.view.code);
store.writes.length = 0;
return { ...store, code, credentials, auths, ids: auths.map((auth) => auth.playerId), initial: g,
save, otherCode: other.view.code, otherBefore };
}
type Fixture = Awaited<ReturnType<typeof persisted>>;
function snapshot(f: Fixture) {
const row = f.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(f.code)!;
return { version: row.version, hash: createHash('sha256').update(String(row.state)).digest('hex') };
}
async function restored(f: Fixture, g: Game) {
const rooms = f.restart();
const before = snapshot(f);
for (const [index, credential] of f.credentials.entries()) {
const auth = await rooms.authenticate(f.code, credential);
const view = await rooms.readSeatView(f.code, auth);
assert.deepEqual(view, engine.viewGame(g, f.ids[index]));
for (const opponent of view.players.filter((seat) => seat.id !== auth.playerId)) {
assert.equal(opponent.hand, undefined);
assert.equal(opponent.spice, undefined);
}
if (index !== 0) {
if (view.grummanCollection) {
assert.deepEqual(view.grummanCollection.tokens, []);
assert.deepEqual(view.grummanCollection.destinations, []);
}
assert.equal(view.terrorEntry?.candidates, undefined);
assert.equal(view.terrorEntry?.kind, undefined);
for (const placed of view.moritaniTerror!.tokens.filter((candidate) => candidate.status === 'placed'))
assert.equal('kind' in placed, false);
}
}
homeworldGameIntegrity(g);
grummanInventory(g);
assert.deepEqual(snapshot(f), before);
assert.deepEqual(await rooms.readRoom(f.otherCode), f.otherBefore);
return rooms;
}
async function act(f: Fixture, g: Game, actor: string, action: engine.Action) {
const rooms = f.restart();
await rooms.act(f.code, f.auths.find((auth) => auth.playerId === actor)!, g.version, action, clock);
return rooms.readRoom(f.code);
}
function barrier(f: Fixture) {
let count = 0;
let release!: () => void;
const wait = new Promise<void>((resolve) => { release = resolve; });
f.hooks.beforeWrite = async () => { if (++count === 2) release(); await wait; };
}
async function opened(f: Fixture) {
const g = await act(f, f.initial, f.initial.active!, { type: 'endMovement' });
assert.equal(g.phase, 7);
assert.equal(g.decision?.kind, 'grummanCollection');
assert.equal(g.grummanCollection?.stage, 'choice');
assert.equal(g.moritaniTerror!.placementTurn, 1);
await restored(f, g);
f.writes.length = 0;
return g;
}
function add(g: Game): engine.Action {
return { type: 'decision', event: g.grummanCollection!.event, mode: 'add', token: token(g, 'sabotage').id, territory: 'arrakeen' };
}
async function stacked(f: Fixture) {
const g = await opened(f);
const added = await act(f, g, f.ids[0], add(g));
const movement = stageGrummanArrival(added);
f.save(movement);
// The following actual paid shipment creates the original stack receipt.
const entered = await act(f, movement, f.ids[1], { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1 });
assert.equal(entered.pendingTerrorEntry?.stage, 'select');
assert.equal(entered.pendingTerrorEntry?.candidates?.length, 2);
assert.equal(entered.pendingTerrorEntry?.amount, 1);
assert.equal(entered.grummanCollection?.outcome, 'add');
await restored(f, entered);
f.writes.length = 0;
return entered;
}
void test('production SQL competing Grumman add and decline commit one token change and at most four spice', async () => {
const f = await persisted();
try {
const g = await opened(f);
const rooms = await restored(f, g);
barrier(f);
const outcomes = await Promise.allSettled([
rooms.act(f.code, f.auths[0], g.version, add(g), clock),
f.rooms.act(f.code, f.auths[0], g.version, { type: 'decision', event: g.grummanCollection!.event, decline: true }, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(outcomes.filter((outcome) => outcome.status === 'fulfilled').length, 1);
assert.deepEqual(f.writes.map((write) => write.changes).sort((a, b) => a - b), [0, 1]);
const done = await rooms.readRoom(f.code);
const added = done.grummanCollection!.outcome === 'add';
assert.equal(done.grummanCollection!.stage, 'complete');
assert.equal(done.version, g.version + 1);
assert.equal(p(done, f.ids[0]).spice, p(g, f.ids[0]).spice + (added ? 4 : 0));
assert.equal(token(done, 'sabotage').status, added ? 'placed' : 'available');
assert.deepEqual(token(done, 'robbery'), token(g, 'robbery'));
assert.equal(done.moritaniTerror!.placementTurn, 1);
const before = snapshot(f);
await assert.rejects(rooms.act(f.code, f.auths[0], done.version, add(g), clock));
await rooms.continueRoomAutomatic(f.code, clock);
assert.deepEqual(snapshot(f), before);
await restored(f, done);
} finally { f.sqlite.close(); }
});
void test('production SQL lost Grumman add response preserves four spice and independent same-turn Mentat placement', async () => {
const f = await persisted();
try {
const g = await opened(f);
await f.rooms.act(f.code, f.auths[0], g.version, add(g), clock);
const rooms = f.restart();
let done = await rooms.readRoom(f.code);
assert.equal(p(done, f.ids[0]).spice, p(g, f.ids[0]).spice + 4);
assert.equal(done.moritaniTerror!.tokens.filter((value) => value.location === 'arrakeen').length, 2);
assert.equal(done.moritaniTerror!.placementTurn, 1);
const before = snapshot(f);
await assert.rejects(rooms.act(f.code, f.auths[0], g.version, add(g), clock));
await assert.rejects(rooms.act(f.code, f.auths[0], done.version, add(g), clock));
await rooms.continueRoomAutomatic(f.code, clock);
assert.deepEqual(snapshot(f), before);
await restored(f, done);
for (const id of f.ids) {
if (done.phase !== 7) break;
done = await act(f, done, id, { type: 'ready' });
}
assert.equal(done.phase, 8);
assert.equal(done.decision?.kind, 'moritaniPlacement');
const extra = token(done, 'sneakAttack').id;
done = await act(f, done, f.ids[0], { type: 'decision', token: extra, territory: 'carthag' });
for (let n = 0; done.response?.kind === 'moritaniPlacement' && n < 20; n++)
done = await act(f, done, done.players.find((seat) => !done.response!.passed.includes(seat.id))!.id, { type: 'passResponse' });
assert.equal(token(done, 'sneakAttack').location, 'carthag');
assert.equal(done.moritaniTerror!.placementTurn, g.turn);
assert.equal(done.grummanCollection!.outcome, 'add');
assert.equal(p(done, f.ids[0]).spice, p(g, f.ids[0]).spice + 4);
await restored(f, done);
} finally { f.sqlite.close(); }
});
void test('production SQL guarded removal and declined Grumman choice never pay or change token custody', async () => {
const f = await persisted();
try {
const g = await opened(f);
const before = snapshot(f);
await assert.rejects(f.rooms.act(f.code, f.auths[0], g.version, {
type: 'decision', event: g.grummanCollection!.event, mode: 'remove', token: token(g, 'robbery').id,
}, clock));
assert.deepEqual(snapshot(f), before);
assert.equal(f.writes.length, 0);
const done = await act(f, g, f.ids[0], { type: 'decision', event: g.grummanCollection!.event, decline: true });
assert.equal(done.grummanCollection!.outcome, 'decline');
assert.deepEqual(done.moritaniTerror, g.moritaniTerror);
assert.equal(p(done, f.ids[0]).spice, p(g, f.ids[0]).spice);
await restored(f, done);
} finally { f.sqlite.close(); }
});
// Signatures are deterministic consistency receipts. These scenarios retain
// original evidence; they do not claim cryptographic protection from wholesale
// rewriting of every stored field and its matching receipt.
void test('production SQL Grumman phase and completion corruption rejects all seat views and writes', async () => {
const f = await persisted();
try {
const g = await opened(f);
const completed = await act(f, g, f.ids[0], add(g));
const cases: [Game, (state: Game) => void][] = [
[g, (state) => { delete state.grummanCollection; }],
[g, (state) => { state.decision = null; }],
[g, (state) => { state.grummanCollection!.event = 'another'; }],
[g, (state) => { state.grummanCollection!.player = f.ids[1]; }],
[g, (state) => { state.grummanCollection!.turn--; }],
[g, (state) => { state.grummanCollection!.signature = 'altered'; }],
[completed, (state) => { state.grummanCollection!.stage = 'choice'; delete state.grummanCollection!.outcome; delete state.grummanCollection!.token; delete state.grummanCollection!.territory; state.decision = { kind: 'grummanCollection', player: f.ids[0], event: g.grummanCollection!.event }; }],
[completed, (state) => { state.grummanCollection!.token = token(state, 'robbery').id; }],
[completed, (state) => { state.grummanCollection!.territory = 'carthag'; }],
];
for (const [original, mutate] of cases) {
const corrupt = reload(original);
mutate(corrupt);
f.save(corrupt);
f.writes.length = 0;
const before = snapshot(f);
const rooms = f.restart();
for (const auth of f.auths) await assert.rejects(rooms.readSeatView(f.code, auth));
await assert.rejects(rooms.act(f.code, f.auths[0], corrupt.version, add(g), clock));
assert.throws(() => engine.normalizeAutomaticGame(corrupt));
assert.deepEqual(snapshot(f), before);
assert.equal(f.writes.length, 0);
assert.deepEqual(await rooms.readRoom(f.otherCode), f.otherBefore);
}
} finally { f.sqlite.close(); }
});
void test('production SQL two competing stacked-token choices preserve one selected identity and original paid entry', async () => {
const f = await persisted();
try {
const g = await stacked(f);
const candidates = g.pendingTerrorEntry!.candidates!;
const rooms = await restored(f, g);
barrier(f);
const results = await Promise.allSettled(candidates.map((id) =>
rooms.act(f.code, f.auths[0], g.version, { type: 'decision', token: id }, clock)));
delete f.hooks.beforeWrite;
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1);
assert.deepEqual(f.writes.map((write) => write.changes).sort((a, b) => a - b), [0, 1]);
const selected = await rooms.readRoom(f.code);
assert.equal(selected.pendingTerrorEntry?.stage, 'offer');
assert.ok(candidates.includes(selected.pendingTerrorEntry!.token));
for (const field of ['candidates', 'entrant', 'territory', 'sector', 'amount', 'elite', 'cause', 'turn', 'phase', 'resume'] as const)
assert.deepEqual(selected.pendingTerrorEntry![field], g.pendingTerrorEntry![field]);
assert.equal(typeof selected.pendingTerrorEntry?.selectionSignature, 'string');
assert.deepEqual(selected.moritaniTerror, g.moritaniTerror);
assert.equal(p(selected, f.ids[1]).reserves, p(g, f.ids[1]).reserves);
assert.deepEqual(p(selected, f.ids[1]).forces, p(g, f.ids[1]).forces);
assert.equal(p(selected, f.ids[1]).spice, p(g, f.ids[1]).spice);
await restored(f, selected);
const done = await act(f, selected, f.ids[0], { type: 'decision', decline: true });
assert.equal(done.pendingTerrorEntry, null);
assert.deepEqual(done.moritaniTerror, g.moritaniTerror);
const before = snapshot(f);
await assert.rejects(rooms.act(f.code, f.auths[0], done.version, { type: 'decision', token: candidates[1] }, clock));
await rooms.continueRoomAutomatic(f.code, clock);
assert.deepEqual(snapshot(f), before);
await restored(f, done);
} finally { f.sqlite.close(); }
});
void test('production SQL selected Robbery reveals only that token and resolves one income after restart', async () => {
const f = await persisted();
try {
let g = await stacked(f);
const original = reload(g);
g = await act(f, g, f.ids[0], { type: 'decision', token: token(g, 'robbery').id });
await restored(f, g);
g = await act(f, g, f.ids[0], { type: 'decision', reveal: true });
assert.equal(g.pendingTerrorEntry?.stage, 'robbery');
assert.equal(token(g, 'robbery').status, 'removed');
assert.deepEqual(token(g, 'sabotage'), token(original, 'sabotage'));
const rooms = await restored(f, g);
const expected = Math.ceil(p(g, f.ids[1]).spice / 2);
await rooms.act(f.code, f.auths[0], g.version, { type: 'decision', choice: 'spice' }, clock);
const done = await f.restart().readRoom(f.code);
assert.equal(done.pendingTerrorEntry, null);
assert.equal(p(done, f.ids[0]).spice, p(g, f.ids[0]).spice + expected);
assert.equal(p(done, f.ids[1]).spice, p(g, f.ids[1]).spice - expected);
assert.equal(token(done, 'sabotage').status, 'placed');
assert.deepEqual(p(done, f.ids[1]).forces, p(original, f.ids[1]).forces);
const before = snapshot(f);
await assert.rejects(rooms.act(f.code, f.auths[0], done.version, { type: 'decision', choice: 'spice' }, clock));
await rooms.continueRoomAutomatic(f.code, clock);
assert.deepEqual(snapshot(f), before);
await restored(f, done);
} finally { f.sqlite.close(); }
});
void test('production SQL stacked original candidates, selected identity and physical custody cannot be edited across reload', async () => {
const f = await persisted();
try {
const g = await stacked(f);
const selected = await act(f, g, f.ids[0], { type: 'decision', token: token(g, 'sabotage').id });
const cases: [Game, (state: Game) => void][] = [
[g, (state) => { state.pendingTerrorEntry!.candidates!.reverse(); }],
[g, (state) => { state.pendingTerrorEntry!.candidates!.push(state.pendingTerrorEntry!.candidates![0]); }],
[g, (state) => { delete state.pendingTerrorEntry!.candidates; }],
[g, (state) => { state.pendingTerrorEntry!.amount++; }],
[g, (state) => { token(state, 'sabotage').location = 'carthag'; }],
[g, (state) => { token(state, 'sabotage').status = 'removed'; token(state, 'sabotage').location = null; }],
[selected, (state) => { state.pendingTerrorEntry!.token = token(state, 'robbery').id; }],
[selected, (state) => { delete state.pendingTerrorEntry!.selectionSignature; }],
[selected, (state) => { state.pendingTerrorEntry!.selectionSignature = 'another-selection'; }],
[selected, (state) => {
state.pendingTerrorEntry!.stage = 'select';
state.pendingTerrorEntry!.token = state.pendingTerrorEntry!.candidates![0];
delete state.pendingTerrorEntry!.selectionSignature;
}],
];
for (const [original, mutate] of cases) {
const corrupt = reload(original);
mutate(corrupt);
f.save(corrupt);
f.writes.length = 0;
const before = snapshot(f);
const rooms = f.restart();
for (const auth of f.auths) await assert.rejects(rooms.readSeatView(f.code, auth));
await assert.rejects(rooms.act(f.code, f.auths[0], corrupt.version, { type: 'decision', decline: true }, clock));
assert.throws(() => engine.normalizeAutomaticGame(corrupt));
assert.deepEqual(snapshot(f), before);
assert.equal(f.writes.length, 0);
assert.deepEqual(await rooms.readRoom(f.otherCode), f.otherBefore);
}
} finally { f.sqlite.close(); }
});
+257
View File
@@ -0,0 +1,257 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { newPlayer } from '../game/engine';
import { createHomeworldCustody } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import {
quoteGrummanCollection,
quoteGrummanCollectionAction,
type GrummanCollectionAction,
type GrummanCollectionContext,
} from '../game/grumman-collection';
import {
createTerrorState,
placeTerror,
projectTerror,
revealTerror,
TERROR_KINDS,
type TerrorState,
} from '../game/moritani-terror';
function fixture(native = 8, advanced = true): GrummanCollectionContext {
const players = [
newPlayer('m', 'Moritani', 'moritani'),
newPlayer('v', 'Visitor', 'harkonnen'),
];
players[0].reserves = native;
const context = { advanced, players };
const state = placeTerror(
createTerrorState(() => 0),
'terror-1',
'arrakeen',
2,
);
state.supplyEpoch = 4;
return {
...context,
homeworlds: { custody: createHomeworldCustody(homeworldContext(context)) },
moritaniTerror: state,
};
}
const add = {
mode: 'add',
token: 'terror-2',
destination: 'arrakeen',
} as const;
void test('Grumman quotes the exact current high threshold in both rules modes; foreign visitors never qualify', () => {
for (const advanced of [false, true])
for (const native of [0, 7, 8, 20]) {
const g = fixture(native, advanced);
g.players[1].reserves = 0;
g.homeworlds!.custody!.visitors['homeworld:moritani'] = {
v: { normal: 20, elite: 0 },
};
const quote = quoteGrummanCollection(g, 'm');
assert.equal(quote.population, native);
assert.equal(quote.high, native >= 8);
assert.equal(quote.blocked === null, native >= 8);
assert.deepEqual(quote.destinations, native >= 8 ? ['arrakeen'] : []);
assert.equal(quote.tokens.length, native >= 8 ? 5 : 0);
}
const g = fixture();
delete g.homeworlds;
assert.equal(quoteGrummanCollection(g, 'm').population, null);
assert.throws(() => quoteGrummanCollectionAction(g, 'm', add));
assert.throws(() => quoteGrummanCollection(g, 'v'));
});
void test('one available token adds to an existing stack for four bank spice without consuming Mentat or changing identities', () => {
const g = fixture();
const original = structuredClone(g);
const first = quoteGrummanCollectionAction(g, 'm', add);
assert.equal(first.amount, 4);
assert.equal(first.state.placementTurn, 2);
assert.equal(first.state.supplyEpoch, 4);
assert.deepEqual(g, original);
assert.equal(
first.state.tokens.filter((token) => token.location === 'arrakeen').length,
2,
);
assert.deepEqual(
first.state.tokens.map((token) => token.id),
g.moritaniTerror!.tokens.map((token) => token.id),
);
assert.deepEqual(
first.state.tokens.map((token) => token.kind).sort(),
[...TERROR_KINDS].sort(),
);
assert.ok(
first.state.tokens.every(
(token, index) => token !== g.moritaniTerror!.tokens[index],
),
);
// A subsequent independent opportunity can add to an existing stack, while
// ordinary Mentat still requires an empty stronghold and its own turn.
g.moritaniTerror = first.state;
const second = quoteGrummanCollectionAction(g, 'm', {
...add,
token: 'terror-3',
});
assert.equal(
second.state.tokens.filter((token) => token.location === 'arrakeen').length,
3,
);
assert.throws(() => placeTerror(second.state, 'terror-4', 'arrakeen', 3));
assert.equal(
placeTerror(second.state, 'terror-4', 'carthag', 3).placementTurn,
3,
);
assert.deepEqual(JSON.parse(JSON.stringify(second.state)), second.state);
});
void test('offers need available supply and an existing ordinary stronghold token; storm is not a placement input', () => {
const g = fixture();
Object.defineProperty(g, 'storm', {
get() {
throw new Error('Storm is irrelevant to Terror placement');
},
});
assert.equal(quoteGrummanCollectionAction(g, 'm', add).amount, 4);
for (const destination of [
'carthag',
'homeworld:moritani',
'mobile_stronghold',
'arrakeen:10',
'hagga_basin',
])
assert.throws(() =>
quoteGrummanCollectionAction(g, 'm', { ...add, destination }),
);
g.moritaniTerror = createTerrorState(() => 0);
assert.match(quoteGrummanCollection(g, 'm').blocked!, /already containing/);
g.moritaniTerror.tokens.forEach((token) => {
token.status = 'placed';
token.location = 'arrakeen';
});
assert.match(quoteGrummanCollection(g, 'm').blocked!, /no available/);
});
void test('decline gives zero with cloned unchanged custody; unresolved removal never spends or reveals a token', () => {
for (const native of [7, 8]) {
const g = fixture(native);
const before = JSON.stringify(g);
const declined = quoteGrummanCollectionAction(g, 'm', { mode: 'decline' });
assert.equal(declined.amount, 0);
assert.deepEqual(declined.state, g.moritaniTerror);
assert.notEqual(declined.state, g.moritaniTerror);
assert.match(quoteGrummanCollection(g, 'm').removeBlocked, /custody zone/);
for (const token of ['terror-1', 'terror-2', 'atomics-aftermath', 'absent'])
assert.throws(
() => quoteGrummanCollectionAction(g, 'm', { mode: 'remove', token }),
/custody zone/,
);
assert.equal(JSON.stringify(g), before);
}
});
void test('adding never relocates, recovers a spent token or accepts stale high eligibility', () => {
const g = fixture();
const offered = quoteGrummanCollection(g, 'm');
offered.tokens[0].status = 'removed';
assert.equal(g.moritaniTerror!.tokens[1].status, 'available');
g.players[0].reserves = 7;
assert.throws(() => quoteGrummanCollectionAction(g, 'm', add));
g.players[0].reserves = 8;
for (const token of ['terror-1', 'missing'])
assert.throws(() =>
quoteGrummanCollectionAction(g, 'm', { ...add, token }),
);
g.moritaniTerror = revealTerror(g.moritaniTerror!, 'terror-1');
assert.throws(() =>
quoteGrummanCollectionAction(g, 'm', { ...add, token: 'terror-1' }),
);
});
void test('malformed physical inventories and operation shapes reject without mutation', () => {
const original = fixture();
const corruptions: ((s: TerrorState) => void)[] = [
(s) => {
s.tokens.pop();
},
(s) => {
s.tokens[1].id = s.tokens[0].id;
},
(s) => {
s.tokens[1].kind = s.tokens[0].kind;
},
(s) => {
s.tokens[1].location = 'carthag';
},
(s) => {
s.tokens[0].location = 'homeworld:moritani';
},
(s) => {
s.tokens[0].status = 'removed';
},
(s) => {
s.tokens.find((t) => t.kind !== 'extortion')!.status = 'extortion';
},
(s) => {
s.supplyEpoch = -1;
},
(s) => {
s.placementTurn = 1.5;
},
];
for (const corrupt of corruptions) {
const g = structuredClone(original);
corrupt(g.moritaniTerror!);
const before = JSON.stringify(g);
assert.throws(() => quoteGrummanCollection(g, 'm'));
assert.throws(() => quoteGrummanCollectionAction(g, 'm', add));
assert.equal(JSON.stringify(g), before);
}
for (const action of [
null,
{},
{ mode: 'add' },
{ ...add, amount: 4 },
{ mode: 'decline', token: 'terror-2' },
])
assert.throws(() =>
quoteGrummanCollectionAction(
original,
'm',
action as GrummanCollectionAction,
),
);
});
void test('the owner quote reads no hands, balances or force positions and stacked public views retain hidden faces', () => {
const g = fixture();
for (const player of g.players)
for (const field of [
'hand',
'spice',
'tanks',
'forces',
'traitors',
'noField',
])
Object.defineProperty(player, field, {
get() {
throw new Error(`Private ${field}`);
},
});
const result = quoteGrummanCollectionAction(g, 'm', add);
const view = projectTerror(result.state, false);
assert.deepEqual(view.tokens, [
{ id: 'terror-1', location: 'arrakeen', status: 'placed' },
{ id: 'terror-2', location: 'arrakeen', status: 'placed' },
]);
for (const token of g.moritaniTerror!.tokens) Object.freeze(token);
Object.freeze(g.moritaniTerror!.tokens);
Object.freeze(g.moritaniTerror);
assert.equal(quoteGrummanCollectionAction(g, 'm', add).amount, 4);
});
+205
View File
@@ -0,0 +1,205 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
import { createHomeworldCustody } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import { quoteGrummanCollectionAction } from '../game/grumman-collection';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function entered(victimCard = false) {
let g = createGame('STACKREVIEW', newPlayer('m', 'Moritani', 'moritani'));
g.players.push(
newPlayer('e', 'Entrant', 'emperor'),
newPlayer('a', 'Observer', 'atreides'),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
active: 'e',
order: ['e', 'm', 'a'],
movementRemaining: ['e', 'm', 'a'],
deck: baseDeck(),
});
for (const p of g.players) {
p.forces = {};
p.reserves = 20;
p.spice = 20;
p.hand = [];
}
g.players[1].elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 };
const initial = createTerrorState(() => 0);
const robbery = initial.tokens.find((t) => t.kind === 'robbery')!.id;
const sabotage = initial.tokens.find((t) => t.kind === 'sabotage')!.id;
g.moritaniTerror = placeTerror(initial, robbery, 'arrakeen', 1);
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
// Focused phase-five audit setup: the stack itself uses the physical quote;
// phase-seven creation/once-only award is covered by the collection suites.
g.moritaniTerror = quoteGrummanCollectionAction(g, 'm', {
mode: 'add',
token: sabotage,
destination: 'arrakeen',
}).state;
if (victimCard) g.players[1].hand.push(g.deck.shift()!);
g = applyAction(g, 'e', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 2,
});
assert.equal(g.pendingTerrorEntry?.stage, 'select');
return { g, robbery, sabotage };
}
function rejected(g: Game, action: Action) {
const before = reload(g);
assert.throws(() => viewGame(g, 'm'));
assert.throws(() => normalizeAutomaticGame(g));
assert.throws(() => applyAction(g, 'm', action));
assert.deepEqual(g, before);
}
void test('a selected actual stacked entry cannot rewind, delete its selection proof or switch token after JSON recovery', () => {
const { g: initial, sabotage, robbery } = entered();
const selected = applyAction(initial, 'm', {
type: 'decision',
token: sabotage,
});
assert.equal(selected.pendingTerrorEntry?.stage, 'offer');
assert.equal(selected.pendingTerrorEntry?.token, sabotage);
assert.deepEqual(normalizeAutomaticGame(reload(selected)), reload(selected));
assert.equal(viewGame(selected, 'm').terrorEntry?.kind, 'sabotage');
assert.equal('kind' in viewGame(selected, 'a').terrorEntry!, false);
assert.equal('candidates' in viewGame(selected, 'a').terrorEntry!, false);
for (const edit of [
(g: Game) => {
g.pendingTerrorEntry!.stage = 'select';
g.pendingTerrorEntry!.token = g.pendingTerrorEntry!.candidates![0];
delete g.pendingTerrorEntry!.selectionSignature;
},
(g: Game) => {
delete g.pendingTerrorEntry!.selectionSignature;
},
(g: Game) => {
g.pendingTerrorEntry!.token = robbery;
},
(g: Game) => {
delete g.pendingTerrorEntry!.candidates;
},
]) {
const g = reload(selected);
edit(g);
rejected(g, { type: 'decision', token: robbery });
}
});
void test('unselected physical stack tokens stay reserved before and after the chosen token is revealed', () => {
const { g: initial, sabotage, robbery } = entered();
const selected = applyAction(initial, 'm', {
type: 'decision',
token: robbery,
});
const revealed = applyAction(reload(selected), 'm', {
type: 'decision',
reveal: true,
});
assert.equal(revealed.pendingTerrorEntry?.stage, 'robbery');
assert.equal(
revealed.moritaniTerror!.tokens.find((t) => t.id === robbery)!.status,
'removed',
);
for (const source of [initial, selected, revealed]) {
const g = reload(source);
g.moritaniTerror!.tokens.find((t) => t.id === sabotage)!.location =
'carthag';
rejected(g, { type: 'decision', decline: true });
}
assert.doesNotThrow(() => viewGame(revealed, 'm'));
});
void test('an actual stacked Sabotage inner-dispatch discard restores its consumed selection while preserving every other token', () => {
const { g: initial, sabotage, robbery } = entered(true);
const arrived = applyAction(initial, 'm', {
type: 'decision',
token: sabotage,
});
const victimCard = arrived.players[1].hand[0].id;
// Capture the genuine durable batch before the public action wrapper drains
// it. This verifies saved-continuation restoration, not a claimed HTTP pause.
const observed: {
applyActionInner?: (g: Game, id: string, action: Action) => Game;
} = {};
runInNewContext(
ts.transpileModule(
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
'\nexport { applyActionInner };\n',
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
{
exports: observed,
require: createRequire(new URL('../game/engine.ts', import.meta.url)),
crypto,
structuredClone,
TextEncoder,
JSON,
},
);
const pending = reload(
observed.applyActionInner!(arrived, 'm', {
type: 'decision',
reveal: true,
}),
);
const continuation = pending.pendingTreacheryDiscard!.continuation;
assert.equal(continuation.kind, 'terrorDiscard');
if (continuation.kind !== 'terrorDiscard')
throw new Error('Missing actual Terror discard');
assert.equal(continuation.entry.stage, 'offer');
assert.equal(continuation.entry.token, sabotage);
assert.equal(
pending.moritaniTerror!.tokens.find((t) => t.id === sabotage)!.status,
'removed',
);
assert.doesNotThrow(() => viewGame(pending, 'm'));
const finished = normalizeAutomaticGame(reload(pending));
assert.equal(finished.pendingTreacheryDiscard, null);
assert.equal(finished.pendingTerrorEntry, null);
assert.equal(
finished.discard.filter((card) => card.id === victimCard).length,
1,
);
assert.equal(finished.players[1].hand.length, 0);
assert.equal(
finished.moritaniTerror!.tokens.find((t) => t.id === robbery)!.location,
'arrakeen',
);
assert.deepEqual(normalizeAutomaticGame(reload(finished)), reload(finished));
for (const tokenId of [robbery, sabotage]) {
const corrupted = reload(pending);
const token = corrupted.moritaniTerror!.tokens.find(
(t) => t.id === tokenId,
)!;
token.status = 'available';
token.location = null;
rejected(corrupted, { type: 'advanceBots' });
}
});