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, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { richeseCards } from '../game/richese-cards'; import { discoveryStormActions } from '../game/discovery-storm-options'; import { discoveryStormDistance } from '../game/discovery-storm'; import { stormSectorAfter } from '../game/disaster-rules'; import { createTestingStationContestFixture } from './fixture-testing-station-contest'; 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 { JuiceOfSapho, SaphoOrderButton } = await import('../components/juice-of-sapho'); aliases.deregister(); const card = 'richese-juice-of-sapho'; const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); const originals = new Map>(); function fixture(advanced: boolean, place: 'first' | 'second') { let original = originals.get(advanced); if (!original) { original = createTestingStationContestFixture({advanced}); originals.set(advanced, original); } const game = structuredClone(original.game); const holder = original.order[place === 'first' ? 0 : 1]; const target = game.players.find(player => player.id === holder)!; // The original fixture has only Ecaz components. Supply its one real printed // Sapho face from the Richese cache, retaining a full hand by transferring an // existing held card to the deck. This explicitly staged acquisition is not // evidence of mixed-expansion roster admission or a natural purchase. const sapho = richeseCards().find(candidate => candidate.id === card); assert.ok(sapho); assert.ok(![...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)].some(candidate => candidate.id === card)); if (target.hand.length >= 4) game.deck.push(target.hand.pop()!); target.hand.push(sapho); return {game, holder, original}; } function saphoOption(game: Game, holder: string, mode: 'first' | 'last') { const option = viewGame(game, holder).saphoOptions.find(choice => String(choice.scope) === 'testingStation' && choice.mode === mode); assert.ok(option, `${holder} has a real ${mode} station opportunity`); return option; } function saphoAction(game: Game, holder: string, mode: 'first' | 'last'): Action { const option = saphoOption(game, holder, mode); return {type:'card', card, scope:option.scope, event:option.event, mode:option.mode}; } function stationAction(game: Game, actor: string, delta: number) { const action = discoveryStormActions(viewGame(game, actor)).find(choice => choice.delta === delta); assert.ok(action, `${actor} can make the ${delta} station adjustment`); return action; } function reject(game: Game, actor: string, action: Action) { const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); } for (const advanced of [false, true]) for (const mode of ['first', 'last'] as const) void test( `${advanced ? 'Advanced' : 'Basic'} Sapho changes the contested station order ${mode} exactly once`, () => { const f = fixture(advanced, mode === 'first' ? 'second' : 'first'); const initial = f.game, action = saphoAction(initial, f.holder, mode); const physicalOrder = [...initial.order], beforeOrder = [...initial.ecologicalStorm!.queue!.order]; const beforeEvent = initial.ecologicalStorm!.event, sourceDistance = initial.stormPending!; const other = beforeOrder.find(id => id !== f.holder)!; reject(initial, other, action); reject(initial, f.holder, {...action, event:'stale-station-event'}); let game = applyAction(reload(initial), f.holder, action); assert.deepEqual(game.order, physicalOrder); assert.deepEqual(game.ecologicalStorm?.queue?.order, [...beforeOrder].reverse()); assert.notEqual(game.ecologicalStorm?.event, beforeEvent); assert.equal(game.ecologicalStorm?.owner, game.ecologicalStorm?.queue?.order[0]); assert.equal(game.decision?.player, game.ecologicalStorm?.owner); assert.equal(game.discard.filter(candidate => candidate.id === card).length, 1); assert.ok(!game.players.find(player => player.id === f.holder)!.hand.some(candidate => candidate.id === card)); reject(game, f.holder, action); const first = game.ecologicalStorm!.owner; const firstDelta = sourceDistance < 40 ? 1 : -1; game = applyAction(reload(game), first, stationAction(game, first, firstDelta)); assert.equal(discoveryStormDistance(game.ecologicalStorm!), sourceDistance + firstDelta); assert.equal(game.stormPending, sourceDistance + firstDelta); const second = game.ecologicalStorm!.owner; assert.notEqual(first, second); game = applyAction(reload(game), second, stationAction(game, second, -firstDelta)); assert.equal(game.ecologicalStorm?.stage, 'complete'); assert.equal(discoveryStormDistance(game.ecologicalStorm!), sourceDistance); assert.equal(game.storm, stormSectorAfter(initial.storm, sourceDistance)); assert.equal(game.phase, 1); assert.equal(game.discard.filter(candidate => candidate.id === card).length, 1); }, ); void test('station Sapho controls render and submit the exact supported action, with busy protection', () => { for (const mode of ['first', 'last'] as const) { const f = fixture(false, mode === 'first' ? 'second' : 'first'); const view = viewGame(f.game, f.holder), option = saphoOption(f.game, f.holder, mode); const html = renderToStaticMarkup(createElement(JuiceOfSapho, {game:view, busy:false, act() {}})); assert.match(html, /Testing Station/i); const sent: Action[] = []; SaphoOrderButton({card, option, busy:false, act(action) {sent.push(action);}}).props.onClick(); assert.deepEqual(sent, [saphoAction(f.game, f.holder, mode)]); SaphoOrderButton({card, option, busy:true, act(action) {sent.push(action);}}).props.onClick(); assert.equal(sent.length, 1); } }); void test('all minimal bot profiles have a legal current station choice after Sapho', () => { const f = fixture(false, 'second'); const game = applyAction(f.game, f.holder, saphoAction(f.game, f.holder, 'first')); for (const difficulty of DIFFICULTIES) { const view = viewGame(game, f.holder); view.players.find(player => player.id === f.holder)!.bot = difficulty; const choice = botActions(view).find(action => action.type === 'decision'); assert.ok(choice, `${difficulty} can choose the original station adjustment`); const next = applyAction(game, f.holder, choice); assert.equal(next.ecologicalStorm?.queue?.choices.length, 1); } });