Files
Dune/tests/sapho-shared-arrival-runtime.test.ts
Quintine e457478f7d
Verify and publish checkpoint container / container (push) Canceled after 0s
Support Sapho ordering for shared arrivals
2026-10-29 05:29:51 +11:00

208 lines
11 KiB
TypeScript

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<typeof createSemutaArrivalOverlapFixture>): 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);
});