Files
Dune/tests/nexus-harkonnen-betrayal-controls.test.tsx

180 lines
9.3 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { Children, createElement, isValidElement } from 'react';
import type { ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type Action, type GameView } from '../game/engine';
import { tableActionOwner } from '../game/table-turn';
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
import { createHarkonnenNexusBetrayalFixture } from './fixture-nexus-harkonnen-betrayal';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
// Next/CSS loading hooks must precede the UI imports.
const { NexusHarkonnenBetrayal } = await import('../components/nexus-harkonnen-betrayal');
const { GameTable } = await import('../components/game-table');
function click(node: ReactNode, selector: string): boolean {
for (const child of Children.toArray(node)) {
if (!isValidElement<{ children?: ReactNode; onClick?: () => void; 'data-testid'?: string }>(child)) continue;
if (child.props['data-testid'] === selector && child.props.onClick) {
child.props.onClick();
return true;
}
if (click(child.props.children, selector)) return true;
}
return false;
}
function select(game: GameView, choice: 'use' | 'pass', busy = false): Action | undefined {
let action: Action | undefined;
click(NexusHarkonnenBetrayal({ game, busy, act(value) { action = value; } }), `nexus-harkonnen-betrayal-${choice}`);
return action;
}
function markup(game: GameView): string {
return renderToStaticMarkup(createElement(NexusHarkonnenBetrayal, { game, busy: false, act() {} }));
}
function table(game: GameView): string {
return renderToStaticMarkup(createElement(GameTable, { game, busy: false, send: async () => {}, onExit() {} }));
}
const scenarios = [
{ advanced: false, remote: false },
{ advanced: true, remote: false },
{ advanced: false, remote: true },
{ advanced: true, remote: true },
] as const;
for (const scenario of scenarios) {
void test(`${scenario.advanced ? 'Advanced' : 'Basic'} ${scenario.remote ? 'allied' : 'personal'} human controls cancel the actual declared card or preserve it with Pass`, () => {
const f = createHarkonnenNexusBetrayalFixture(scenario);
const own = viewGame(f.game, f.holder);
const use = select(own, 'use');
assert.ok(use);
assert.deepEqual(use, { type: 'nexusHarkonnenBetrayalUse', event: f.event });
const denied = applyAction(f.game, f.holder, use);
assert.equal(viewGame(denied, f.holder).nexusHarkonnenBetrayalReaction, null);
assert.equal(denied.players.find(p => p.id === f.provider)!.traitors.includes(f.identity), false);
assert.equal(denied.nexusCards!.cards!.hands[f.holder], null);
assert.equal(denied.pendingNexusHarkonnenReplacement!.identity, f.identity);
assert.equal(select(viewGame(denied, f.holder), 'use'), undefined);
assert.throws(() => applyAction(denied, f.holder, use));
let allowed = f.game;
for (const id of f.required) {
const pass = select(viewGame(allowed, id), 'pass');
assert.ok(pass);
assert.deepEqual(pass, { type: 'nexusHarkonnenBetrayalPass', event: f.event });
allowed = applyAction(allowed, id, pass);
}
assert.equal(viewGame(allowed, f.holder).nexusHarkonnenBetrayalReaction, null);
assert.equal(allowed.players.find(p => p.id === f.provider)!.traitors.includes(f.identity), true);
assert.equal(allowed.nexusCards!.cards!.hands[f.holder], 'harkonnen');
assert.equal(allowed.pendingNexusHarkonnenReplacement ?? null, null);
});
}
void test('the public declaration names only the actual card and roles; inspectors remain private to their own held face', () => {
for (const scenario of scenarios) {
const f = createHarkonnenNexusBetrayalFixture(scenario);
const traitorName = viewGame(f.game, f.holder).allLeaders.find(l => l.id === f.identity)!.name;
for (const seat of f.game.players) {
const own = viewGame(f.game, seat.id);
const html = markup(own);
for (const id of [f.provider, f.beneficiary, f.target]) assert.ok(html.includes(f.game.players.find(p => p.id === id)!.name));
assert.ok(html.includes(traitorName));
if (own.nexusCards?.card === 'harkonnen') assert.ok(html.includes('Inspect Harkonnen Nexus card'));
else assert.equal(html.includes('Inspect Harkonnen Nexus card'), false);
if (seat.id !== f.provider) {
for (const hidden of f.game.players.find(p => p.id === f.provider)!.traitors.filter(id => id !== f.identity)) {
const hiddenName = own.allLeaders.find(l => l.id === hidden)!.name;
assert.equal(html.includes(hiddenName), false);
}
}
if (!f.required.includes(seat.id)) {
assert.equal(select(own, 'use'), undefined);
assert.equal(select(own, 'pass'), undefined);
assert.equal(own.nexusHarkonnenBetrayalReaction!.blocked, null);
}
}
const wrong = createHarkonnenNexusBetrayalFixture({ ...scenario, face: 'richese' });
const ownWrong = viewGame(wrong.game, wrong.holder);
assert.equal(ownWrong.nexusHarkonnenBetrayalReaction!.canPass, true);
assert.equal(ownWrong.nexusHarkonnenBetrayalReaction!.blocked, null);
assert.equal(select(ownWrong, 'use'), undefined);
assert.ok(select(ownWrong, 'pass'));
assert.equal(markup(ownWrong).includes('Inspect Harkonnen Nexus card'), false);
assert.ok(markup(ownWrong).includes('Inspect Richese Nexus card'));
}
});
void test('partial acknowledgements use the sole public unfinished seat, not the provider or private face', () => {
const f = createHarkonnenNexusBetrayalFixture({ receiverCount: 2 });
for (const seat of f.game.players) assert.equal(tableActionOwner(viewGame(f.game, seat.id)), null);
const first = f.required[0];
const pass = select(viewGame(f.game, first), 'pass');
assert.ok(pass);
const next = applyAction(f.game, first, pass);
for (const seat of next.players) assert.equal(tableActionOwner(viewGame(next, seat.id)), f.required[1]);
const refreshed = viewGame(structuredClone(next), first);
assert.equal(select(refreshed, 'pass'), undefined);
assert.equal(select(refreshed, 'use'), undefined);
assert.equal(select(viewGame(next, f.provider), 'use'), undefined);
});
void test('table reserves the source and dispatch honors own response, room, transport and autopilot locks', () => {
for (const scenario of scenarios) {
const f = createHarkonnenNexusBetrayalFixture(scenario);
const own: GameView = viewGame(f.game, f.holder);
const html = table(own);
assert.ok(html.includes('Declared Harkonnen traitor acknowledgement'));
assert.equal(html.includes('Reveal traitor'), false);
assert.equal(html.includes('No traitor'), false);
assert.equal(html.includes('Make a bribe'), false);
for (const restriction of ['paused', 'closed', 'autopilot', 'busy', 'automatic'] as const) {
const blocked = structuredClone(own);
if (restriction === 'autopilot') blocked.players.find(p => p.id === own.me)!.autopilot = 'Easy';
else if (restriction === 'automatic') blocked.automaticContinuationPending = true;
else if (restriction !== 'busy') blocked.roomControl = { ...DEFAULT_ROOM_CONTROL, [restriction]: true };
for (const choice of ['use', 'pass'] as const) assert.equal(select(blocked, choice, restriction === 'busy'), undefined);
}
const blocked = structuredClone(own);
blocked.nexusHarkonnenBetrayalReaction!.blocked = 'A committed promise reserves this Nexus card.';
assert.equal(select(blocked, 'use'), undefined);
assert.ok(select(blocked, 'pass'));
if (scenario.remote) {
const native = viewGame(f.beforeNativeCounter, f.holder);
assert.ok(native.response);
assert.equal(native.nexusHarkonnenBetrayalReaction, null);
assert.equal(select(native, 'use'), undefined);
}
}
});
void test('every seat independently delegates and takes back control without changing declaration or custody', () => {
const f = createHarkonnenNexusBetrayalFixture({ receiverCount: 2 });
for (const seat of f.game.players) {
const delegated = applyAction(f.game, seat.id, { type: 'setAutopilot', difficulty: 'Hard' });
const own = viewGame(delegated, seat.id);
assert.ok(table(own).includes('Take back control'));
assert.equal(select(own, 'use'), undefined);
assert.equal(select(own, 'pass'), undefined);
const resumed = applyAction(delegated, seat.id, { type: 'setAutopilot', difficulty: null });
assert.equal(resumed.players.find(p => p.id === seat.id)!.autopilot, undefined);
assert.deepEqual(resumed.pendingNexusHarkonnenBetrayal, f.game.pendingNexusHarkonnenBetrayal);
assert.deepEqual(resumed.traitorReserve, f.game.traitorReserve);
assert.deepEqual(resumed.players.map(p => p.traitors), f.game.players.map(p => p.traitors));
}
});
aliases.deregister();