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:
1 parent
28ba1618ba
commit
1db11a0187
27 files changed
+2451
-43
No files matched your search
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user