import assert from 'node:assert/strict'; import test from 'node:test'; import { viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { createSemutaArrivalOverlapFixture, actSemutaArrivalOverlap as act, player, inventory, passCurrentSemuta, claimOverlapSemuta, advanceSemutaArrivalOverlap } from './fixture-semuta-arrival-overlap'; type Mode = 'first' | 'last'; type OrderView = { event: string; revision: number; order: string[]; passed: string[]; choices: { card: string; mode: Mode }[] }; function orderView(game: Game, actor: string): OrderView { const value = (viewGame(game, actor) as unknown as { saphoArrivalOrder: OrderView | null }).saphoArrivalOrder; assert.ok(value, 'The public shared-arrival ordering window is open'); return value; } function pass(game: Game, actor: string): Game { const order = orderView(game, actor); return act(game, actor, { type: 'arrivalOrderPass', event: order.event, revision: order.revision } as Action); } function use(game: Game, actor: string, card: string, mode: Mode): Game { const order = orderView(game, actor); assert.ok(order.choices.some(choice => choice.card === card && choice.mode === mode)); return act(game, actor, { type: 'saphoArrivalOrder', event: order.event, revision: order.revision, card, mode } as Action); } /** At the clean owned entry opportunity, exchange original physical cards only. * This preserves every hand size, card identity and actual resource/force state. */ function stageSapho(game: Game, recipient: string): string { const zones = [game.deck, game.discard, ...(game.richeseCache ? [game.richeseCache] : []), ...(game.richeseRemoved ? [game.richeseRemoved] : []), ...game.players.map(seat => seat.hand)]; const target = player(game, recipient).hand; const found = zones.find(zone => zone.some(card => card.effect === 'juiceOfSapho')); assert.ok(found); const at = found.findIndex(card => card.effect === 'juiceOfSapho'); const card = found[at]; if (found !== target) { const replacement = target.findIndex(item => item.effect !== 'semutaDrug'); assert.ok(replacement >= 0); [found[at], target[replacement]] = [target[replacement], found[at]]; } return card.id; } function decline(game: Game, who: 'ambassador' | 'terror'): Game { if (who === 'ambassador') { assert.ok(game.pendingAmbassador); return act(game, 'ecaz', { type: 'decision', event: game.pendingAmbassador.event, decline: true }); } assert.ok(game.pendingTerrorEntry); return act(game, 'moritani', { type: 'decision', decline: true }); } function checkArrival(game: Game, f: ReturnType): void { assert.equal(player(game, f.entrant).forces[f.location], 1); assert.equal(player(game, f.entrant).reserves, player(f.game, f.entrant).reserves - 1); assert.equal(player(game, f.entrant).spice, f.balances[f.entrant] - 1); assert.equal(player(game, f.entrant).shipped, true); assert.deepEqual(inventory(game), f.originalInventory); } for (const first of ['ambassador', 'terror'] as const) { void test(`Sapho may move ${first} last before the original shared arrival, then resume after Semuta`, () => { const f = createSemutaArrivalOverlapFixture({ effect: 'choam', first, terror: 'robbery' }); const holder = first === 'ambassador' ? f.owner : f.moritani; const other = holder === f.owner ? f.moritani : f.owner; const card = stageSapho(f.game, holder); assert.deepEqual(inventory(f.game), f.originalInventory); let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action); checkArrival(game, f); assert.equal(game.pendingAmbassador ?? null, null); assert.equal(game.pendingTerrorEntry ?? null, null); assert.deepEqual(orderView(game, holder).order, [holder, other]); assert.ok(orderView(game, holder).choices.some(choice => choice.card === card && choice.mode === 'last')); assert.deepEqual(orderView(game, other).choices, []); const stale = orderView(game, holder); assert.throws(() => act(game, holder, { type: 'saphoArrivalOrder', event: stale.event, revision: stale.revision + 1, card, mode: 'last' } as Action)); game = use(game, holder, card, 'last'); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(game.pendingAmbassador ?? null, null); assert.equal(game.pendingTerrorEntry ?? null, null); if (first === 'ambassador') { game = claimOverlapSemuta(game, f.claimant, card); assert.ok(player(game, f.claimant).hand.some(row => row.id === card)); } else { game = passCurrentSemuta(game); assert.ok(game.discard.some(row => row.id === card)); } assert.deepEqual(orderView(game, other).order, [other, holder]); game = pass(game, other); assert.equal(game.pendingArrivalOverlap?.first, first === 'ambassador' ? 'terror' : 'ambassador'); checkArrival(game, f); const now = first === 'ambassador' ? 'terror' : 'ambassador'; game = decline(game, now); game = decline(game, first); assert.equal(game.pendingArrivalOverlap, null); checkArrival(game, f); }); } void test('both eligible owners can pass without Sapho and four bot profiles provide legal replies', () => { const f = createSemutaArrivalOverlapFixture({ effect: 'choam', first: 'ambassador', terror: 'robbery' }); stageSapho(f.game, 'choam'); const initial = act(f.game, f.arrivalStep.actor, f.arrivalStep.action); const original = orderView(initial, f.owner); assert.deepEqual(original.order, [f.owner, f.moritani]); assert.deepEqual(original.choices, []); assert.deepEqual(orderView(initial, f.moritani).choices, []); for (const difficulty of DIFFICULTIES) { const view = viewGame(initial, f.owner); view.players.find(seat => seat.id === f.owner)!.bot = difficulty; const action = botActions(view)[0]; assert.ok(action, difficulty); assert.doesNotThrow(() => act(initial, f.owner, action)); } let game = pass(initial, f.owner); game = pass(game, f.moritani); assert.equal(game.pendingArrivalOverlap?.first, 'ambassador'); game = decline(game, 'ambassador'); game = decline(game, 'terror'); assert.equal(game.pendingArrivalOverlap ?? null, null); checkArrival(game, f); }); void test('a final Sapho response resumes directly into the newly first reaction after Semuta passes', () => { const f = createSemutaArrivalOverlapFixture({ effect: 'choam', first: 'ambassador', terror: 'robbery' }); const card = stageSapho(f.game, f.moritani); let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action); game = pass(game, f.owner); assert.deepEqual(orderView(game, f.moritani).passed, [f.owner]); game = use(game, f.moritani, card, 'first'); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(game.pendingAmbassador ?? null, null); assert.equal(game.pendingTerrorEntry ?? null, null); game = passCurrentSemuta(game); assert.equal(game.pendingTreacheryDiscard, null); assert.equal(game.pendingArrivalOverlap?.first, 'terror'); assert.equal(game.pendingTerrorEntry?.stage, 'offer'); checkArrival(game, f); game = decline(game, 'terror'); game = decline(game, 'ambassador'); assert.equal(game.pendingArrivalOverlap, null); checkArrival(game, f); }); void test('a Guild income response finishes before a deferred shared arrival opens its public order', () => { const f = createSemutaArrivalOverlapFixture({ effect: 'choam', first: 'ambassador', terror: 'robbery', copied: true }); // Replay the original real Mentat placement, then stop on CHOAM's own // actual next-turn movement opportunity instead of Guild's later turn. let game = act(f.beforeTerrorPlacement, f.terrorPlacement.actor, f.terrorPlacement.action); game = advanceSemutaArrivalOverlap(game, state => state.turn === 2 && state.phase === 5 && state.active === 'choam' && !state.response && !state.decision && !state.phaseOpening && !state.truthtrance && !state.pendingTreacheryDiscard); // The source's controlled ordinary opening hands have no Karama, so the // engine automatically resolves Guild income before exposing the queue. // Swap one existing physical card for a real Karama in Ecaz's hand to keep // the native cancellation opportunity open; no response is manufactured. const zones = [game.deck, game.discard, ...(game.richeseCache ? [game.richeseCache] : []), ...(game.richeseRemoved ? [game.richeseRemoved] : []), ...game.players.map(seat => seat.hand)]; const ecazHand = player(game, f.owner).hand; const source = zones.find(zone => zone.some(card => card.effect === 'karama')); assert.ok(source); const karamaIndex = source.findIndex(card => card.effect === 'karama'); const karama = source[karamaIndex]; if (source !== ecazHand) { const replacement = ecazHand.findIndex(card => card.effect !== 'semutaDrug'); assert.ok(replacement >= 0); [source[karamaIndex], ecazHand[replacement]] = [ecazHand[replacement], source[karamaIndex]]; } const before = structuredClone(game), originalCards = inventory(game); assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.token)?.location, f.territory); assert.equal(game.moritaniTerror!.tokens.find(token => token.id === f.terrorToken)?.location, f.territory); game = act(game, 'choam', { type: 'ship', territory: f.territory, sector: f.sector, amount: 1, elite: 0, allyPayment: 0 }); // A native Guild-rate response can precede actual payment. Pass it without // staging any response or changing its saved shipment. for (let count = 0; game.response?.kind === 'guildRate' && count < game.players.length; count++) { const seat = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(seat); game = act(game, seat.id, { type: 'passResponse' }); } assert.equal(game.response?.kind, 'guildIncome'); assert.ok(viewGame(game, f.owner).responseControls?.cancelCards.includes(karama.id)); assert.equal(game.pendingArrivalReaction?.length, 1); assert.equal(game.pendingArrivalReaction?.[0].entrant, 'choam'); assert.equal(game.pendingArrivalReaction?.[0].territory, f.territory); assert.equal(game.pendingArrivalOverlap ?? null, null); assert.equal(player(game, 'choam').reserves, player(before, 'choam').reserves - 1); for (let count = 0; game.response?.kind === 'guildIncome' && count < game.players.length; count++) { const seat = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(seat); game = act(game, seat.id, { type: 'passResponse' }); } assert.equal(game.response, null); assert.equal(game.pendingArrivalReaction ?? null, null); assert.equal(game.pendingAmbassador ?? null, null); assert.equal(game.pendingTerrorEntry ?? null, null); assert.deepEqual(orderView(game, f.owner).order, [f.owner, f.moritani]); assert.equal(player(game, 'choam').forces[f.location], 1); assert.equal(player(game, 'choam').reserves, player(before, 'choam').reserves - 1); assert.deepEqual(inventory(game), originalCards); game = pass(game, f.owner); game = pass(game, f.moritani); game = decline(game, 'ambassador'); game = decline(game, 'terror'); assert.equal(game.pendingArrivalOverlap, null); assert.equal(player(game, 'choam').forces[f.location], 1); assert.deepEqual(inventory(game), originalCards); });