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 } from '../game/engine'; import type { Action, Game, GameView } from '../game/engine'; import { tableActionOwner } from '../game/table-turn'; import { DEFAULT_ROOM_CONTROL } from '../lib/room-control'; import { createIxianNexusBetrayalFixture } from './fixture-nexus-ixian-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 }; }, }); // Static UI imports would resolve before these Node-only Next/CSS hooks are // installed; these imports intentionally exercise the platform loading boundary. const { NexusIxianBetrayal } = await import('../components/nexus-ixian-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(NexusIxianBetrayal({ game, busy, act(value) { action = value; } }), `nexus-ixian-betrayal-${choice}`); return action; } function markup(game: GameView): string { return renderToStaticMarkup(createElement(NexusIxianBetrayal, { game, busy: false, act() {} })); } function table(game: GameView, busy = false): string { return renderToStaticMarkup(createElement(GameTable, { game, busy, send: async () => {}, onExit() {} })); } function passAll(original: Game): Game { let game = structuredClone(original); for (const seat of game.players) { const action = select(viewGame(game, seat.id), 'pass'); if (action) game = applyAction(game, seat.id, action); } return game; } const scenarios = [ { kind: 'bidding', advanced: false }, { kind: 'bidding', advanced: true }, { kind: 'technology', advanced: true }, ] as const; for (const scenario of scenarios) { void test(`${scenario.advanced ? 'Advanced' : 'Basic'} ${scenario.kind} human Use closes exactly the native advantage; Pass resumes it`, () => { const fixture = createIxianNexusBetrayalFixture(scenario); const own = viewGame(fixture.game, fixture.holder); const action = select(own, 'use'); assert.deepEqual(action, { type: 'nexusIxianBetrayalUse', event: fixture.event }); assert.ok(action); const denied = applyAction(fixture.game, fixture.holder, action); const allowed = passAll(fixture.game); assert.equal(viewGame(denied, fixture.holder).nexusIxianBetrayalReaction, null); assert.equal(viewGame(allowed, fixture.holder).nexusIxianBetrayalReaction, null); assert.equal(denied.nexusCards!.cards!.hands[fixture.holder], null); assert.equal(allowed.nexusCards!.cards!.hands[fixture.holder], 'ixians'); assert.deepEqual(denied.players.map(seat => ({ id: seat.id, spice: seat.spice, forces: seat.forces, reserves: seat.reserves })), allowed.players.map(seat => ({ id: seat.id, spice: seat.spice, forces: seat.forces, reserves: seat.reserves }))); if (scenario.kind === 'bidding') { assert.equal(denied.ixAuction, null); assert.equal(denied.auction!.cards.length, fixture.nativeCount); assert.equal(allowed.decision?.kind, 'ixAuction'); assert.equal(allowed.ixAuction!.cards.length, fixture.nativeCount + 1); } else { assert.ok(fixture.selected); const nativeBefore = fixture.game.players.find(seat => seat.id === fixture.provider)!; assert.deepEqual(denied.players.find(seat => seat.id === fixture.provider)!.hand, nativeBefore.hand); assert.deepEqual(denied.auction!.cards, fixture.game.auction!.cards); assert.equal(allowed.players.find(seat => seat.id === fixture.provider)!.hand.some(card => card.id === fixture.selected!.id), false); assert.equal(allowed.auction!.cards[allowed.auction!.index].id, fixture.selected.id); assert.equal(denied.ixTechnologyTurn, fixture.game.turn); assert.equal(allowed.ixTechnologyTurn, fixture.game.turn); } assert.equal(select(viewGame(denied, fixture.holder), 'use'), undefined); assert.doesNotMatch(table(viewGame(denied, fixture.holder)), /data-testid="nexus-ixian-betrayal"/); const before = JSON.stringify(denied); assert.throws(() => applyAction(denied, fixture.holder, action)); assert.equal(JSON.stringify(denied), before); }); } void test('irrelevant held faces receive the same public acknowledgement without a private Ixian inspector or rival Use', () => { for (const scenario of scenarios) { const fixture = createIxianNexusBetrayalFixture(scenario); const irrelevant = createIxianNexusBetrayalFixture({ ...scenario, face: 'richese' }); const own = viewGame(fixture.game, fixture.holder); const neutral = viewGame(irrelevant.game, irrelevant.holder); assert.match(markup(own), /aria-label="Inspect Ixians Nexus card"/); assert.ok(select(own, 'use')); assert.ok(select(neutral, 'pass')); assert.equal(select(neutral, 'use'), undefined); assert.doesNotMatch(markup(neutral), /Inspect Ixians Nexus card|Betrayal timing and preview limits/); for (const seat of fixture.game.players.filter(seat => seat.id !== fixture.holder)) { const rival = viewGame(fixture.game, seat.id); assert.equal(select(rival, 'use'), undefined); assert.doesNotMatch(markup(rival), /Inspect Ixians Nexus card/); assert.equal(rival.nexusIxianBetrayalReaction!.blocked, null); } const publicOffer = (view: GameView) => { const reaction = view.nexusIxianBetrayalReaction!; return { event: reaction.event, kind: reaction.kind, provider: reaction.provider, canPass: reaction.canPass, hasPassed: reaction.hasPassed }; }; assert.deepEqual(publicOffer(own), publicOffer(neutral)); assert.doesNotMatch(markup(own), /Inspect Treachery|Your purchased Treachery|private auction card/); } }); void test('multiple public responders have no owner until only one acknowledgement remains', () => { const fixture = createIxianNexusBetrayalFixture({ receiverCount: 2 }); for (const seat of fixture.game.players) assert.equal(tableActionOwner(viewGame(fixture.game, seat.id)), null); const first = fixture.required[0]; const action = select(viewGame(fixture.game, first), 'pass'); assert.ok(action); const next = applyAction(fixture.game, first, action); for (const seat of next.players) assert.equal(tableActionOwner(viewGame(next, seat.id)), fixture.required[1]); assert.equal(select(viewGame(next, first), 'pass'), undefined); assert.equal(select(viewGame(next, first), 'use'), undefined); }); void test('actual table reserves bids and voluntary card mutations; transport, room and autopilot locks stop response dispatch', () => { for (const scenario of scenarios) { const fixture = createIxianNexusBetrayalFixture(scenario); const own: GameView = viewGame(fixture.game, fixture.holder); const html = table(own); assert.match(html, /data-testid="nexus-ixian-betrayal"/); assert.doesNotMatch(html, /]*>[^<]*(?:Bid |Place bid|Play Karama|Make a bribe|Pledge|Search)/i); for (const restriction of ['paused', 'closed', 'autopilot', 'busy'] as const) { const blocked = structuredClone(own); if (restriction === 'autopilot') blocked.players.find(seat => seat.id === own.me)!.autopilot = 'Easy'; 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.nexusIxianBetrayalReaction!.blocked = 'A committed promise reserves this Nexus card.'; assert.equal(select(blocked, 'use'), undefined); assert.ok(select(blocked, 'pass')); const native = viewGame(fixture.beforeNativeCounter, fixture.holder); assert.equal(native.nexusIxianBetrayalReaction, null); assert.doesNotMatch(table(native), /data-testid="nexus-ixian-betrayal"/); assert.match(table(native), /Power response/); } }); void test('every seat can regain independent control while the native acknowledgement remains unchanged', () => { const fixture = createIxianNexusBetrayalFixture({ receiverCount: 2 }); for (const seat of fixture.game.players) { const delegated = applyAction(fixture.game, seat.id, { type: 'setAutopilot', difficulty: 'Hard' }); const view = viewGame(delegated, seat.id); const takeBackButton = [...table(view).matchAll(/]*>[\s\S]*?<\/button>/g)] .map(match => match[0]).find(button => button.includes('Take back control')); assert.ok(takeBackButton); assert.doesNotMatch(takeBackButton, /disabled=/); const resumed = applyAction(delegated, seat.id, { type: 'setAutopilot', difficulty: null }); assert.equal(resumed.players.find(player => player.id === seat.id)!.autopilot, undefined); assert.deepEqual(resumed.pendingNexusIxianBetrayal, fixture.game.pendingNexusIxianBetrayal); assert.deepEqual(resumed.deck, fixture.game.deck); } }); aliases.deregister();