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 { handLimit, viewGame, type Action, type Game, type GameView } from '../game/engine'; import { turnActionPromiseModeSupported } from '../game/turn-action-promises'; import { createKullBribeFixture } from './fixture-kull-bribe'; import { act, advance, clean, createKullRicheseSemutaFixture, inventory, player } from './fixture-kull-richese-semuta'; 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'); const {Truthtrance} = await import('../components/truthtrance'); const {TurnActionGuideButton} = await import('../components/turn-action-promises'); aliases.deregister(); const table = (game:GameView) => renderToStaticMarkup(createElement(GameTable, {game,busy:false,send:async () => {},onExit() {}})); const truth = (game:GameView) => renderToStaticMarkup(createElement(Truthtrance, {game,busy:false,act() {}})); void test('Basic printed Kull admits whole-turn questions without opening Advanced or optional modules',()=>{ const classic = {advanced:false, expansions:['choam','ix'], kullPreview:true, players:[{faction:'choam'},{faction:'emperor'},{faction:'guild'}]}; const richese = {...classic, semutaPreview:true, players:[...classic.players, {faction:'richese'}]}; assert.equal(turnActionPromiseModeSupported(classic),true); assert.equal(turnActionPromiseModeSupported(richese),true); assert.equal(turnActionPromiseModeSupported({...richese,kullPreview:false}),true); assert.equal(turnActionPromiseModeSupported({...richese,advanced:true}),false); assert.equal(turnActionPromiseModeSupported({...classic,expansions:['choam']}),false); assert.equal(turnActionPromiseModeSupported({...richese,players:[...richese.players,{faction:'richese'}]}),false); for (const flag of ['nexusKullPreview','richeseBetrayalPreview','nexusCards','homeworlds', 'leaderSkills','techTokens','strongholdCards','discoveryEnabled','ecazTreachery'] as const) assert.equal(turnActionPromiseModeSupported({...richese,[flag]:true}),false,flag); }); /** Both sources use genuine Basic setup and phases. In the Richese case only * one original physical Truthtrance is moved to Harkonnen's hand; the native * Richese cache purchase and all subsequent actions remain real. */ function source(richese:boolean): {game:Game; asker:string; card:string; originalCards:string[]} { if (!richese) { const f = createKullBribeFixture(); return {game:structuredClone(f.game),asker:f.asker,card:f.truth,originalCards:f.originalCards}; } const f = createKullRicheseSemutaFixture(); const game = structuredClone(f.game); const zones = [game.deck,game.discard,...(game.richeseCache?[game.richeseCache]:[]), ...(game.richeseRemoved?[game.richeseRemoved]:[]),...game.players.map(seat=>seat.hand)]; const zone=zones.find(cards=>cards.some(card=>card.effect==='truthtrance')); assert.ok(zone); const [card]=zone.splice(zone.findIndex(row=>row.effect==='truthtrance'),1); const asker=player(game,'harkonnen'); assert.ok(asker.hand.length < handLimit(asker)); asker.hand.push(card); assert.deepEqual(inventory(game),f.originalCards); return {game,asker:'harkonnen',card:card.id,originalCards:f.originalCards}; } for (const richese of [false,true]) void test(`Basic Kull ${richese?'with':'without'} Richese renders and executes a whole-answer guide`,()=>{ const f=source(richese); let game=act(f.game,f.asker,{type:'card',card:f.card}); while (game.truthtrance?.stage==='priority') { const seat=game.players.find(row=>!game.truthtrance!.passed.includes(row.id)); assert.ok(seat); game=act(game,seat.id,{type:'truthPass'}); } const asker=viewGame(game,f.asker); assert.ok(asker.truthTurnActionTargets.includes('emperor')); assert.match(truth(asker),/Combine printed card plays, native battle powers and this turn’s other actions/); assert.match(table(asker),/Combine printed card plays, native battle powers and this turn’s other actions/); game=act(game,f.asker,{type:'truthAsk',question:{kind:'turnActions',target:'emperor',turn:game.turn, mixed:{kind:'and',terms:[ {kind:'action',action:{kind:'payment',recipient:'choam',minimum:1}}, {kind:'action',action:{kind:'ship',territory:'polar_sink',minimum:1}}, ]}}}); const respondent=viewGame(game,'emperor'); assert.ok(respondent.truthTurnActionAnswers?.includes('yes')); assert.ok(respondent.truthTurnActionAnswers?.includes('no')); assert.equal(viewGame(game,f.asker).truthTurnActionAnswers,null); assert.match(truth(respondent),/Answer Yes/); assert.match(truth(respondent),/Answer No/); assert.match(table(respondent),/Answer Yes/); assert.match(table(respondent),/Answer No/); game=act(game,'emperor',{type:'truthAnswer',answer:'yes'}); game=advance(game,clean); const own=viewGame(game,'emperor'), sent:Action[]=[]; assert.ok(own.turnActionGuide); assert.match(table(own),/while honoring your whole answer/); const button=TurnActionGuideButton({game:own,busy:false,act(action) {sent.push(action);}}); assert.ok(button); assert.equal(button.props.disabled,false); button.props.onClick(); assert.deepEqual(sent,[own.turnActionGuide.action]); game=act(game,'emperor',sent[0]); assert.ok(game.turnActionPromises?.some(row=>row.player==='emperor' && !row.released)); assert.deepEqual(inventory(game),f.originalCards); });