import assert from 'node:assert/strict'; import test from 'node:test'; import { readFileSync } from 'node:fs'; import { runInNewContext } from 'node:vm'; import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import * as jsxRuntime from 'react/jsx-runtime'; import ts from 'typescript'; import * as catalog from '../game/catalog'; import * as bribeOptions from '../game/bribe-options'; import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { bribeAction } from '../game/bribe-options'; import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early'; import { truthMovementInventory as inventory } from './fixture-truth-movement'; const payer = 'richese', recipient = 'ecaz', atreides = 'atreides', fremen = 'fremen', emperor = 'emperor'; const seat = (g: Game, id: string) => { const player = g.players.find(p => p.id === id); assert.ok(player); return player; }; const clean = (g: Game) => !g.response && !g.decision && !g.phaseOpening && !g.truthtrance && !g.pendingTreacheryDiscard; const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); function next(g: Game) { if (g.status === 'setup' && g.setupStage === 'forces' && seat(g, fremen).reserves === 20 && viewGame(g, fremen).setupPending.includes(fremen)) return {actor:fremen, action:{type:'fremenSetup', placements:{'sietch_tabr:14':10}} satisfies Action}; if (g.phase === 5 && g.active === emperor && clean(g)) return {actor:emperor, action:{type:'endMovement'} satisfies Action}; return nextTruthMovementNativeEarlyStep(g, {position:'first', method:'onceAround', direction:'clockwise'}); } function reach(game: Game, until: (g: Game) => boolean): Game { for (let count = 0; count < 2400; count++) { if (until(game)) return game; assert.notEqual(game.status, 'finished'); const step = next(game); assert.ok(step, `Native action required at ${game.turn}/${game.phase}/${game.decision?.kind}`); game = act(game, step.actor, step.action); } throw Error('The native Basic mixed-faction control was not reached'); } /** Genuine public mixed-deck setup. Only the original unopened starting order * and a later conserved physical card swap are chosen to ensure an Emperor * Karama keeps the native Atreides/Fremen response open. */ function originalGame() { let game = createGame('NATIVEBRIBES', newPlayer(atreides, 'Atreides', 'atreides'), false, ['ix','choam','ecaz']); for (const faction of [fremen, emperor, payer, 'choam', recipient] as const) joinGame(game, newPlayer(faction, faction, faction)); for (const p of game.players) game = act(game, p.id, {type:'ready'}); game = act(game, game.host, {type:'start'}); assert.equal(game.status, 'setup'); assert.equal(game.advanced, false); const originalCards = inventory(game); game = reach(game, state => state.status === 'playing'); assert.equal(game.semutaPreview, true); const holder = seat(game, emperor); if (!holder.hand.some(card => card.effect === 'karama')) { const fromDeck = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(holder.hand.length); if (fromDeck >= 0) [holder.hand[0], game.deck[fromDeck]] = [game.deck[fromDeck], holder.hand[0]]; else { const other = game.players.find(p => p.id !== emperor && p.hand.some(card => card.effect === 'karama')); assert.ok(other); const index = other.hand.findIndex(card => card.effect === 'karama'); [holder.hand[0], other.hand[index]] = [other.hand[index], holder.hand[0]]; } } assert.deepEqual(inventory(game), originalCards); return {game, originalCards}; } let saved: ReturnType | undefined; const source = () => saved ??= originalGame(); const payment: Action = {type:'bribe', target:recipient, amount:1}; function reject(g: Game, actor: string, action: Action) { const before = structuredClone(g); assert.throws(() => act(g, actor, action)); assert.deepEqual(g, before); } function paid(game: Game) { const before = structuredClone(game), options = viewGame(game,payer).bribeOptions; assert.equal(options.blocked,null); assert.deepEqual(bribeAction(options,recipient,1),payment); const after = act(game,payer,payment); assert.deepEqual(after.response,before.response); assert.deepEqual(after.decision,before.decision); assert.deepEqual(after.auction,before.auction); assert.deepEqual(after.pendingRevival,before.pendingRevival); assert.equal(seat(after,payer).spice,seat(before,payer).spice-1); assert.equal(seat(after,recipient).spice,seat(before,recipient).spice); assert.equal(seat(after,recipient).bribes,seat(before,recipient).bribes+1); assert.deepEqual(inventory(after),source().originalCards); reject(game,payer,{type:'bribe',target:payer,amount:1}); return after; } function inspectSource() { return reach(structuredClone(source().game), state => state.turn === 1 && state.phase === 3 && state.response?.kind === 'atreidesAuction' && !!state.auction && !state.richeseAuction); } function auctionSource() { let g = inspectSource(); g = reach(g, state => state.phase === 3 && clean(state) && !!state.auction && state.auction.active === emperor); g = act(g,emperor,{type:'bid',amount:1,allyPayment:0}); return reach(g, state => state.decision?.kind === 'auctionPayment' && state.decision.player === emperor); } function grantSource() { let g = reach(structuredClone(source().game), state => state.turn === 1 && state.phase === 4 && clean(state)); // The response is opened by the real grant. Stage only an otherwise valid // reciprocal alliance and one physically conserved casualty for its target. seat(g,fremen).ally = atreides; seat(g,atreides).ally = fremen; assert.ok(seat(g,atreides).reserves > 0); seat(g,atreides).reserves--; seat(g,atreides).tanks++; g = act(g,fremen,{type:'grantRevival'}); assert.equal(g.response?.kind,'fremenRevivalGrant'); return g; } void test('a mixed native faction table preserves Atreides inspection while a nonally pays a bribe', () => { const before = inspectSource(), after = paid(before); const control = act(before,emperor,{type:'passResponse'}); const done = act(after,emperor,{type:'passResponse'}); assert.deepEqual(done.auction,control.auction); assert.notEqual(done.response?.kind,'atreidesAuction'); assert.equal(seat(done,recipient).bribes,seat(before,recipient).bribes+1); }); void test('a mixed native faction table preserves a winning auction invoice and purchase through a bribe', () => { const before = auctionSource(), lot = before.auction!.cards[before.auction!.index].id; const after = paid(before), done = act(after,emperor,{type:'decision',karama:false}); const delivered = reach(done,state => seat(state,emperor).hand.some(card => card.id === lot)); assert.equal(seat(delivered,emperor).hand.filter(card => card.id === lot).length,1); assert.equal(seat(delivered,recipient).bribes,seat(before,recipient).bribes+1); assert.deepEqual(inventory(delivered),source().originalCards); }); void test('a mixed native faction table preserves the actual Fremen grant response and its original result', () => { const before = grantSource(), after = paid(before); const control = act(before,emperor,{type:'passResponse'}), done = act(after,emperor,{type:'passResponse'}); assert.deepEqual(done.freeRevival,control.freeRevival); assert.ok(done.freeRevival.includes(atreides)); assert.equal(seat(done,recipient).bribes,seat(before,recipient).bribes+1); }); void test('the original human and bot responders retain legal choices after the bribe', () => { for (const g of [inspectSource(),grantSource()]) { const after = paid(g); for (const difficulty of DIFFICULTIES) { const view = viewGame(after,emperor); view.players.find(p => p.id === emperor)!.bot = difficulty; const action = botActions(view).find(choice => choice.type === 'passResponse' || choice.type === 'card' && choice.mode === 'cancel'); assert.ok(action,difficulty); assert.doesNotThrow(() => act(reload(after),emperor,action)); } } }); void test('the rendered mixed-faction bribe control pays once and preserves the actual inspection', () => { let game = inspectSource(); const original = structuredClone(game.response), before = seat(game,payer).spice; const states: unknown[] = [], sent: Action[] = []; let cursor = 0; const exported: {Bribes?: (props: {game: ReturnType; busy: boolean; act: (action: Action) => void}) => ReactNode} = {}; runInNewContext(ts.transpileModule(readFileSync(new URL('../components/bribes.tsx', import.meta.url), 'utf8'), { compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}, }).outputText, {exports:exported,require(name:string) { if (name === 'react/jsx-runtime') return jsxRuntime; if (name === 'react') return {useId:() => 'native-bribe',useState(initial:unknown) { const index = cursor++; if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial; return [states[index], (value:unknown) => {states[index] = typeof value === 'function' ? value(states[index]) : value;}]; }}; if (name === '@/game/catalog') return catalog; if (name === '@/game/bribe-options') return bribeOptions; if (name === '@/components/ui/button') return {Button:'button'}; throw Error(`Unexpected import ${name}`); }}); assert.ok(exported.Bribes); type Element = ReactElement<{children?: ReactNode; [key: string]: unknown}>; const elements = (node: ReactNode): Element[] => { if (Array.isArray(node)) return node.flatMap(elements); if (!isValidElement<{children?: ReactNode; [key: string]: unknown}>(node)) return []; return [node,...elements(node.props.children)]; }; const render = () => { cursor = 0; return exported.Bribes!({game:viewGame(game,payer),busy:false,act(action) { sent.push(action); game = act(game,payer,action); }}); }; let nodes = elements(render()); const select = nodes.find(node => node.type === 'select'); assert.ok(select); assert.equal(select.props.disabled,false); (select.props.onChange as (event: {target: {value: string}}) => void)({target:{value:recipient}}); nodes = elements(render()); const button = nodes.find(node => node.type === 'button'); assert.ok(button); assert.equal(button.props.disabled,false); assert.match(renderToStaticMarkup(createElement(() => render())),/Mentat Pause/); (button.props.onClick as () => void)(); assert.deepEqual(JSON.parse(JSON.stringify(sent)),[payment]); assert.equal(seat(game,payer).spice,before-1); assert.deepEqual(game.response,original); const done = act(game,emperor,{type:'passResponse'}); assert.notEqual(done.response?.kind,'atreidesAuction'); }); void test('an actual Ecaz placement keeps the bribe unavailable', () => { const game = reach(structuredClone(source().game), state => state.phase === 4 && state.decision?.kind === 'ecazPlacement'); assert.ok(viewGame(game,payer).bribeOptions.blocked); reject(game,payer,payment); });