import assert from 'node:assert/strict'; import test from 'node:test'; import { createGame, newPlayer, type Decision, type Game } from '../game/engine'; import { richeseRevealInterruptSupported as supported } from '../game/richese-reveal-interruption'; /** Staged parent records test only the admission quote; native parent and * physical token transitions are exercised by the separate runtime suite. */ function source(): Game { const g = createGame('REVEALQUOTE', newPlayer('r', 'Richese', 'richese'), false, ['choam', 'ix']); g.players.push(newPlayer('c', 'CHOAM', 'choam'), newPlayer('e', 'Emperor', 'emperor')); g.status = 'playing'; g.phase = 3; g.turn = 2; return g; } function parent(kind: Decision['kind']): Game { const g = source(); if (kind === 'auctionPayment') { g.decision = {kind, player:'e'}; g.auction = {cards:[], index:0, bid:3, bidder:'e', active:'e', passed:[], opener:0}; } else if (kind === 'richeseDeclaration' || kind === 'richeseCacheTerms' || kind === 'richeseCache' || kind === 'richeseUnbid') { g.decision = {kind, player:'r'}; g.richeseBidding = {owner:'r', event:'round', turn:g.turn, stage:kind === 'richeseDeclaration' ? 'declaration' : kind === 'richeseUnbid' ? 'lot' : 'cacheOffer', position:'first', normalCount:1, blackMarketSold:false, cacheCanceled:false}; if (kind === 'richeseUnbid') g.richeseAuction = { event:'lot', cardId:'card', source:'cache', owner:'r', method:'onceAround', eligible:['r','e'], order:['e','r'], tieOrder:['r','e'], active:null, bid:0, bidder:null, passed:['r','e'], acted:['r','e'], sealed:{}, outcome:{kind:'unbid'}, }; } else if (kind === 'choamMarket' || kind === 'choamTradeReply' || kind === 'choamTradeConfirm') { g.decision = {kind, player:kind === 'choamTradeReply' ? 'r' : 'c'}; g.choamMarket = {owner:'c', resume:'phase', blocked:[], ...(kind === 'choamMarket' ? {} : {trade:{ally:'r', offered:'offered', ...(kind === 'choamTradeConfirm' ? {returned:'returned'} : {})}})}; } else if (kind === 'richeseAllyOpportunity') { g.phase = 5; g.decision = {kind, player:'r', recipient:'e'}; g.richeseAllyOpportunity = {turn:g.turn, recipient:'e'}; } else throw Error(`Unrecognized staged parent ${kind}`); return g; } void test('each supported economic parent is admitted without modifying its frame or inspecting marker custody', () => { for (const kind of ['auctionPayment','richeseDeclaration','richeseCacheTerms','richeseCache','richeseUnbid', 'choamMarket','choamTradeReply','choamTradeConfirm','richeseAllyOpportunity'] as const) { const game = parent(kind), before = structuredClone(game); assert.equal(supported(game), true, kind); assert.deepEqual(game, before); assert.equal(game.players[0].noField?.deployed ?? null, null, 'Seat/marker authorization belongs to the caller'); } }); void test('a clean Ix phase opening remains admitted before or after its owner passes', () => { const game = source(); game.phase = 0; game.phaseOpening = {passed:[], initialize:true}; game.amalWindowOpen = true; assert.equal(supported(game), true); game.phaseOpening.passed.push('r'); assert.equal(supported(game), true); const before = structuredClone(game); assert.equal(supported(game), true); assert.deepEqual(game, before); game.decision = {kind:'auctionPayment', player:'e'}; assert.equal(supported(game), false); game.decision = null; game.amalWindowOpen = false; assert.equal(supported(game), false); }); void test('supported native responses admit the reveal without changing their original source or passes', () => { const charity = source(); charity.phase = 2; charity.response = {kind:'choamCharity', owner:'c', passed:['e']}; const auction = source(); auction.players.push(newPlayer('a','Atreides','atreides')); auction.auction = {cards:[{id:'lot', name:'Shield', kind:'shield'}], index:0, bid:0, bidder:null, active:'a', passed:[], opener:0}; auction.response = {kind:'atreidesAuction', owner:'a', passed:['e']}; const declaration = source(); declaration.richeseCache = []; declaration.richeseBidding = {owner:'r', event:'round', turn:declaration.turn, stage:'declaration', position:'first', normalCount:1, normalCountBeforeCache:1, blackMarketSold:false, cacheCanceled:false}; declaration.response = {kind:'richeseAuction',owner:'r',passed:['e']}; const grant = source(); grant.phase = 4; grant.players.push(newPlayer('f','Fremen','fremen')); grant.players.find(p => p.id === 'f')!.ally = 'r'; grant.players.find(p => p.id === 'r')!.ally = 'f'; grant.freeRevival = []; grant.response = {kind:'fremenRevivalGrant', owner:'f', recipient:'r', passed:['e'], fremenGrant:{turn:grant.turn, owner:'f', recipient:'r'}}; for (const game of [charity, auction, declaration, grant]) { const before = structuredClone(game); assert.equal(supported(game), true, game.response!.kind); assert.deepEqual(game, before); } }); void test('response admission requires the original phase, owner, source and no nested interaction', () => { const charity = source(); charity.phase = 2; charity.response = {kind:'choamCharity',owner:'c',passed:[]}; for (const patch of [ {phase:3}, {decision:{kind:'auctionPayment',player:'e'}}, {phaseOpening:{passed:[],initialize:true}}, {pendingShipment:{}}, {response:{kind:'choamCharity',owner:'e',passed:[]}}, {response:{kind:'choamCharity',owner:'c',passed:['unknown']}}, {response:{kind:'choamCharity',owner:'c',passed:['e','e']}}, {response:{kind:'choamCharity',owner:'c',passed:[],intent:'extra'}}, {choamCharity:{turn:2,canceled:false}}, ]) assert.equal(supported(Object.assign(structuredClone(charity), patch)), false, JSON.stringify(patch)); const auction = source(); auction.players.push(newPlayer('a','Atreides','atreides')); auction.auction = {cards:[{id:'lot',name:'Shield',kind:'shield'}], index:0, bid:0, bidder:null, active:'a', passed:[], opener:0}; auction.response = {kind:'atreidesAuction',owner:'a',passed:[]}; const missingLot = structuredClone(auction); missingLot.auction!.index = 1; assert.equal(supported(missingLot), false); const wrongOwner = structuredClone(auction); wrongOwner.response!.owner = 'e'; assert.equal(supported(wrongOwner), false); const unsupportedLot = structuredClone(auction); unsupportedLot.richeseAuction = {} as NonNullable; assert.equal(supported(unsupportedLot), false); const otherResponse = structuredClone(auction); otherResponse.response!.kind = 'richeseAuction'; assert.equal(supported(otherResponse), false); const declaration = source(); declaration.richeseCache = []; declaration.richeseBidding = {owner:'r',event:'round',turn:declaration.turn,stage:'declaration', position:'first',normalCount:1,normalCountBeforeCache:1,blackMarketSold:false,cacheCanceled:false}; declaration.response = {kind:'richeseAuction',owner:'r',passed:[]}; for (const patch of [ {phase:4}, {richeseCache:undefined}, {auction:auction.auction}, {richeseBidding:{...declaration.richeseBidding,turn:1}}, {richeseBidding:{...declaration.richeseBidding,owner:'e'}}, {richeseBidding:{...declaration.richeseBidding,stage:'lot'}}, {richeseBidding:{...declaration.richeseBidding,position:undefined}}, {richeseBidding:{...declaration.richeseBidding,normalCount:-1}}, {richeseBidding:{...declaration.richeseBidding,normalCountBeforeCache:3}}, {richeseBidding:{...declaration.richeseBidding,cacheCanceled:true}}, {pendingRevival:{}}, ]) assert.equal(supported(Object.assign(structuredClone(declaration), patch)), false, JSON.stringify(patch)); const grant = source(); grant.phase = 4; grant.players.push(newPlayer('f','Fremen','fremen')); grant.players.find(p => p.id === 'f')!.ally = 'r'; grant.players.find(p => p.id === 'r')!.ally = 'f'; grant.freeRevival = []; grant.response = {kind:'fremenRevivalGrant',owner:'f',recipient:'r',passed:[], fremenGrant:{turn:grant.turn,owner:'f',recipient:'r'}}; const stale = structuredClone(grant); stale.response!.fremenGrant!.turn--; assert.equal(supported(stale), false); const noAlly = structuredClone(grant); noAlly.players.find(p => p.id === 'r')!.ally = null; assert.equal(supported(noAlly), false); const wrongRecipient = structuredClone(grant); wrongRecipient.response!.recipient = 'e'; assert.equal(supported(wrongRecipient), false); const unrelatedChild = structuredClone(grant); unrelatedChild.pendingRevival = {} as NonNullable; assert.equal(supported(unrelatedChild), false); }); void test('a legacy table without public Truthtrance history keeps its earlier interruption behavior', () => { const game = parent('auctionPayment'); delete game.truthHistory; assert.equal(supported(game), false); }); void test('profile and nested-interaction boundaries do not admit another parent or module', () => { const patches: Array> = [ {advanced:true}, {status:'setup'}, {phase:6}, {phase:-1}, {turn:0}, {expansions:['choam','unknown']}, {expansions:['ix']}, {response:{kind:'richeseAuction', owner:'r', passed:[]}}, {pendingNullentropy:{} as NonNullable}, {pendingTreacheryDiscard:{} as NonNullable}, {pendingShipment:{} as NonNullable}, {pendingKull:{} as NonNullable}, {biddingEnd:{} as NonNullable}, {stormResolution:{} as NonNullable}, {techTokens:{} as NonNullable}, {leaderSkills:{} as NonNullable}, {discoveryEnabled:true}, {ecazTreachery:true}, ]; for (const patch of patches) { const game = Object.assign(parent('auctionPayment'), patch), before = structuredClone(game); assert.equal(supported(game), false, JSON.stringify(patch)); assert.deepEqual(game, before); } const wrong = parent('auctionPayment'); wrong.decision = {kind:'revivalStop', player:'e', recipient:'r', revival:'forces'}; assert.equal(supported(wrong), false); const missing = parent('auctionPayment'); missing.auction = null; assert.equal(supported(missing), false); const wrongSeat = parent('auctionPayment'); wrongSeat.auction!.bidder = 'r'; assert.equal(supported(wrongSeat), false); const native = parent('auctionPayment'); native.players.push(newPlayer('i','Ixians','ixians')); native.expansions = ['choam']; assert.equal(supported(native), false); const e3 = parent('auctionPayment'); e3.players.push(newPlayer('z','Ecaz','ecaz')); e3.expansions = ['choam']; assert.equal(supported(e3), false); e3.expansions = ['choam','ecaz']; assert.equal(supported(e3), true); }); void test('live current-turn commitments block the interruption; settled and previous-turn records do not', () => { const game = parent('auctionPayment'); game.shipmentPromises = [{turn:game.turn, player:'e', asker:'r', answer:true, territory:'carthag', minimum:3}]; assert.equal(supported(game), false); game.shipmentPromises[0].fulfilled = true; assert.equal(supported(game), true); game.shipmentPromises[0].fulfilled = false; game.shipmentPromises[0].turn--; assert.equal(supported(game), true); game.shipmentPromises = []; game.futureBattleLeaderPromises = [{turn:game.turn, asker:'r', player:'e', leader:'leader', answer:true, status:'waiting'}]; assert.equal(supported(game), false); game.futureBattleLeaderPromises[0].status = 'settled'; assert.equal(supported(game), true); game.deals = [{status:'active'}] as unknown as Game['deals']; assert.equal(supported(game), false); });