Files
Dune/tests/grumman-collection-controls.test.ts

281 lines
7.8 KiB
TypeScript

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: 'No placed Terror token is available to return.',
removable: [],
cunning: null,
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('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',
},
}),
'',
);
});