Support Sapho ordering for shared arrivals
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 05:29:51 +11:00
1 parent b18a767470
commit e457478f7d
18 files changed
+808 -23

No files matched your search

+4
View File
@@ -29,6 +29,10 @@ export function createBasicNativeOverlapFixture(native: Native, first: First, en
send('ecaz',{type:'seatPosition',position:first === 'ambassador' ? 4 : 5});
send('choam',{type:'seatPosition',position:1});
send(native,{type:'seatPosition',position:2});
// CHOAM and Ix now enable printed Kull by default. This fixture exercises
// the separately supported no-Kull arrival profile, so the host opts out
// through the normal lobby control before anyone becomes ready.
send('ecaz',{type:'moduleOption',module:'kullPreview',enabled:false});
for (const seat of game.players) send(seat.id,{type:'ready'});
const lobby=structuredClone(game);
send('ecaz',{type:'start'});
+20 -2
View File
@@ -20,6 +20,22 @@ export const actSemutaArrivalOverlap = actBasicSemutaAmbassador;
export const player = basicSemutaAmbassadorPlayer;
export const inventory = basicSemutaAmbassadorInventory;
/** Complete only the original public ordering opportunity. Owners pass even
* when neither holds Sapho; no card custody or arrival outcome is staged. */
export function passArrivalOrder(source: Game): Game {
let game = source;
for (let count = 0; count < 2; count++) {
const overlap = game.pendingArrivalOverlap, order = overlap?.saphoOrder;
if (!order || game.pendingAmbassador || game.pendingTerrorEntry || game.pendingTreacheryDiscard ||
order.frame.passed.length === order.frame.holders.length) break;
const actor = order.frame.holders.find(id => !order.frame.passed.includes(id));
assert.ok(actor);
game = actSemutaArrivalOverlap(game, actor, { type: 'arrivalOrderPass', event: order.frame.event,
revision: order.frame.revision });
}
return game;
}
/** Quiet native choices after the inherited turn-one prefix; token placement,
* arrival and both reactions remain explicit caller actions. */
export function nextSemutaArrivalOverlapStep(game: Game): BasicSemutaAmbassadorStep | null {
@@ -160,6 +176,7 @@ export function claimOverlapSemuta(source: Game, actor: string, card: string): G
export function openOverlapAmbassadorGrant(source: Game, f: SemutaArrivalOverlapFixture): Game {
let game = source;
if (!game.pendingArrivalOverlap) game = actSemutaArrivalOverlap(game, f.arrivalStep.actor, f.arrivalStep.action);
game = passArrivalOrder(game);
assert.ok(game.pendingArrivalOverlap);
if (game.pendingTerrorEntry?.stage === 'offer')
game = actSemutaArrivalOverlap(game, f.moritani, { type: 'decision', decline: true });
@@ -171,8 +188,9 @@ export function openOverlapAmbassadorGrant(source: Game, f: SemutaArrivalOverlap
assert.equal(game.pendingAmbassador?.stage, 'cards'); return game;
}
export function openOverlapTerrorDiscard(source: Game, f: SemutaArrivalOverlapFixture): Game {
assert.equal(source.pendingTerrorEntry?.stage, 'offer');
let game = actSemutaArrivalOverlap(source, f.moritani, { type: 'decision', reveal: true });
let game = passArrivalOrder(source);
assert.equal(game.pendingTerrorEntry?.stage, 'offer');
game = actSemutaArrivalOverlap(game, f.moritani, { type: 'decision', reveal: true });
if (f.terror === 'robbery') {
assert.equal(game.pendingTerrorEntry?.stage, 'robbery');
game = actSemutaArrivalOverlap(game, f.moritani, { type: 'decision', choice: 'card' });
+2 -2
View File
@@ -3,7 +3,7 @@ import type { Action, Game } from '../game/engine';
import { validateAmbassadors } from '../game/ecaz-ambassadors';
import { fremenAmbassadorMovement } from '../game/fremen-ambassador';
import { actSemutaArrivalOverlap as act, createSemutaArrivalOverlapFixture,
inventory, player } from './fixture-semuta-arrival-overlap';
inventory, player, passArrivalOrder } from './fixture-semuta-arrival-overlap';
export { act, inventory, player };
@@ -37,7 +37,7 @@ export type SemutaOverlapChildFixture = ReturnType<typeof createSemutaOverlapChi
/** The real paid Guild arrival opens both original opportunities; the Fremen
* Ambassador then moves one existing Ecaz force into that same stronghold. */
export function openSemutaOverlapChild(f: SemutaOverlapChildFixture): Game {
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
let game = passArrivalOrder(act(f.game, f.arrivalStep.actor, f.arrivalStep.action));
assert.equal(game.pendingArrivalOverlap?.first, 'ambassador');
const entry = game.pendingAmbassador; assert.ok(entry && entry.stage === 'offer');
assert.equal(entry.token, f.fremenToken);
+2 -2
View File
@@ -5,7 +5,7 @@ import { botActions } from '../game/bots';
import {
createSemutaArrivalOverlapFixture as fixture, actSemutaArrivalOverlap as act,
inventory, player, passCurrentSemuta as passSemuta, claimOverlapSemuta as claimSemuta,
openOverlapTerrorDiscard,
openOverlapTerrorDiscard, passArrivalOrder,
} from './fixture-semuta-arrival-overlap';
function reject(game: Game, actor: string, action: Action) {
@@ -19,7 +19,7 @@ function reject(game: Game, actor: string, action: Action) {
* original unopened physical cards move from the deck to the entrant's hand. */
function source() {
const f = fixture({ effect: 'choam', first: 'terror', arrival: 'shipment', terror: 'sabotage' });
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
let game = passArrivalOrder(act(f.game, f.arrivalStep.actor, f.arrivalStep.action));
assert.equal(game.pendingTerrorEntry?.stage, 'offer');
game = openOverlapTerrorDiscard(game, f);
const originalDiscard = game.pendingTreacheryDiscard!.batch.event;
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { Action } from '../game/engine';
import { SaphoArrivalOrder, SaphoArrivalOrderChoiceButton, SaphoArrivalOrderPass,
type SaphoArrivalOrderProps } from '../components/sapho-arrival-order';
function props(choices: SaphoArrivalOrderProps['view']['choices'] = []): SaphoArrivalOrderProps & { sent: Action[] } {
const sent: Action[] = [];
const names: Record<string, string> = { ecaz: 'Ecaz', moritani: 'Moritani' };
return { view: { event: 'arrival-order-event', revision: 2, order: ['ecaz', 'moritani'], passed: [], choices },
me: 'ecaz', busy: false, name: id => names[id] ?? id,
sent, act(action) { sent.push(action); } };
}
const render = (input: SaphoArrivalOrderProps) => renderToStaticMarkup(createElement(SaphoArrivalOrder, input));
void test('both reaction owners see a public ordering opportunity without needing Sapho', () => {
const input = props(), html = render(input);
assert.match(html, /Ambassador and Terror reaction order/);
assert.match(html, /Ecaz.*→.*Moritani/);
assert.match(html, /provisional interpretation/);
assert.match(html, /Continue without changing reaction order/);
assert.doesNotMatch(html, /Use Juice of Sapho/);
SaphoArrivalOrderPass(input).props.onClick();
assert.deepEqual(input.sent, [{ type: 'arrivalOrderPass', event: 'arrival-order-event', revision: 2 }]);
});
void test('quoted private options dispatch the exact event-bound Sapho actions', () => {
const input = props([{ card: 'private-card-id', mode: 'first' }, { card: 'private-card-id', mode: 'last' }]);
const html = render(input);
assert.match(html, /Use Juice of Sapho to react first/);
assert.match(html, /Use Juice of Sapho to react last/);
assert.doesNotMatch(html, /private-card-id/);
for (const choice of input.view.choices) SaphoArrivalOrderChoiceButton({ ...input, choice }).props.onClick();
assert.deepEqual(input.sent, input.view.choices.map(choice => ({ type: 'saphoArrivalOrder',
event: 'arrival-order-event', revision: 2, ...choice })));
});
void test('busy, already-passed and other viewers cannot issue ordering actions', () => {
for (const variant of ['busy', 'passed', 'other'] as const) {
const input = props([{ card: 'sapho-card', mode: 'first' }]);
if (variant === 'busy') input.busy = true;
if (variant === 'passed') input.view.passed.push(input.me);
if (variant === 'other') input.me = 'other';
SaphoArrivalOrderPass(input).props.onClick();
SaphoArrivalOrderChoiceButton({ ...input, choice: input.view.choices[0] }).props.onClick();
assert.deepEqual(input.sent, []);
if (variant !== 'busy') assert.doesNotMatch(render(input), /data-testid="sapho-arrival-order-pass"/);
else assert.match(render(input), /disabled=""/);
}
});
void test('callbacks captured before an event, revision, pass or quoted choice changes do not send', () => {
for (const change of ['event', 'revision', 'pass', 'choice'] as const) {
const input = props([{ card: 'sapho-card', mode: 'first' }]);
const pass = SaphoArrivalOrderPass(input), choice = SaphoArrivalOrderChoiceButton({ ...input, choice: input.view.choices[0] });
if (change === 'event') input.view.event = 'new-event';
if (change === 'revision') input.view.revision++;
if (change === 'pass') input.view.passed.push(input.me);
if (change === 'choice') input.view.choices.splice(0);
if (change !== 'choice') pass.props.onClick();
choice.props.onClick();
assert.deepEqual(input.sent, []);
}
});
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createSaphoArrivalOrder,
passSaphoArrivalOrder,
quoteSaphoArrivalOrder,
reorderSaphoArrivalOrder,
validateSaphoArrivalOrder,
} from '../game/sapho-arrival-order';
const owners = ['ecaz', 'moritani'] as const;
const initial = () => createSaphoArrivalOrder('arrival:event', owners[0], owners[1]);
const request = (player: string, revision: number) => ({ event: 'arrival:event', revision, player });
void test('both public arrival owners can pass and retain the original storm order', () => {
const first = initial();
assert.deepEqual(first.frame.holders, owners);
validateSaphoArrivalOrder(first, 'arrival:event', owners, 'ecaz');
const a = passSaphoArrivalOrder(first, request('ecaz', 0));
assert.equal(a.done, false);
const b = passSaphoArrivalOrder(a.order, request('moritani', 1));
assert.equal(b.done, true);
assert.deepEqual(b.order.queue.map(row => row.player), owners);
assert.deepEqual(b.order.frame.passed, owners);
validateSaphoArrivalOrder(b.order, 'arrival:event', owners, 'ecaz');
assert.deepEqual(first.frame.passed, [], 'a later response does not mutate the prior frame');
});
for (const [player, mode] of [['moritani', 'first'], ['ecaz', 'last']] as const) {
void test(`${player} can select ${mode} without displacing a prior protected choice`, () => {
const start = initial();
assert.ok(quoteSaphoArrivalOrder(start, player).modes.includes(mode));
const used = reorderSaphoArrivalOrder(start, { ...request(player, 0), mode });
assert.equal(used.done, false);
assert.deepEqual(used.order.queue.map(row => row.player), ['moritani', 'ecaz']);
validateSaphoArrivalOrder(used.order, 'arrival:event', owners, 'moritani');
const other = player === 'ecaz' ? 'moritani' : 'ecaz';
assert.deepEqual(quoteSaphoArrivalOrder(used.order, other).modes, []);
const passed = passSaphoArrivalOrder(used.order, request(other, 1));
assert.equal(passed.done, true);
validateSaphoArrivalOrder(passed.order, 'arrival:event', owners, 'moritani');
assert.deepEqual(start.queue.map(row => row.player), owners);
});
}
void test('an arrival frame rejects stale, foreign, duplicate and no-op actions', () => {
const start = initial();
assert.throws(() => createSaphoArrivalOrder('arrival:event', 'ecaz', 'ecaz'));
assert.throws(() => passSaphoArrivalOrder(start, request('outsider', 0)));
assert.throws(() => passSaphoArrivalOrder(start, request('ecaz', 1)));
assert.throws(() => reorderSaphoArrivalOrder(start, { ...request('ecaz', 0), mode: 'first' }));
const passed = passSaphoArrivalOrder(start, request('ecaz', 0));
assert.throws(() => passSaphoArrivalOrder(passed.order, request('ecaz', 1)));
assert.throws(() => passSaphoArrivalOrder(passed.order, { ...request('moritani', 1), event: 'old' }));
});
void test('an arrival receipt remains bound to the parent owners, event and chosen first reaction', () => {
const used = reorderSaphoArrivalOrder(initial(), { ...request('moritani', 0), mode: 'first' }).order;
const restored = JSON.parse(JSON.stringify(used)) as typeof used;
validateSaphoArrivalOrder(restored, 'arrival:event', owners, 'moritani');
assert.throws(() => validateSaphoArrivalOrder(restored, 'other:event', owners, 'moritani'));
assert.throws(() => validateSaphoArrivalOrder(restored, 'arrival:event', ['moritani', 'ecaz'], 'moritani'));
assert.throws(() => validateSaphoArrivalOrder(restored, 'arrival:event', owners, 'ecaz'));
const tampered = structuredClone(restored);
delete tampered.frame.protectedFirst;
assert.throws(() => validateSaphoArrivalOrder(tampered, 'arrival:event', owners, 'moritani'));
const duplicate = structuredClone(restored);
duplicate.queue[1].player = duplicate.queue[0].player;
assert.throws(() => validateSaphoArrivalOrder(duplicate, 'arrival:event', owners, 'moritani'));
});
+207
View File
@@ -0,0 +1,207 @@
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);
});
+4 -4
View File
@@ -5,7 +5,7 @@ import {
createSemutaArrivalOverlapFixture as fixture, actSemutaArrivalOverlap as act,
player, inventory, passCurrentSemuta as pass, claimOverlapSemuta as claim,
openOverlapAmbassadorGrant as grant, openOverlapTerrorDiscard as terrorDiscard,
finishOverlapTerror as finishTerror, type SemutaArrivalOverlapFixture,
finishOverlapTerror as finishTerror, passArrivalOrder, type SemutaArrivalOverlapFixture,
} from './fixture-semuta-arrival-overlap';
function reject(game: Game, actor: string, action: Action) {
@@ -52,7 +52,7 @@ for (const first of ['ambassador', 'terror'] as const)
assert.equal(f.game.moritaniTerror!.placementTurn, 1);
assert.equal(f.game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'placed');
assert.ok(f.game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(f.territory + ':'))));
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
let game = passArrivalOrder(act(f.game, f.arrivalStep.actor, f.arrivalStep.action));
const overlap = game.pendingArrivalOverlap; assert.ok(overlap);
assert.equal(overlap.first, first); assert.equal(overlap.stage, 'first');
assert.equal(overlap.entrant, f.entrant); assert.equal(overlap.cause, arrival);
@@ -106,7 +106,7 @@ void test('CHOAM first recovers one private card before Sabotage, pays once and
void test('Robbery first passes its overflow before a full-hand Ixian claim and one original replacement', () => {
const f = fixture({ effect: 'ixians', first: 'terror', terror: 'robbery', fullHand: true });
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
let game = passArrivalOrder(act(f.game, f.arrivalStep.actor, f.arrivalStep.action));
const top = game.deck[0].id, size = game.deck.length;
game = terrorDiscard(game, f);
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
@@ -134,7 +134,7 @@ void test('Robbery first passes its overflow before a full-hand Ixian claim and
void test('Sabotage first spends Semuta on its victim before copied CHOAM creates a distinct discard', () => {
const f = fixture({ effect: 'choam', first: 'terror', arrival: 'movement', copied: true });
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
let game = passArrivalOrder(act(f.game, f.arrivalStep.actor, f.arrivalStep.action));
game = terrorDiscard(game, f);
const terrorEvent = game.pendingTreacheryDiscard!.batch.event;
assert.equal(game.pendingTreacheryDiscard!.batch.entries[0].card.id, f.entrantCard);