import assert from 'node:assert/strict'; import test from 'node:test'; import { registerHooks } from 'node:module'; import { readFileSync } from 'node:fs'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game, type GameView } from '../game/engine'; import { EmperorTimingGift } from '../components/emperor-timing-gift'; import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; import { createTruthTurnAmalFixture } from './fixture-truth-turn-amal'; 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}; }, }); const { GameTable } = await import('../components/game-table'); aliases.deregister(); /** A public Advanced start and native setup, followed by a staged mutual * alliance and clean Guild timing boundary, as in the engine journey. */ function fixture(): Game { let game = createGame('EMPGIFTCONTROL', newPlayer('e','Emperor','emperor'), true); joinGame(game, newPlayer('g','Guild','guild')); joinGame(game, newPlayer('h','Harkonnen','harkonnen')); for (const player of game.players) game = applyAction(game, player.id, {type:'ready'}); game = applyAction(game, 'e', {type:'start', advancedPreview:true}); for (let step = 0; game.status === 'setup' && step < 60; step++) { const next = nextStrongholdFactionsNativeStep(game); assert.ok(next); game = applyAction(game, next.actor, next.action); } assert.equal(game.status, 'playing'); game.players.find(player => player.id === 'e')!.ally = 'g'; game.players.find(player => player.id === 'g')!.ally = 'e'; Object.assign(game, {phase:5, turn:1, order:['e','g','h'], active:null, movementRemaining:['e','g','h'], guildTimingGranted:false, guildTimingLocked:false, decision:{kind:'guildTiming', player:'g', next:'e', following:'e'}, response:null, phaseOpening:null, ready:[]}); return game; } const component = (game: GameView, busy = false) => renderToStaticMarkup(createElement(EmperorTimingGift, {game, busy, act() {}})); const table = (game: GameView, busy = false) => renderToStaticMarkup(createElement(GameTable, {game, busy, send:async () => {}, onExit() {}})); void test('quoted Guild timing gift shows the ally, amount bounds and explanation', () => { const game = fixture(), view = viewGame(game, 'e'); assert.deepEqual(view.emperorGuildTimingGift, {recipient:'g', maximum:10}); const html = component(view); assert.match(html, /Share spice with Guild/); assert.match(html, /Guild timing decision resumes after the Karama response/); assert.match(html, /Spice to share/); assert.match(html, /type="number"[^>]*min="1"[^>]*max="10"/); assert.match(html, /]*disabled)[^>]*>Share spice<\/button>/); }); void test('the gift component hides absent quotes and disables submission while busy', () => { const game = fixture(), own = viewGame(game, 'e'), other = viewGame(game, 'h'); assert.equal(other.emperorGuildTimingGift, null); assert.equal(component(other), ''); const busy = component(own, true); assert.match(busy, /]*disabled=""[^>]*>Share spice<\/button>/); assert.match(busy, /]*disabled=""/); }); void test('the real Guild timing table exposes the gift only to the Emperor', () => { const game = fixture(), own = viewGame(game, 'e'), guild = viewGame(game, 'g'), observer = viewGame(game, 'h'); assert.equal(game.decision?.kind, 'guildTiming'); assert.match(table(own), /Share spice with Guild/); assert.match(table(own), /Guild timing decision resumes after the Karama response/); assert.doesNotMatch(table(guild), /Share spice with Guild/); assert.doesNotMatch(table(observer), /Share spice with Guild/); assert.match(table(own, true), /]*disabled=""[^>]*>Share spice<\/button>/); }); void test('an Extortion quote presents the recipient and restores its original decision after Karama', () => { const game = fixture(); // Isolate the component contract: the underlying Guild fixture supplies the // seats, while this projected Extortion choice and quote are simulated. const decision = {kind:'moritaniExtortion' as const, player:'h', event:'extortion-test'}; const own: GameView = {...viewGame(game, 'e'), decision, emperorGuildTimingGift:null, emperorExtortionGift:{recipient:'h', maximum:4}}; const observer: GameView = {...viewGame(game, 'h'), decision, emperorGuildTimingGift:null, emperorExtortionGift:null}; const html = component(own); assert.match(html, /Share spice with Harkonnen/); assert.match(html, /Moritani Extortion decision resumes after the Karama response/); assert.match(html, /type="number"[^>]*min="1"[^>]*max="4"/); assert.match(html, /]*disabled)[^>]*>Share spice<\/button>/); assert.equal(component(observer), ''); assert.match(component(own, true), /]*disabled=""[^>]*>Share spice<\/button>/); assert.match(table(own), /Moritani Extortion decision resumes after the Karama response/); assert.doesNotMatch(table(observer), /Share spice with Harkonnen/); }); void test('a phase-opening quote gives only its owner the gift control', () => { const game = fixture(); // Exercise the isolated projected contract; the engine owns its eligibility. const own: GameView = {...viewGame(game, 'e'), emperorGuildTimingGift:null, emperorExtortionGift:null, emperorPhaseOpeningGift:{recipient:'g', maximum:6}}; const observer: GameView = {...viewGame(game, 'h'), emperorGuildTimingGift:null, emperorExtortionGift:null, emperorPhaseOpeningGift:null}; const html = component(own); assert.match(html, /Share spice with Guild/); assert.match(html, /Phase opening resumes after the Karama response to your gift/); assert.match(html, /type="number"[^>]*min="1"[^>]*max="6"/); assert.match(html, /]*disabled)[^>]*>Share spice<\/button>/); assert.match(component(own, true), /]*disabled=""[^>]*>Share spice<\/button>/); assert.equal(component(observer), ''); assert.equal(component({...own, emperorPhaseOpeningGift:null}), ''); }); void test('the real Basic Ix opening table exposes the control only to the Emperor', () => { const {game} = createTruthTurnAmalFixture(); game.players.find(player => player.id === 'emperor')!.ally = 'atreides'; game.players.find(player => player.id === 'atreides')!.ally = 'emperor'; const own = table(viewGame(game, 'emperor')); const observer = table(viewGame(game, 'harkonnen')); assert.match(own, /Before Storm/); assert.match(own, /Phase opening resumes after the Karama response to your gift/); assert.doesNotMatch(observer, /Phase opening resumes after the Karama response to your gift/); });