208 lines
11 KiB
TypeScript
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);
|
|
});
|