import assert from 'node:assert/strict'; import test from 'node:test'; import { readFileSync } from 'node:fs'; import { registerHooks } from 'node:module'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { applyAction, viewGame, type Game } from '../game/engine'; import { nexusOfferGame } from './fixture-nexus-offer'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; 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(); function markup(g: Game, id = 'a', busy = false) { return renderToStaticMarkup(createElement(GameTable, { game: viewGame(g, id), busy, send: async () => {}, onExit() {} })); } void test('staged group mode renders its real Nexus invitation and hides conflicting pair controls', () => { let game = nexusOfferGame(); game.coalitionState = emptyCoalitionState(); game = applyAction(game, 'a', {type: 'alliance', target: 'e'}); game = applyAction(game, 'e', {type: 'alliance', target: 'a'}); const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair); assert.match(markup(game, 'a'), /Invite Harkonnen observer to Atreides offerer, Emperor recipient/); game = applyAction(game, 'a', {type: 'coalitionPropose', groupId: pair.id, entrant: 'h', expectedRevision: game.coalitionState!.revision}); const html = markup(game, 'h'); assert.match(html, /Accept invitation/); assert.match(html, /Decline and cancel invitation/); assert.doesNotMatch(html, /Propose \/ accept alliance/); assert.match(markup(game, 'h', true), /disabled=""[^>]*>Accept invitation/); }); void test('staged group shipment funding appears on the live table for the donor and recipient', () => { let game = nexusOfferGame(); game.coalitionState = emptyCoalitionState(); game = applyAction(game, 'a', {type:'alliance', target:'e'}); game = applyAction(game, 'e', {type:'alliance', target:'a'}); const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair); game = applyAction(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'h', expectedRevision:game.coalitionState!.revision}); for (const id of ['e', 'h']) { const offer = game.coalitionState!.proposal!; game = applyAction(game, id, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus, turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision}); } // Stage the next movement boundary; the group negotiation above used real actions. Object.assign(game, {phase:5, nexus:false, active:'a', ready:[], movementRemaining:['a','e','h']}); const emperor = game.players.find(player => player.id === 'e')!; assert.ok(emperor.reserves > 0); emperor.reserves--; emperor.forces['arrakeen:10'] = (emperor.forces['arrakeen:10'] ?? 0) + 1; game = applyAction(game, 'e', {type:'coalitionPledge', recipient:'a', amount:1}); assert.match(markup(game, 'a'), /Emperor recipient: 1 spice/); assert.match(markup(game, 'a'), /Use from Emperor recipient for your next shipment/); assert.match(markup(game, 'a'), /Basic allied transit: ship forces/); assert.match(markup(game, 'e'), /Pledge to Atreides offerer · 1 spice currently set aside/); }); void test('staged group bid funding displays donor selection in the ordinary auction', () => { let game = nexusOfferGame(); game.coalitionState = emptyCoalitionState(); game = applyAction(game, 'a', {type:'alliance', target:'e'}); game = applyAction(game, 'e', {type:'alliance', target:'a'}); const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair); game = applyAction(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'h', expectedRevision:game.coalitionState!.revision}); for (const id of ['e', 'h']) { const offer = game.coalitionState!.proposal!; game = applyAction(game, id, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus, turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision}); } const card = game.deck.shift(); assert.ok(card); // Stage an ordinary lot with its original physical card in auction custody. Object.assign(game, {phase:3, nexus:false, active:'a', ready:[], auction:{ cards:[card], index:0, bid:0, bidder:null, active:'a', passed:[], opener:0, }}); game = applyAction(game, 'e', {type:'coalitionPledge', recipient:'a', amount:1}); assert.match(markup(game, 'a'), /Use from Emperor recipient for your next bid/); assert.match(markup(game, 'a'), /Raise bid/); }); void test('live outgoing offer is named and withdrawable only by its sender, separately from an incoming invitation', () => { const g = applyAction(nexusOfferGame(), 'a', { type: 'alliance', target: 'e' }); const html = markup(g); assert.match(html, /Your alliance offer is to Emperor recipient/); assert.match(html, /Withdraw your alliance offer/); assert.doesNotMatch(html, />Break alliance]*>Withdraw your alliance offer/); const changed = applyAction(g, 'a', { type: 'alliance', target: 'h' }); assert.match(markup(changed), /Your alliance offer is to Harkonnen observer/); }); void test('saved withdrawal clears its own offer, resets readiness, preserves custody and records the named change', () => { let g = nexusOfferGame(); g = applyAction(g, 'h', { type: 'alliance', target: 'a' }); g = applyAction(g, 'a', { type: 'alliance', target: 'e' }); assert.match(g.log.at(-1)!.text, /offered an alliance to Emperor recipient/); g.ready = ['h']; const before = JSON.stringify(g); const withdrawn = applyAction(JSON.parse(before), 'a', { type: 'alliance' }); assert.equal(JSON.stringify(g), before); assert.deepEqual(withdrawn.allianceOffers, { h: 'a' }); assert.deepEqual(withdrawn.players, g.players); assert.deepEqual(withdrawn.ready, []); assert.match(withdrawn.log.at(-1)!.text, /withdrew the alliance offer to Emperor recipient/); assert.doesNotMatch(markup(withdrawn), /Withdraw your alliance offer/); assert.match(markup(withdrawn), /Harkonnen observer · Invited you/); const next = applyAction(withdrawn, 'a', { type: 'alliance', target: 'h' }); assert.equal(next.players[0].ally, 'h'); assert.match(markup(next), />Break alliance p.ally === null)); }); void test('closing the Nexus or opening Truthtrance never exposes a usable stale withdrawal', () => { const g = applyAction(nexusOfferGame(), 'a', { type: 'alliance', target: 'e' }); const closed = { ...g, nexus: false }; assert.doesNotMatch(markup(closed), /Withdraw your alliance offer/); const before = JSON.stringify(closed); assert.throws(() => applyAction(closed, 'a', { type: 'alliance' }), /Nexus/); assert.equal(JSON.stringify(closed), before); const paused = structuredClone(g); paused.truthtrance = { stage: 'priority', queue: [], passed: [], question: null }; const html = markup(paused); if (html.includes('Withdraw your alliance offer')) assert.match(html, /disabled=""[^>]*>Withdraw your alliance offer/); assert.throws(() => applyAction(paused, 'a', { type: 'alliance' })); }); void test('every existing AI profile has legal participation after a human withdraws an offer', () => { let g = applyAction(nexusOfferGame(), 'a', { type: 'alliance', target: 'e' }); g = applyAction(g, 'a', { type: 'alliance' }); for (const level of DIFFICULTIES) { const view = viewGame(g, 'e'); view.players.find(p => p.id === 'e')!.bot = level; const action = botActions(view)[0]; assert.ok(action); const before = JSON.stringify(g); assert.ok(applyAction(g, 'e', action)); assert.equal(JSON.stringify(g), before); } });