import assert from 'node:assert/strict'; import test from 'node:test'; import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { bribePromiseModeSupported } from '../game/bribe-promises'; import { act, advance, clean, createPublicChoamKullFixture as fixture, player, publicKullAttempt as attempt, publicKullInventory as inventory } from './fixture-public-choam-kull'; const held = (g: Game,id: string,card: string) => player(g,id).hand.some(c => c.id === card); function reject(g: Game,id: string,action: Action) { const before = structuredClone(g); assert.throws(() => act(g,id,action)); assert.deepEqual(g,before); } function createLobby(advanced = false, expansions: string[] = ['choam','ix'], last: 'guild' = 'guild') { const game = createGame('KULLLOBBY',newPlayer('choam','CHOAM','choam'),advanced,expansions); joinGame(game,newPlayer('emperor','Emperor','emperor')); joinGame(game,newPlayer(last,last,last)); return game; } function ready(g: Game) { for (const seat of g.players) g = act(g,seat.id,{type:'ready'}); return g; } function declare(g: Game,kull: string) { const reaction = viewGame(g,'choam').kullReaction; assert.ok(reaction); return act(g,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:kull}); } void test('Kull is a host-configurable Basic lobby preview with supported Truth promises', () => { let lobby = createLobby(); assert.equal(lobby.lobbyOptions?.kullPreview,undefined); assert.equal(bribePromiseModeSupported(lobby),true); reject(lobby,'emperor',{type:'moduleOption',module:'kullPreview',enabled:true}); reject(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:'true'}); reject(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:true,other:true}); const baseline = act(ready(lobby),'choam',{type:'start'}); assert.equal(baseline.kullPreview,true); assert.equal(bribePromiseModeSupported(baseline),true); lobby = ready(lobby); lobby = act(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:true}); assert.ok(lobby.players.every(seat => !seat.ready)); assert.equal(lobby.lobbyOptions?.kullPreview,undefined); lobby = act(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:false}); assert.equal(lobby.lobbyOptions?.kullPreview,undefined); assert.equal(lobby.lobbyOptions?.kullOptOut,true); const f = fixture(); assert.equal(f.setup.kullPreview,true); assert.equal(bribePromiseModeSupported(f.setup),true,'Basic printed Kull now supports the structured bribe family'); assert.ok(f.actions.some(row => row.actor === f.lobby.host && row.action.type === 'start')); }); void test('incompatible opt-in starts reject without modifying their original lobby', () => { for (const config of [ {advanced:true,expansions:['choam','ix'],last:'guild' as const,module:true}, {advanced:false,expansions:['choam'],last:'guild' as const,module:false}, {advanced:false,expansions:['choam','ix'],last:'guild' as const,module:true}, ]) { let g = createLobby(config.advanced,config.expansions,config.last); if (config.module) g = act(g,'choam',{type:'moduleOption',module:'leaderSkills',enabled:true}); g = act(g,'choam',{type:'moduleOption',module:'kullPreview',enabled:true}); g = ready(g); reject(g,'choam',config.advanced ? {type:'start',advancedPreview:true} : {type:'start'}); } }); void test('public Kull offer, decline, successful use, and distinct counter retain native custody and income', () => { const f = fixture(), original = structuredClone(f.game), sourceCards = inventory(original); assert.equal(original.kullPreview,true); assert.deepEqual(sourceCards,f.originalCards); const wallet = original.players.map(p => p.spice), forces = original.players.map(p => ({forces:p.forces,reserves:p.reserves,tanks:p.tanks})); const offered = act(original,f.actor,attempt(f)); assert.equal(offered.pendingKull?.stage,'offer'); assert.equal(offered.karamaShipping,null); assert.ok(held(offered,f.actor,f.karama)); assert.deepEqual(offered.players.map(p => p.spice),wallet); assert.deepEqual(offered.players.map(p => ({forces:p.forces,reserves:p.reserves,tanks:p.tanks})),forces); for (const outsider of [f.actor,f.counterOwner]) { const reaction = viewGame(offered,outsider).kullReaction; assert.ok(reaction); assert.equal(reaction.canDecline,false); assert.deepEqual(reaction.plays,[]); } const reaction = viewGame(offered,f.reactor).kullReaction; assert.ok(reaction); assert.ok(reaction.plays.some(play => play.source === 'printed' && play.card.id === f.kull && !play.blocked)); reject(offered,f.counterOwner,{type:'kullDecision',event:reaction.event,decline:true}); reject(offered,f.reactor,{type:'kullDecision',event:'stale',decline:true}); const declined = act(offered,f.reactor,{type:'kullDecision',event:reaction.event,decline:true}); assert.equal(declined.pendingKull,null); assert.equal(declined.karamaShipping?.owner,f.actor); assert.equal(declined.discard.filter(card => card.id === f.karama).length,1); assert.ok(held(declined,f.reactor,f.kull)); assert.deepEqual(inventory(declined),sourceCards); const declared = declare(offered,f.kull); const successful = advance(declared,clean); assert.equal(successful.karamaShipping,null); assert.ok(held(successful,f.actor,f.karama)); assert.equal(successful.discard.filter(card => card.id === f.kull).length,1); assert.deepEqual(successful.players.map(p => p.spice),wallet); assert.deepEqual(inventory(successful),sourceCards); reject(successful,f.actor,attempt(f)); const counter = viewGame(declared,f.counterOwner).responseControls?.cancelCards ?? []; assert.ok(counter.includes(f.counter),'The distinct original Guild Karama is a legal counter'); const interrupted = act(declared,f.counterOwner,{type:'card',mode:'cancel',card:f.counter}); const resumed = advance(interrupted,clean); assert.equal(resumed.pendingKull,null); assert.equal(resumed.karamaShipping?.owner,f.actor); assert.ok(held(resumed,f.reactor,f.kull)); assert.equal(resumed.discard.filter(card => card.id === f.karama).length,1); assert.equal(resumed.discard.filter(card => card.id === f.counter).length,1); assert.deepEqual(inventory(resumed),sourceCards); }); void test('neutral public offer and deferred overbid do not depend on holding Kull', () => { const f = fixture(), absent = structuredClone(f.game), owner = player(absent,f.reactor); // Conserved controlled supplement: swap the original held Kull and a physical // undealt deck card; this is not an earned trade or a new card. const heldIndex = owner.hand.findIndex(card => card.id === f.kull); assert.ok(heldIndex >= 0); assert.ok(absent.deck.length); [owner.hand[heldIndex],absent.deck[0]] = [absent.deck[0],owner.hand[heldIndex]]; assert.deepEqual(inventory(absent),f.originalCards); const withCard = act(f.game,f.actor,attempt(f)), withoutCard = act(absent,f.actor,attempt(f)); assert.deepEqual(viewGame(withCard,f.actor).kullReaction,viewGame(withoutCard,f.actor).kullReaction); const auctionWithout = structuredClone(f.auction), auctionOwner = player(auctionWithout,f.reactor); const index = auctionOwner.hand.findIndex(card => card.id === f.kull); assert.ok(index >= 0); assert.ok(auctionWithout.deck.length); [auctionOwner.hand[index],auctionWithout.deck[0]] = [auctionWithout.deck[0],auctionOwner.hand[index]]; for (const g of [f.auction,auctionWithout]) { const amount = player(g,f.actor).spice + 1; reject(g,f.actor,{type:'bid',amount}); assert.notEqual(g.auction?.bidder,f.actor); assert.ok(held(g,f.actor,f.karama)); } }); void test('all four profiles use only the projected physical Kull cost', () => { const f = fixture(), offered = act(f.game,f.actor,attempt(f)); for (const difficulty of DIFFICULTIES) { const v = viewGame(offered,f.reactor); v.players.find(p => p.id === f.reactor)!.bot = difficulty; const action = botActions(v)[0]; assert.ok(action,difficulty); assert.deepEqual(action,{type:'kullDecision',event:v.kullReaction!.event,source:'printed',card:f.kull}); const result = act(offered,f.reactor,action); assert.equal(result.pendingKull?.stage,'counter'); assert.deepEqual(inventory(result),f.originalCards); } }); void test('a legacy unmarked Basic game retains its original Karama path', () => { const f = fixture(), legacy = structuredClone(f.game); // This controlled legacy-shape supplement removes the opt-in stamp only; // the original cards, turn, board and wallets remain from native play. delete legacy.kullPreview; const completed = act(legacy,f.actor,attempt(f)); assert.equal(completed.pendingKull ?? null,null); assert.equal(completed.karamaShipping?.owner,f.actor); assert.equal(completed.discard.filter(card => card.id === f.karama).length,1); assert.ok(held(completed,f.reactor,f.kull)); assert.deepEqual(inventory(completed),f.originalCards); });