281 lines
7.8 KiB
TypeScript
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',
|
|
},
|
|
}),
|
|
'',
|
|
);
|
|
});
|