import test from 'node:test'; import assert from 'node:assert/strict'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { applyAction, createGame, initializeBaseGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game, } from '../game/engine'; import { baseDeck, leaders } from '../game/cards'; import { richeseCards } from '../game/richese-cards'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { JuiceOfSapho } from '../components/juice-of-sapho'; const card = 'richese-juice-of-sapho'; const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); function initial(advanced = false) { let g = createGame( 'SAPHOENGINE', newPlayer('a', 'Atreides', 'atreides'), advanced, ); joinGame(g, newPlayer('e', 'Emperor', 'emperor')); joinGame(g, newPlayer('g', 'Guild', 'guild')); g.players.forEach((p) => { p.ready = true; }); g = advanced ? initializeBaseGameForAudit(g) : applyAction(g, 'a', { type: 'start' }); for (const p of g.players) if (p.traitorChoices.length) g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0], }); assert.equal(g.status, 'playing'); Object.assign(g, { turn: 2, storm: 18, response: null, decision: null, phaseOpening: null, order: ['a', 'e', 'g'], ready: [], deck: baseDeck(), discard: [], richeseCache: richeseCards(), }); for (const p of g.players) { p.hand = []; p.spice = 20; p.forces = {}; p.reserves = 20; } return g; } function hold(g: Game, id: string) { const at = g.richeseCache!.findIndex((c) => c.id === card); assert.ok(at >= 0); g.players .find((p) => p.id === id)! .hand.push(g.richeseCache!.splice(at, 1)[0]); } function movement(owner = 'e', advanced = false) { let g = initial(advanced); g.phase = 4; hold(g, owner); for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); assert.equal(g.phase, 5); return g; } function auction(owner = 'e', method = 'onceAround', fullHand = false) { let g = initial(); g.players[2].faction = 'richese'; g.players[2].leaders = leaders('richese'); hold(g, owner); if (fullHand) g.players.find((p) => p.id === owner)!.hand.push(...g.deck.splice(0, 3)); g.phase = 2; for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); g = applyAction(g, 'g', { type: 'decision', event: g.richeseBidding!.event, position: 'first', }); g = applyAction(g, 'g', { type: 'decision', event: g.richeseBidding!.event, card: g.richeseCache![0].id, method, direction: 'counterclockwise', }); assert.equal(g.richeseAuction!.method, method); return g; } function action( g: Game, mode: 'first' | 'last', scope = g.phase === 5 ? 'movement' : 'onceAround', ): Action { return { type: 'card', card, mode, scope, event: scope === 'movement' ? `movement:${g.turn}` : g.richeseAuction!.event, }; } function reject(g: Game, owner: string, a: Action) { const before = reload(g); assert.throws(() => applyAction(g, owner, a)); assert.deepEqual(reload(g), before); } function end(g: Game) { return applyAction(g, g.active!, { type: 'endMovement' }); } function bid(g: Game, owner: string, amount: number | null) { return applyAction(g, owner, { type: 'richeseBid', event: g.richeseAuction!.event, amount, }); } void test('Once Around first changes only the still-unstarted lot and discards exactly once after JSON recovery', () => { const before = auction(); assert.notEqual(before.richeseAuction!.active, 'e'); const request = action(before, 'first'); const g = applyAction(reload(before), 'e', request); assert.equal(g.richeseAuction!.active, 'e'); assert.equal(g.active, 'e'); assert.deepEqual(g.order, before.order); assert.deepEqual(g.richeseAuction!.tieOrder, before.richeseAuction!.tieOrder); assert.equal(g.discard.filter((c) => c.id === card).length, 1); assert.equal( g.players[1].hand.some((c) => c.id === card), false, ); reject(g, 'e', request); const next = bid(reload(g), 'e', 2); assert.equal(next.richeseAuction!.bid, 2); assert.deepEqual(next.richeseAuction!.acted, ['e']); }); void test('Once Around last preserves prior bids and permits a final outbid after Richese', () => { let g = auction(); const first = g.richeseAuction!.active!; assert.notEqual(first, 'e'); g = bid(g, first, 2); const lotCard = g.richeseAuction!.cardId; g = applyAction(g, 'e', action(g, 'last')); assert.equal(g.richeseAuction!.bid, 2); assert.equal(g.richeseAuction!.bidder, first); assert.deepEqual(g.richeseAuction!.acted, [first]); assert.equal(g.richeseAuction!.active, 'g'); g = bid(g, 'g', 3); assert.equal(g.richeseAuction!.active, 'e'); reject(g, 'e', { type: 'richeseBid', event: g.richeseAuction!.event, amount: 3, }); g = bid(reload(g), 'e', 4); assert.ok(g.players[1].hand.some((c) => c.id === lotCard)); assert.equal(g.players[1].spice, 16); assert.equal(g.players[2].spice, 24); assert.deepEqual(g.order, ['a', 'e', 'g']); }); void test('Once Around first after a prior bid moves only unacted bidders and preserves the committed bid', () => { let g = auction('g'); const first = g.richeseAuction!.active!; g = bid(g, first, 1); const before = reload(g); assert.ok(viewGame(g, 'g').saphoOptions.some((o) => o.scope === 'onceAround' && o.mode === 'first')); g = applyAction(reload(g), 'g', action(g, 'first')); assert.deepEqual(g.richeseAuction!.acted, [first]); assert.equal(g.richeseAuction!.bid, 1); assert.equal(g.richeseAuction!.bidder, first); assert.deepEqual(g.richeseAuction!.tieOrder, before.richeseAuction!.tieOrder); assert.equal(g.richeseAuction!.active, 'g'); assert.equal(g.discard.filter((c) => c.id === card).length, 1); g = bid(reload(g), 'g', 2); assert.equal(g.richeseAuction!.active, 'e'); assert.deepEqual(g.richeseAuction!.acted, [first, 'g']); const sold = g.richeseAuction!.cardId; g = bid(g, 'e', 3); assert.ok(g.players[1].hand.some((c) => c.id === sold)); assert.equal(g.players[1].spice, 17); }); void test('Once Around rejects no-op first, completed opportunity, no-op last and stale event without card loss', () => { let g = auction(); g = bid(g, g.richeseAuction!.active!, 1); reject(g, 'e', action(g, 'first')); reject(g, 'e', { ...action(g, 'last'), event: 'expired' }); g = bid(g, 'e', 2); reject(g, 'e', action(g, 'last')); const alreadyLast = auction('g'); reject(alreadyLast, 'g', action(alreadyLast, 'last')); const alreadyFirst = auction('a'); reject(alreadyFirst, 'a', action(alreadyFirst, 'first')); }); void test('Silent bidding has no Sapho ordering options or reordered storm tie outcome', () => { let g = auction('e', 'silent'); assert.deepEqual(viewGame(g, 'e').saphoOptions, []); reject(g, 'e', action(g, 'last')); const lot = g.richeseAuction!.cardId; for (const id of ['g', 'e', 'a']) g = bid(g, id, 2); assert.ok(g.players[0].hand.some((c) => c.id === lot)); }); void test('Basic movement first/last preserve physical order and give each faction precisely one combined turn', () => { for (const mode of ['first', 'last'] as const) { const before = movement(); let g = applyAction(before, 'e', action(before, mode)); const acted: string[] = []; while (g.phase === 5) { acted.push(g.active!); g = end(reload(g)); } assert.deepEqual( acted, mode === 'first' ? ['e', 'a', 'g'] : ['a', 'g', 'e'], ); assert.deepEqual(g.order, before.order); assert.deepEqual(g.movementRemaining, []); assert.equal(g.discard.filter((c) => c.id === card).length, 1); } }); void test('The already-current player cannot spend Sapho on first, while last works at the next clean boundary', () => { let g = end(movement()); reject(g, 'e', action(g, 'first')); g = applyAction(g, 'e', action(g, 'last')); assert.deepEqual(g.movementRemaining, ['g', 'e']); assert.equal(g.active, 'g'); }); void test('Sapho first reorders only the unstarted movement suffix after a real completed shipment', () => { let g = movement('g'); g = applyAction(g, 'a', { type: 'ship', territory: 'polar_sink', sector: 0, amount: 2, }); g = end(g); assert.equal(g.active, 'e'); const completed = reload(g).players[0]; assert.equal(completed.shipped, true); assert.equal(completed.reserves, 18); const before = reload(g); const controls = renderToStaticMarkup( createElement(JuiceOfSapho, { game: viewGame(g, 'g'), act: () => {}, busy: false, }), ); assert.match( controls, /Take your movement turn first and discard Juice of Sapho/, ); assert.match(controls, /Completed turns stay completed/); assert.equal( renderToStaticMarkup( createElement(JuiceOfSapho, { game: viewGame(g, 'e'), act: () => {}, busy: false, }), ), '', ); const request = action(g, 'first'); g = applyAction(reload(g), 'g', request); assert.deepEqual(g.movementRemaining, ['g', 'e']); assert.equal(g.active, 'g'); assert.deepEqual(g.order, before.order); assert.deepEqual(g.players[0], completed); assert.deepEqual(g.players[1], before.players[1]); assert.equal(g.discard.filter((c) => c.id === card).length, 1); assert.equal( g.players[2].hand.some((c) => c.id === card), false, ); reject(g, 'g', request); g = end(reload(g)); assert.equal(g.active, 'e'); g = end(reload(g)); assert.notEqual(g.phase, 5); assert.deepEqual(g.movementRemaining, []); assert.deepEqual(g.players[0], completed); const startedNext = applyAction(before, 'e', { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, }); assert.deepEqual(viewGame(startedNext, 'g').saphoOptions, []); reject(startedNext, 'g', request); }); void test('Genuine Advanced setup permits Sapho first after Guild waits or finishes, retaining a granted current Guild turn', () => { let g = movement('e', true); assert.equal(g.players[1].elites!.reserves, 5); assert.equal(g.decision?.kind, 'guildTiming'); reject(g, 'e', action(g, 'first')); const deferred = applyAction(reload(g), 'g', { type: 'decision', take: false, }); assert.equal(deferred.active, 'a'); const first = applyAction(deferred, 'e', action(deferred, 'first')); assert.deepEqual(first.movementRemaining, ['e', 'a', 'g']); assert.equal(first.active, null); assert.equal(first.decision?.kind, 'guildTiming'); assert.equal(first.guildTimingGranted, deferred.guildTimingGranted); assert.equal(first.guildTimingLocked, deferred.guildTimingLocked); g = applyAction(g, 'g', { type: 'decision', take: true }); assert.equal(g.active, 'g'); reject(g, 'e', action(g, 'first')); g = applyAction(g, 'g', { type: 'ship', territory: 'polar_sink', sector: 0, amount: 2, }); assert.equal(g.decision?.kind, 'guildShipment'); g = applyAction(g, 'g', { type: 'decision', allow: true }); g = end(g); const completed = reload(g).players[2]; assert.equal(g.active, 'a'); assert.deepEqual(g.movementRemaining, ['a', 'e']); g = applyAction(reload(g), 'e', action(g, 'first')); assert.equal(g.active, 'e'); assert.deepEqual(g.movementRemaining, ['e', 'a']); assert.deepEqual(g.players[2], completed); g = end(reload(g)); assert.equal(g.active, 'a'); g = end(reload(g)); assert.notEqual(g.phase, 5); assert.deepEqual(g.players[2], completed); assert.equal(g.discard.filter((c) => c.id === card).length, 1); }); void test('Sapho first cannot move a queued participant with a spent shipment, movement or preparation', () => { for (const kind of ['shipment', 'movement', 'hajr'] as const) { const g = end(movement('g')); if (kind === 'shipment') g.players[2].shipped = true; if (kind === 'movement') g.players[2].moved = 1; if (kind === 'hajr') g.hajr.push('g'); assert.deepEqual(viewGame(g, 'g').saphoOptions, []); reject(g, 'g', action(g, 'first')); } }); void test('All four AI profiles use the private Sapho first option after a completed movement prefix', () => { for (const difficulty of DIFFICULTIES) { let g = movement('g'); g = applyAction(g, 'a', { type: 'ship', territory: 'polar_sink', sector: 0, amount: 2, }); g = end(g); const view = viewGame(g, 'g'); view.players.find((p) => p.id === 'g')!.bot = difficulty; const untouched = structuredClone(view); const proposals = botActions(view); assert.deepEqual(view, untouched); assert.deepEqual(proposals, [action(g, 'first')]); for (const id of ['a', 'e']) assert.deepEqual(viewGame(g, id).saphoOptions, []); const next = applyAction(reload(g), 'g', proposals[0]); assert.equal(next.active, 'g'); assert.deepEqual(next.players[0], g.players[0]); assert.equal(next.discard.filter((c) => c.id === card).length, 1); } }); void test('after shipment or movement another holder may queue last without splitting the active combined turn', () => { let g = movement(); g = applyAction(g, 'a', { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, }); assert.equal(g.players[0].shipped, true); reject(g, 'e', action(g, 'last')); const shipped = reload(g).players[0]; const shipmentOption = viewGame(g, 'e').saphoOptions.find(option => option.scope === 'movement' && option.mode === 'last'); assert.ok(shipmentOption && shipmentOption.scope === 'movement'); assert.equal(shipmentOption.afterCurrent, 'a'); g = applyAction(reload(g), 'e', { type: 'card', card, scope: shipmentOption.scope, mode: shipmentOption.mode, event: shipmentOption.event }); assert.equal(g.active, 'a'); assert.deepEqual(g.players[0], shipped); assert.deepEqual(g.movementRemaining, ['a', 'g', 'e']); assert.equal(g.players[1].hand.some(c => c.id === card), false); assert.equal(g.discard.filter(c => c.id === card).length, 1); let moved = movement(); moved.players[0].forces = { 'arrakeen:10': 1 }; moved.players[0].reserves = 19; moved = applyAction(moved, 'a', { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 10, amount: 1, }); assert.equal(moved.players[0].moved, 1); reject(moved, 'e', action(moved, 'last')); reject(moved, 'e', action(moved, 'first')); const active = reload(moved).players[0]; const movementOption = viewGame(moved, 'e').saphoOptions.find(option => option.scope === 'movement' && option.mode === 'last'); assert.ok(movementOption && movementOption.scope === 'movement'); assert.equal(movementOption.afterCurrent, 'a'); moved = applyAction(reload(moved), 'e', { type: 'card', card, scope: movementOption.scope, mode: movementOption.mode, event: movementOption.event }); assert.equal(moved.active, 'a'); assert.deepEqual(moved.players[0], active); assert.deepEqual(moved.movementRemaining, ['a', 'g', 'e']); assert.equal(moved.players[1].hand.some(c => c.id === card), false); assert.equal(moved.discard.filter(c => c.id === card).length, 1); }); void test('Completed holder and already-first/already-last movement no-ops cannot consume Sapho', () => { const first = movement('a'); reject(first, 'a', action(first, 'first')); const last = movement('g'); reject(last, 'g', action(last, 'last')); const completed = end(first); assert.deepEqual(viewGame(completed, 'a').saphoOptions, []); reject(completed, 'a', action(completed, 'last')); }); void test('Advanced Guild remains before Sapho last across repeated deferrals and saved-state normalization', () => { let g = movement('e', true); assert.equal(g.decision?.kind, 'guildTiming'); reject(g, 'e', action(g, 'last')); g = applyAction(g, 'g', { type: 'decision', take: false }); assert.equal(g.active, 'a'); assert.ok(viewGame(g, 'e').saphoOptions.some(option => option.scope === 'movement' && option.mode === 'first')); g = applyAction(g, 'e', action(g, 'last')); assert.deepEqual(g.movementRemaining, ['a', 'g', 'e']); assert.equal( g.decision, null, 'an unchanged current actor must not reopen the resolved Guild choice', ); assert.equal(g.active, 'a'); g = end(reload(g)); if (g.decision?.kind === 'guildTiming') g = applyAction(g, 'g', { type: 'decision', take: false }); assert.equal(g.active, 'g'); g = normalizeAutomaticGame(reload(g)); assert.equal(g.active, 'g'); g = end(g); assert.equal(g.active, 'e'); g = end(g); assert.notEqual(g.phase, 5); assert.deepEqual(g.order, ['a', 'e', 'g']); }); void test('Sapho last respects an already granted early Guild turn, including Guild itself moving to last', () => { for (const holder of ['a', 'g']) { let g = movement(holder, true); g = applyAction(g, 'g', { type: 'decision', take: true }); assert.equal(g.active, 'g'); assert.equal(g.guildTimingGranted, true); assert.equal(g.decision, null); assert.equal(g.response, null); assert.deepEqual(g.movementRemaining, ['a', 'e', 'g']); const heldView = viewGame(g, holder); assert.ok( heldView.saphoOptions.some( (option) => option.scope === 'movement' && option.mode === 'last', ), ); assert.equal( heldView.saphoOptions.some((option) => option.mode === 'first'), false, ); reject(g, holder, action(g, 'first')); g = applyAction(reload(g), holder, action(g, 'last')); assert.equal( g.guildTimingGranted, true, 'Sapho does not revoke the resolved Guild power', ); assert.equal( g.decision, null, 'last priority does not reopen Guild timing', ); assert.equal(g.response, null); assert.equal(g.active, holder === 'a' ? 'g' : 'a'); const expected = holder === 'a' ? ['g', 'e', 'a'] : ['a', 'e', 'g']; assert.deepEqual(g.movementRemaining, expected); assert.equal(g.saphoMovementLast?.player, holder); const acted: string[] = []; while (g.phase === 5) { assert.equal(g.decision, null); acted.push(g.active!); g = end(normalizeAutomaticGame(reload(g))); } assert.deepEqual(acted, expected); assert.deepEqual(g.order, ['a', 'e', 'g']); assert.deepEqual(g.movementRemaining, []); assert.equal(g.saphoMovementLast, null); assert.equal(g.discard.filter((c) => c.id === card).length, 1); } }); void test('early Guild timing makes the previous penultimate faction actually last without spending Sapho', () => { let g = movement('e', true); g = applyAction(g, 'g', { type: 'decision', take: true }); assert.equal(g.active, 'g'); assert.deepEqual(g.movementRemaining, ['a', 'e', 'g']); assert.deepEqual(viewGame(g, 'e').saphoOptions, []); reject(g, 'e', action(g, 'last')); assert.ok(g.players[1].hand.some((c) => c.id === card)); const acted: string[] = []; while (g.phase === 5) { acted.push(g.active!); g = end(g); } assert.deepEqual(acted, ['g', 'a', 'e']); }); void test('after Guild shipment another holder may queue last while Guild cannot split its own started turn', () => { for (const holder of ['a', 'g']) { let g = movement(holder, true); g = applyAction(g, 'g', { type: 'decision', take: true }); g = applyAction(g, 'g', { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, }); if (g.decision?.kind === 'guildShipment') g = applyAction(g, 'g', { type: 'decision', allow: true }); assert.equal(g.players[2].shipped, true); assert.equal(g.active, 'g'); reject(g, holder, action(g, 'last')); assert.ok( g.players.find((p) => p.id === holder)!.hand.some((c) => c.id === card), ); const options = viewGame(g, holder).saphoOptions; if (holder === 'g') { assert.deepEqual(options, []); continue; } const option = options.find(option => option.scope === 'movement' && option.mode === 'last'); assert.ok(option && option.scope === 'movement'); assert.equal(option.afterCurrent, 'g'); const before = reload(g); g = applyAction(reload(g), holder, { type: 'card', card, scope: option.scope, mode: option.mode, event: option.event }); assert.equal(g.active, 'g'); assert.deepEqual(g.players[2], before.players[2]); assert.equal(g.guildTimingGranted, before.guildTimingGranted); assert.equal(g.guildTimingLocked, before.guildTimingLocked); assert.deepEqual(g.movementRemaining, ['g', 'e', 'a']); assert.equal(g.players[0].hand.some(c => c.id === card), false); assert.equal(g.discard.filter(c => c.id === card).length, 1); } }); void test('a started movement preparation locks the turn before forces are shipped or moved', () => { for (const effect of ['hajr', 'karama']) { let g = movement(); const index = g.deck.findIndex((c) => c.effect === effect); const preparation = g.deck.splice(index, 1)[0]; g.players[0].hand.push(preparation); g = applyAction(g, 'a', { type: 'card', card: preparation.id, ...(effect === 'karama' ? { mode: 'shipment' } : {}), }); assert.equal(g.players[0].moved, 0); assert.equal(g.players[0].shipped, false); assert.deepEqual(viewGame(g, 'e').saphoOptions, []); reject(g, 'e', action(g, 'last')); } }); void test('saved last protection rejects corrupt phase, event, owner and duplicate or reordered queues without repair', () => { const before = movement(); const valid = applyAction(before, 'e', action(before, 'last')); assert.deepEqual(normalizeAutomaticGame(reload(valid)), reload(valid)); const corruptions: ((g: Game) => void)[] = [ (g) => { g.saphoMovementLast!.turn--; }, (g) => { g.saphoMovementLast!.event = 'movement:expired'; }, (g) => { g.saphoMovementLast!.player = 'foreign'; }, (g) => { g.phase = 6; }, (g) => { g.movementRemaining = ['a', 'e', 'g']; }, (g) => { g.movementRemaining = ['a', 'a', 'e']; }, (g) => { g.movementRemaining = null; }, ]; for (const corrupt of corruptions) { const g = reload(valid); corrupt(g); const saved = reload(g); assert.throws(() => normalizeAutomaticGame(g)); assert.throws(() => applyAction(g, 'a', { type: 'endMovement' })); assert.deepEqual(g, saved); } }); void test('discarding Sapho creates eligibility for a full-hand holder excluded from an open Once Around lot', () => { for (const mode of ['first', 'last'] as const) { let g = auction('e', 'onceAround', true); assert.equal(g.players[1].hand.length, 4); assert.equal(g.richeseAuction!.eligible.includes('e'), false); const sold = g.richeseAuction!.cardId; assert.ok(viewGame(g, 'e').saphoOptions.some((o) => o.mode === mode)); g = applyAction(g, 'e', action(g, mode)); assert.equal(g.players[1].hand.length, 3); assert.ok(g.richeseAuction!.eligible.includes('e')); assert.deepEqual(g.richeseAuction!.tieOrder, g.order); if (mode === 'first') { assert.equal(g.richeseAuction!.active, 'e'); g = bid(g, 'e', 1); while (g.richeseAuction && !g.richeseAuction.outcome) g = bid(g, g.richeseAuction.active!, null); } else { assert.equal(g.richeseAuction!.order.at(-1), 'e'); while (g.richeseAuction!.active !== 'e') g = bid(g, g.richeseAuction!.active!, 1 + g.richeseAuction!.bid); g = bid(g, 'e', 1 + g.richeseAuction!.bid); } assert.ok(g.players[1].hand.some((c) => c.id === sold)); assert.equal(g.players[1].hand.length, 4); assert.equal(g.discard.filter((c) => c.id === card).length, 1); } }); void test('Semuta pauses a clean Once Around Sapho order and preserves remaining bidders', () => { for (const mode of ['first', 'last'] as const) { let g = auction('e', 'onceAround', true); g.semutaPreview = true; g.expansions = ['choam']; const semuta = g.richeseCache!.findIndex(c => c.id === SEMUTA_DRUG_ID); assert.ok(semuta >= 0); g.players[0].hand.push(g.richeseCache!.splice(semuta, 1)[0]); const original = reload(g), lotEvent = g.richeseAuction!.event; g = applyAction(g, 'e', action(g, mode)); const batchEvent = g.pendingTreacheryDiscard!.batch.event; assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(g.richeseAuction?.event, lotEvent); assert.equal(g.richeseAuction?.eligible.includes('e'), true); assert.equal(g.players[1].hand.length, 3); assert.equal(viewGame(g, 'a').semutaReaction?.canCommit, true); assert.equal(viewGame(g, 'g').semutaReaction?.canCommit, false); assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g)); for (const [index, change] of [ (saved: Game) => { saved.richeseAuction!.order.reverse(); }, (saved: Game) => { saved.richeseAuction!.bid = 5; }, (saved: Game) => { saved.richeseAuction!.active = mode === 'first' ? 'a' : 'e'; saved.active = saved.richeseAuction!.active; }, (saved: Game) => { saved.richeseBidding!.position = 'last'; }, (saved: Game) => { saved.players[1].hand.pop(); }, ].entries()) { const corrupt = reload(g), before = reload(corrupt); change(corrupt); const unchanged = reload(corrupt); assert.throws(() => applyAction(corrupt, 'a', { type: 'semutaCommit', event: batchEvent }), `corruption ${index}`); assert.deepEqual(corrupt, unchanged); assert.notDeepEqual(corrupt, before); } let declined = reload(g); for (const id of ['a', 'e', 'g']) declined = applyAction(declined, id, { type: 'semutaPass', event: batchEvent }); assert.equal(declined.pendingTreacheryDiscard, null); assert.equal(declined.discard.filter(c => c.id === card).length, 1); const claimed = applyAction(reload(g), 'a', { type: 'semutaCommit', event: batchEvent }); assert.equal(claimed.pendingTreacheryDiscard, null); assert.deepEqual(claimed.richeseAuction, declined.richeseAuction); assert.equal(claimed.players[0].hand.filter(c => c.id === card).length, 1); assert.equal(claimed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1); assert.equal(claimed.richeseAuction?.acted.length, original.richeseAuction?.acted.length); assert.throws(() => applyAction(claimed, 'a', { type: 'semutaCommit', event: batchEvent })); const next = claimed.richeseAuction!.active!; const continued = bid(claimed, next, 1); assert.equal(continued.richeseAuction?.acted.includes(next), true); } }); void test('a reserved Black Market Semuta cannot claim an auction Sapho discard', () => { let g = auction('e'); g.semutaPreview = true; g.expansions = ['choam']; const semuta = g.richeseCache!.findIndex(c => c.id === SEMUTA_DRUG_ID); assert.ok(semuta >= 0); g.players[2].hand.push(g.richeseCache!.splice(semuta, 1)[0]); g.richeseAuction!.source = 'blackMarket'; g.richeseAuction!.cardId = SEMUTA_DRUG_ID; g = applyAction(g, 'e', action(g, 'first')); const event = g.pendingTreacheryDiscard!.batch.event; assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(viewGame(g, 'g').semutaReaction?.canCommit, false); assert.match(viewGame(g, 'g').semutaReaction?.blocked ?? '', /Black Market/); const before = reload(g); assert.throws(() => applyAction(g, 'g', { type: 'semutaCommit', event })); assert.deepEqual(reload(g), before); for (const id of ['a', 'e', 'g']) g = applyAction(g, id, { type: 'semutaPass', event }); assert.equal(g.pendingTreacheryDiscard, null); assert.equal(g.players[2].hand.filter(c => c.id === SEMUTA_DRUG_ID).length, 1); }); void test('Sapho options are owner-only and do not reveal hidden cards through another player view', () => { const withCard = movement(); assert.ok(viewGame(withCard, 'e').saphoOptions.length); assert.deepEqual(viewGame(withCard, 'a').saphoOptions, []); const without = reload(withCard); // Swap identities at fixed public draw/hand sizes and preserve the physical Sapho. const replacement = without.deck.pop()!; without.deck.push(...without.players[1].hand.splice(0)); without.players[1].hand.push(replacement); assert.equal(without.deck.length, withCard.deck.length); assert.deepEqual([...without.deck, ...without.players[1].hand].map(c => c.id).sort(), [...withCard.deck, ...withCard.players[1].hand].map(c => c.id).sort()); assert.deepEqual(viewGame(without, 'a'), viewGame(withCard, 'a')); assert.deepEqual(viewGame(without, 'e').saphoOptions, []); reject(withCard, 'a', action(withCard, 'last')); });