import assert from 'node:assert/strict'; import test from 'node:test'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { bribePromiseModeSupported } from '../game/bribe-promises'; import { revivalPromiseModeSupported } from '../game/revival-promises'; import { alliancePromiseModeSupported } from '../game/alliance-promises'; import { bidPromiseModeSupported } from '../game/bid-promises'; import { createSaphoTruthOrderFixture, playSaphoTruthOrder, finishTruthOrder, finishOriginalTruthQuestions, inventory as saphoInventory } from './fixture-sapho-truth-order'; const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action); const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!; function inventory(game: Game): string[] { const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...game.players.flatMap(player => player.hand)]; const ids = cards.map(card => card.id).sort(); assert.equal(new Set(ids).size, ids.length); return ids; } function take(game: Game, idOrEffect: string) { const zones = [game.deck, game.discard, game.richeseCache!, ...game.players.map(player => player.hand)]; for (const zone of zones) { const index = zone.findIndex(card => card.id === idOrEffect || card.effect === idOrEffect); if (index >= 0) return zone.splice(index, 1)[0]; } throw new Error(`Missing physical card ${idOrEffect}.`); } /** Use the actual public Advanced start and setup. For the specific discard * window, transfer the two existing physical cards and stage Movement; this * does not represent a natural auction or certify a complete game. */ function fixture() { let game = createGame('ADVSEMUTAPUBLIC', newPlayer('a', 'Atreides', 'atreides'), true, ['choam']); joinGame(game, newPlayer('r', 'Richese', 'richese')); joinGame(game, newPlayer('e', 'Emperor', 'emperor')); for (const player of game.players) game = act(game, player.id, {type:'ready'}); assert.throws(() => act(game, 'a', {type:'start'}), /Advanced.*preview/i); game = act(game, 'a', {type:'start', advancedPreview:true}); assert.equal(game.status, 'setup'); assert.equal(game.semutaAdvancedOrdinaryPreview, true); assert.equal(game.semutaPreview, undefined); assert.equal(game.advanced, true); for (let count = 0; game.status === 'setup' && count < 80; count++) { let next: Game | undefined; for (const player of game.players) { const view = viewGame(game, player.id); view.players.find(row => row.id === player.id)!.bot = 'Medium'; const action = botActions(view)[0]; if (action) { next = act(game, player.id, action); break; } } assert.ok(next, 'The genuine setup has a legal next step.'); game = next; } assert.equal(game.status, 'playing'); const before = inventory(game); const semuta = take(game, SEMUTA_DRUG_ID); const hajr = take(game, 'hajr'); seat(game, 'r').hand.push(semuta); seat(game, 'a').hand.push(hajr); assert.deepEqual(inventory(game), before); Object.assign(game, {turn:2, phase:5, active:'a', storm:18, ready:[], response:null, decision:null, phaseOpening:null, stormPending:null, movementRemaining:['a','r','e']}); // Advanced Hajr is available after the original Force Movement, not before. game = act(game, 'a', {type:'move', from:'arrakeen:10', territory:'imperial_basin', sector:9, amount:1, elite:0}); assert.equal(seat(game, 'a').moved, 1); return {game, semuta, hajr, before}; } for (const claim of [false, true]) void test(`public Advanced Richese offers Hajr to Semuta and ${claim ? 'claim' : 'pass'} resumes Movement`, () => { const f = fixture(), original = structuredClone(f.game); let game = act(f.game, 'a', {type:'card', card:f.hajr.id}); assert.deepEqual(f.game, original); assert.ok(game.hajr.includes('a')); const pending = game.pendingTreacheryDiscard; assert.ok(pending); assert.equal(pending.continuation.kind, 'ordinaryCardDiscard'); assert.equal(pending.reaction?.stage, 'offer'); const event = pending.batch.event; const reaction = viewGame(game, 'r').semutaReaction; assert.ok(reaction?.canCommit); for (const difficulty of DIFFICULTIES) { const view = viewGame(game, 'r'); view.players.find(player => player.id === 'r')!.bot = difficulty; assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass'), difficulty); } if (claim) { game = act(game, 'r', {type:'semutaCommit', event}); if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') game = act(game, 'r', {type:'semutaSelect', event, card:f.hajr.id}); } else { for (const actor of ['a','r','e']) game = act(game, actor, {type:'semutaPass', event}); } assert.equal(game.pendingTreacheryDiscard, null); assert.ok(game.hajr.includes('a')); assert.equal(seat(game, 'r').hand.some(card => card.id === f.hajr.id), claim); assert.equal(game.discard.some(card => card.id === f.hajr.id), !claim); assert.deepEqual(inventory(game), f.before); game = act(game, 'a', {type:'endMovement'}); assert.ok(!game.movementRemaining?.includes('a')); }); for (const effect of ['ghola', 'harvester', 'weather'] as const) { for (const claim of [false, true]) void test(`public Advanced Richese offers ${effect} to Semuta and ${claim ? 'claim' : 'pass'} preserves its effect`, () => { const f = fixture(); let game = f.game; // Keep actual card inventory conserved while replacing the staged Hajr. game.deck.push(take(game, f.hajr.id)); const card = take(game, effect); seat(game, 'a').hand.push(card); assert.deepEqual(inventory(game), f.before); if (effect === 'ghola') { Object.assign(game, {phase:4, active:null}); const player = seat(game, 'a'); assert.ok(player.forces['arrakeen:10'] > 0); player.forces['arrakeen:10']--; player.tanks++; } else if (effect === 'harvester') { Object.assign(game, {phase:1, active:null, nexus:false}); game.spiceWindow = {territory:'hagga_basin', sector:12, amount:6, harvested:false}; game.spice['hagga_basin:12'] = 6; } else { Object.assign(game, {phase:0, active:null, stormPending:2, stormResolution:null, stormCardWindowOpen:true}); } const before = {reserves:seat(game, 'a').reserves, tanks:seat(game, 'a').tanks}; const action: Action = effect === 'ghola' ? {type:'card', card:card.id, amount:1, elite:0} : effect === 'weather' ? {type:'card', card:card.id, amount:1} : {type:'card', card:card.id}; game = act(game, 'a', action); const pending = game.pendingTreacheryDiscard; assert.ok(pending); assert.equal(pending.continuation.kind, 'ordinaryCardDiscard'); assert.equal(pending.reaction?.stage, 'offer'); assert.equal(viewGame(game, 'r').semutaReaction?.canCommit, true); if (effect === 'ghola') { assert.equal(seat(game, 'a').tanks, before.tanks - 1); assert.equal(seat(game, 'a').reserves, before.reserves + 1); } else if (effect === 'harvester') { assert.equal(game.spice['hagga_basin:12'], 12); assert.equal(game.spiceWindow?.amount, 12); assert.equal(game.spiceWindow?.harvested, true); } else assert.equal(game.stormPending, 1); const committed = structuredClone({phase:game.phase, stormPending:game.stormPending, spice:game.spice, spiceWindow:game.spiceWindow, reserves:seat(game, 'a').reserves, tanks:seat(game, 'a').tanks}); const event = pending.batch.event; if (claim) { game = act(game, 'r', {type:'semutaCommit', event}); if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') game = act(game, 'r', {type:'semutaSelect', event, card:card.id}); } else for (const actor of ['a', 'r', 'e']) game = act(game, actor, {type:'semutaPass', event}); assert.equal(game.pendingTreacheryDiscard, null); assert.deepEqual({phase:game.phase, stormPending:game.stormPending, spice:game.spice, spiceWindow:game.spiceWindow, reserves:seat(game, 'a').reserves, tanks:seat(game, 'a').tanks}, committed); assert.equal(seat(game, 'r').hand.some(item => item.id === card.id), claim); assert.equal(game.discard.some(item => item.id === card.id), !claim); assert.deepEqual(inventory(game), f.before); }); } void test('Advanced ordinary Semuta entry preserves Sapho in a simultaneous Truthtrance order', () => { const f = createSaphoTruthOrderFixture({advanced:true, mode:'first'}); assert.equal(f.game.semutaAdvancedOrdinaryPreview, true); assert.equal(f.game.semutaPreview, undefined); assert.ok(viewGame(f.game, f.owner).saphoTruthOrderChoices.some(option => option.card === f.card && option.mode === 'first')); let game = playSaphoTruthOrder(f.game, f.owner, f.card, 'first'); assert.equal(game.pendingTreacheryDiscard ?? null, null); game = finishTruthOrder(game); assert.equal(game.truthtrance!.queue[0].player, f.owner); game = finishOriginalTruthQuestions(game); assert.equal(game.truthtrance, null); assert.deepEqual(saphoInventory(game), f.originalCards); }); void test('Advanced ordinary Semuta entry preserves existing bribe, revival, alliance and bid question admission', () => { const {game} = fixture(); assert.equal(game.semutaAdvancedOrdinaryPreview, true); assert.equal(game.semutaPreview, undefined); for (const supported of [bribePromiseModeSupported, revivalPromiseModeSupported, alliancePromiseModeSupported, bidPromiseModeSupported]) assert.equal(supported(game), true); });