180 lines
9.3 KiB
TypeScript
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();
|