import assert from 'node:assert/strict'; import test from 'node:test'; import { createGame, newPlayer, type Game } from '../game/engine'; import { choamGholaEvent } from '../game/choam-market-ghola'; import { semutaMarketGholaProfile as profile, semutaMarketGholaSupported as supported } from '../game/semuta-market-ghola'; import type { FreshDiscardBatch } from '../game/semuta-drug'; type Ordinary = Extract['continuation'], {kind:'ordinaryCardDiscard'}>; /** A staged frame isolates admission. The separate runtime suite exercises * the actual Ghola, market, income and physical continuation. */ function source(withIncome = true) { const game = createGame('MARKETGHOLA', newPlayer('r','Richese','richese'), false, ['choam','ix']); game.players.push(newPlayer('c','CHOAM','choam'), newPlayer('a','Atreides','atreides')); if (withIncome) game.players.push(newPlayer('t','Tleilaxu','tleilaxu')); game.status = 'playing'; game.turn = 2; game.phase = 2; game.semutaPreview = true; game.response = null; game.decision = null; game.pendingKarama = null; game.phaseOpening = null; game.choamMarket = {owner:'c',resume:'phase',blocked:[],sale:{card:'sale',price:2}}; game.treacheryDiscardSequence = 7; const card = {id:'physical-ghola',name:'Ghola',kind:'special',effect:'ghola'} as const; game.discard = [{...card}]; game.pendingChoamMarketGhola = {turn:game.turn,phase:game.phase,player:'a',card:card.id, discardSequence:7,event:'',stage:'discard',response:{kind:'choamSale',owner:'c',passed:[]}, market:structuredClone(game.choamMarket)}; game.pendingChoamMarketGhola.event = choamGholaEvent(game.pendingChoamMarketGhola); const continuation:Ordinary = {kind:'ordinaryCardDiscard',player:'a',card:card.id,effect:'ghola', resume:{response:withIncome ? {kind:'revivalIncome',owner:'t',recipient:'a',amount:1,passed:[], intent:game.pendingChoamMarketGhola.event} : null,decision:null,pendingKarama:null,phaseOpening:null}, stateSignature:'staged'}; const entries:FreshDiscardBatch['entries'] = [{card:{...card},discardedBy:'a',publicFace:true}]; return {game,continuation,entries}; } function install(frame: ReturnType, reaction = true) { const {game,continuation,entries} = frame; game.pendingTreacheryDiscard = {sequence:7,batch:{event:'discard:2:2:7',turn:2,phase:2, cause:'ordinary:ghola',entries:structuredClone(entries)},continuation:structuredClone(continuation), ...(reaction ? {reaction:{stage:'offer' as const,passed:[]}} : {})}; return frame; } void test('a fresh actual market Ghola with and without native income is admitted without mutation', () => { for (const withIncome of [true,false]) { const {game,continuation,entries} = source(withIncome), before = structuredClone(game); assert.equal(supported(game,continuation,entries),true); assert.deepEqual(game,before); game.kullPreview = true; assert.equal(supported(game,continuation,entries),true); } }); void test('an installed reaction retains its same typed source, while an old unreacted discard stays closed', () => { const fresh = install(source()); const before = structuredClone(fresh.game); assert.equal(supported(fresh.game,fresh.continuation,fresh.entries),true); assert.deepEqual(fresh.game,before); const old = install(source(),false); assert.equal(supported(old.game,old.continuation,old.entries),false); }); void test('market, sequence, source, income and nested-control changes are rejected', () => { const changes: Array<(s:ReturnType) => void> = [ s => { s.game.advanced = true; }, s => { s.game.techTokens = {} as NonNullable; }, s => { s.game.sandtrout = true; }, s => { s.game.coalitionState = {} as NonNullable; }, s => { s.game.nexusKullPreview = true; }, s => { s.game.semutaPreview = undefined; }, s => { s.game.pendingChoamMarketGhola!.turn++; }, s => { s.game.pendingChoamMarketGhola!.stage = 'income'; }, s => { s.game.pendingChoamMarketGhola!.discardSequence++; }, s => { s.game.pendingChoamMarketGhola!.event = 'stale'; }, s => { s.game.pendingChoamMarketGhola!.card = 'other'; }, s => { s.game.pendingChoamMarketGhola!.player = 'r'; }, s => { s.game.pendingChoamMarketGhola!.market.sale!.price = 3; }, s => { s.game.pendingChoamMarketGhola!.response.owner = 'a'; }, s => { s.game.pendingChoamMarketGhola!.response.passed = ['a','a']; }, s => { s.entries[0].publicFace = false; }, s => { s.entries[0].discardedBy = 'r'; }, s => { s.game.discard = []; }, s => { s.continuation.resume.response!.intent = 'wrong'; }, s => { s.continuation.resume.response!.amount = 2; }, s => { s.continuation.resume.response!.passed = ['r']; }, s => { s.continuation.resume.decision = {kind:'auctionPayment',player:'a'}; }, s => { s.game.response = {kind:'choamSale',owner:'c',passed:[]}; }, s => { s.game.pendingKarama = {} as NonNullable; }, ]; for (const change of changes) { const candidate = source(); change(candidate); const before = structuredClone(candidate.game); assert.equal(supported(candidate.game,candidate.continuation,candidate.entries),false); assert.deepEqual(candidate.game,before); } }); void test('market Ghola income shares the same optional-module profile boundary', () => { const {game} = source(); assert.equal(profile(game), true); game.techTokens = {} as NonNullable; assert.equal(profile(game), false); game.techTokens = null; game.sandtrout = true; assert.equal(profile(game), false); game.sandtrout = false; game.coalitionState = {} as NonNullable; assert.equal(profile(game), false); });