import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, handLimit, normalizeAutomaticGame, viewGame, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { nativeShipmentSources } from '../game/homeworld-options'; import { splitLocation } from '../game/board'; import { advanceSemutaHomeworldChoam, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam'; import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share'; import { semutaHomeworldChoamMovementResources as resources, withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement'; import { biddingPhysicalIds as stock } from './fixture-homeworld-occupied-bidding'; import { createSemutaHomeworldSaphoFixture, disposeSemutaHomeworldSapho, finishSemutaHomeworldSaphoAuction, finishSemutaHomeworldSaphoMovement, type SemutaHomeworldSaphoFixture, type SemutaHomeworldSaphoMode, type SemutaHomeworldSaphoScope, } from './fixture-semuta-homeworld-sapho'; import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech'; const scopes: SemutaHomeworldSaphoScope[] = ['onceAround', 'movement']; const modes: SemutaHomeworldSaphoMode[] = ['first', 'last']; /** Assert the original consumer opportunities/payment, not receipt wiring. */ function finishOriginal(state: Game, f: SemutaHomeworldSaphoFixture) { const actions: RicheseSkillsTechStep[] = []; if (f.scope === 'onceAround') { const lot = f.game.richeseAuction!, buyer = lot.bidder!; const funding = viewGame(f.game, buyer).richeseAuction!; assert.equal(lot.bid, 1); assert.deepEqual(state.richeseAuction!.acted, f.prefix); assert.equal(state.richeseAuction!.bidder, buyer); assert.equal(state.richeseAuction!.bid, lot.bid); assert.equal(viewGame(state, buyer).richeseAuction!.ownAvailable, funding.ownAvailable); assert.equal(viewGame(state, buyer).richeseAuction!.ownAllyPayment, funding.ownAllyPayment); assert.throws(() => applyAction(state, buyer, { type: 'richeseBid', event: f.event, amount: 2, allyPayment: 0 }), 'The earlier bidder never receives a second opportunity'); const done = finishSemutaHomeworldSaphoAuction(state, f, actions); const expected = f.suffix.filter(id => player(state, id).hand.length < handLimit(player(state, id))); assert.deepEqual(actions.map(step => step.actor), expected); assert.equal(new Set([...f.prefix, ...actions.map(step => step.actor)]).size, f.prefix.length + actions.length); assert.equal(player(done, buyer).hand.filter(card => card.id === f.lotCard).length, 1); assert.equal(player(done, buyer).spice, player(f.game, buyer).spice - 1); assert.equal(player(done, f.holder).spice, player(f.game, f.holder).spice + 1, 'Original unoccupied Richese receives exactly the native cache-lot invoice'); for (const seat of done.players.filter(seat => ![buyer, f.holder].includes(seat.id))) assert.equal(seat.spice, player(f.game, seat.id).spice); assert.deepEqual(done.homeworlds?.custody, f.setup.homeworlds?.custody); assert.deepEqual(stock(done), stock(f.game)); assert.throws(() => applyAction(done, buyer, { type: 'richeseBid', event: f.event, amount: 2, allyPayment: 0 })); return done; } const result = finishSemutaHomeworldSaphoMovement(state, f, actions), done = result.game; assert.deepEqual(actions.filter(step => step.action.type === 'endMovement').map(step => step.actor), f.suffix, 'Every original combined turn finishes exactly once in the reordered public queue'); assert.equal(player(result.afterShipment, f.nextActor).spice, player(result.beforeShipment, f.nextActor).spice - 2); assert.equal(player(result.afterShipment, f.nextActor).reserves, player(result.beforeShipment, f.nextActor).reserves - 1); assert.equal(player(result.afterShipment, f.nextActor).forces[result.origin], 1); assert.equal(player(done, f.nextActor).forces[result.origin] ?? 0, 0); assert.equal(player(done, f.nextActor).forces[result.destination], 1); assert.equal(player(done, f.nextActor).moved, 1); assert.equal(done.turn, 1); assert.equal(done.phase, 7); assert.deepEqual(done.homeworlds?.custody, f.setup.homeworlds?.custody); assert.deepEqual(stock(done), stock(f.game)); assert.throws(() => applyAction(done, f.nextActor, result.shipment)); assert.throws(() => applyAction(done, f.nextActor, result.move)); return done; } for (const advanced of [false, true]) for (const scope of scopes) for (const mode of modes) { const label = `${advanced ? 'Advanced' : 'Basic'} original Homeworld Sapho ${scope} ${mode}`; for (const claim of [false, true]) void test(`${label}: Semuta ${claim ? 'claim' : 'pass'} retains reordered unacted opportunities and original funded continuation`, () => { const f = createSemutaHomeworldSaphoFixture(scope, mode, advanced); const original = stock(f.game), paid = resources(f.game); const offered = disposeSemutaHomeworldSapho(f); assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true); assert.equal(player(offered, f.owner).hand.some(card => card.id === f.card), false); assert.equal(offered.discard.filter(card => card.id === f.card).length, 1); assert.deepEqual(stock(offered), original); assert.deepEqual(resources(offered), paid, 'Sapho and its disposal cannot spend the paid prefix or start a turn'); assert.deepEqual(offered.order, f.game.order, 'Physical storm order is never rewritten'); assert.deepEqual(normalizeAutomaticGame(structuredClone(offered)), offered); if (scope === 'onceAround') { assert.deepEqual(offered.richeseAuction!.order.filter(id => !offered.richeseAuction!.acted.includes(id)), f.suffix); assert.deepEqual(offered.richeseAuction!.acted, f.prefix); assert.deepEqual(offered.richeseAuction!.tieOrder, f.game.richeseAuction!.tieOrder); assert.equal(viewGame(offered, 'a').richeseAuction!.card!.id, f.lotCard, 'Atreides retains the real public cache card knowledge'); assert.throws(() => applyAction(offered, f.nextActor, { type: 'richeseBid', event: f.event, amount: null, allyPayment: 0 })); } else { assert.deepEqual(offered.movementRemaining, f.suffix); assert.equal(offered.active, f.game.active, 'The original unstarted turn stays frozen until disposal resolves'); assert.ok(offered.players.every(seat => !seat.shipped && seat.moved === 0)); assert.throws(() => applyAction(offered, f.game.active!, { type: 'endMovement' })); } const event = offered.pendingTreacheryDiscard!.batch.event; const resumed = reactSemutaHomeworldChoam(structuredClone(offered), f.holder, claim); assert.equal(resumed.pendingTreacheryDiscard, null); assert.equal(resumed.active, f.nextActor); assert.deepEqual(resources(resumed), paid); assert.deepEqual(stock(resumed), original); assert.equal(player(resumed, f.holder).hand.filter(card => card.id === f.card).length, claim ? 1 : 0); assert.equal(resumed.discard.filter(card => card.id === f.card).length, claim ? 0 : 1); assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, claim ? 1 : 0); assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event })); assert.throws(() => applyAction(resumed, f.owner, f.action), 'The same physical Sapho cannot be spent twice'); const done = finishOriginal(resumed, f); assert.deepEqual(done.order, f.game.order); }); void test(`${label}: original full-hand singleton exchange and private entitlement`, () => { const f = createSemutaHomeworldSaphoFixture(scope, mode, advanced, true); const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder); assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder))); const offered = disposeSemutaHomeworldSapho(f); const neutral = disposeSemutaHomeworldSapho(f, absent); assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true); assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false); assert.throws(() => applyAction(neutral, f.holder, { type: 'semutaCommit', event: neutral.pendingTreacheryDiscard!.batch.event })); for (const seat of offered.players.filter(seat => seat.id !== f.holder)) { assert.deepEqual(viewGame(offered, seat.id), viewGame(neutral, seat.id)); assert.equal(viewGame(offered, seat.id).semutaReaction?.canCommit, false); assert.throws(() => applyAction(offered, seat.id, { type: 'semutaCommit', event: offered.pendingTreacheryDiscard!.batch.event })); } const claimed = reactSemutaHomeworldChoam(offered, f.holder, true); assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder))); assert.equal(player(claimed, f.holder).hand.some(card => card.id === f.card), true); assert.equal(player(claimed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false); finishOriginal(claimed, f); }); void test(`${label}: every minimal bot profile legally claims or passes before the native continuation`, () => { const f = createSemutaHomeworldSaphoFixture(scope, mode, advanced); const offered = disposeSemutaHomeworldSapho(f); for (const difficulty of DIFFICULTIES) { let game = structuredClone(offered); const own = viewGame(game, f.holder); own.players.find(seat => seat.id === f.holder)!.bot = difficulty; const proposal = botActions(own)[0]; assert.ok(proposal); assert.ok(['semutaCommit', 'semutaPass'].includes(proposal.type)); game = applyAction(game, f.holder, proposal); while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') { const pending = game.pendingTreacheryDiscard, reaction = pending.reaction; if (reaction?.stage !== 'offer') throw Error('Missing Semuta offer'); const actor = game.players.find(seat => !reaction.passed.includes(seat.id))!.id; game = applyAction(game, actor, { type: 'semutaPass', event: pending.batch.event }); } assert.equal(game.pendingTreacheryDiscard, null); assert.equal(player(game, f.holder).hand.some(card => card.id === f.card), proposal.type === 'semutaCommit'); const nextView = viewGame(game, f.nextActor); nextView.players.find(seat => seat.id === f.nextActor)!.bot = difficulty; const nextAction = botActions(nextView)[0]; assert.ok(nextAction, 'The resumed original actor has a real legal bot continuation'); const botContinued = applyAction(structuredClone(game), f.nextActor, nextAction); assert.deepEqual(stock(botContinued), stock(game)); finishOriginal(game, f); } }); void test(`${label}: stale source events and altered committed queue reject without card/resource loss`, () => { const f = createSemutaHomeworldSaphoFixture(scope, mode, advanced); const before = structuredClone(f.game); assert.throws(() => applyAction(f.game, f.owner, { ...f.action, event: 'expired' })); assert.deepEqual(f.game, before); const offered = disposeSemutaHomeworldSapho(f), tampered = structuredClone(offered); if (scope === 'onceAround') tampered.richeseAuction!.order.reverse(); else tampered.movementRemaining!.reverse(); const saved = structuredClone(tampered); assert.throws(() => applyAction(tampered, f.holder, { type: 'semutaCommit', event: tampered.pendingTreacheryDiscard!.batch.event })); assert.deepEqual(tampered, saved); const passed = applyAction(offered, f.holder, { type: 'semutaPass', event: offered.pendingTreacheryDiscard!.batch.event }); assert.throws(() => applyAction(passed, f.holder, { type: 'semutaCommit', event: offered.pendingTreacheryDiscard!.batch.event })); }); } for (const advanced of [false, true]) for (const mode of modes) void test( `${advanced ? 'Advanced' : 'Basic'} Homeworld Sapho ${mode}: paid shipment expires the old unstarted quote and new tail options preserve the current turn`, () => { const f = createSemutaHomeworldSaphoFixture('movement', mode, advanced); const origin = 'red_chasm:7'; const actor = f.game.active!; const sources = nativeShipmentSources(viewGame(f.game, actor), 1, 0); assert.ok(sources); let started = applyAction(f.game, actor, { type: 'ship', ...splitLocation(origin), amount: 1, elite: 0, homeworldSources: sources, allyPayment: 0 }); started = advanceSemutaHomeworldChoam(started, game => !game.response && !game.decision && !game.pendingTreacheryDiscard && !game.phaseOpening); assert.equal(player(started, actor).shipped, true); const paid = resources(started), original = stock(started); const options = viewGame(started, f.owner).saphoOptions.filter(option => option.scope === 'movement'); assert.ok(options.length > 0, 'The unstarted holder can reorder later unstarted combined turns'); assert.ok(options.every(option => option.scope === 'movement' && option.afterCurrent === actor)); assert.ok(options.every(option => option.event !== f.action.event), 'Queued tail options have a new source; the original unstarted quote expires'); const queue = [...started.movementRemaining!]; assert.equal(started.active, actor); assert.throws(() => applyAction(started, f.owner, f.action)); assert.equal(started.active, actor); assert.deepEqual(started.movementRemaining, queue); assert.deepEqual(resources(started), paid); assert.deepEqual(stock(started), original); assert.equal(player(started, f.owner).hand.some(card => card.id === f.card), true); }, );