Support Sapho ordering for shared arrivals
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
b18a767470
commit
e457478f7d
18 files changed
+808
-23
No files matched your search
@@ -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,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' });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, []);
|
||||
}
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user