Preserve Basic shared arrival reactions through Semuta
Admit the original ordered Ecaz/Moritani entry in bare Basic Richese games, retain both native opportunities during fresh discard recovery, and bind each suspended child to its actual arrival. Show the current discard response in the table banner. Cover real five-seat setup and placements, both owner orders, physical payouts/draws/gifts, persistent controls and minimal legal bot participation.
This commit is contained in:
1 parent
7b9938219d
commit
4b2f4f3fa3
12 files changed
+815
-23
No files matched your search
@@ -59,6 +59,8 @@ export function nextBasicSemutaAmbassadorStep(game: Game): BasicSemutaAmbassador
|
||||
}
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
if (game.decision?.kind === 'ecazAmbassador' || game.decision?.kind === 'richeseCache') return null;
|
||||
if (game.decision?.kind === 'moritaniSetup' && game.status === 'setup' && !game.setupStage)
|
||||
return { actor: game.decision.player, action: { type: 'decision', territory: 'oh_gap', sector: 9 } };
|
||||
if (game.decision?.kind === 'ecazPlacement')
|
||||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' &&
|
||||
@@ -108,6 +110,7 @@ function originalPacket(effect: BasicSemutaAmbassadorEffect, copied: boolean) {
|
||||
export function createBasicSemutaAmbassadorFixture(options: {
|
||||
effect: BasicSemutaAmbassadorEffect; copied?: boolean; arrival?: 'shipment' | 'movement';
|
||||
cacheCard?: 'semuta' | 'ornithopter'; ecazTruth?: boolean;
|
||||
overlapOrder?: 'ambassador' | 'terror';
|
||||
}) {
|
||||
const effect = options.effect, copied = options.copied ?? false, packet = originalPacket(effect, copied);
|
||||
const arrivalKind = options.arrival ?? 'shipment';
|
||||
@@ -115,6 +118,7 @@ export function createBasicSemutaAmbassadorFixture(options: {
|
||||
let game = createGame('BASICSEMUTAAMBASSADOR', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz', 'choam']);
|
||||
for (const faction of ['choam', 'richese', 'guild'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
if (options.overlapOrder) joinGame(game, newPlayer('moritani', 'Moritani', 'moritani'));
|
||||
const owner = 'ecaz', beneficiary = owner, holder = 'guild', entrant = holder;
|
||||
const actions: BasicSemutaAmbassadorStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actBasicSemutaAmbassador(game, actor, action, actions); };
|
||||
@@ -123,6 +127,13 @@ export function createBasicSemutaAmbassadorFixture(options: {
|
||||
const before = structuredClone(game); act(step.actor, step.action); reach(until);
|
||||
return { before, step: structuredClone(step), after: structuredClone(game) };
|
||||
};
|
||||
if (options.overlapOrder) {
|
||||
act('guild', { type: 'seatPosition', position: 6 });
|
||||
if (options.overlapOrder === 'terror') act('moritani', { type: 'seatPosition', position: 4 });
|
||||
act('ecaz', { type: 'seatPosition', position: options.overlapOrder === 'ambassador' ? 4 : 5 });
|
||||
act('choam', { type: 'seatPosition', position: 1 });
|
||||
act('richese', { type: 'seatPosition', position: 2 });
|
||||
}
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, { type: 'start' });
|
||||
@@ -137,10 +148,11 @@ export function createBasicSemutaAmbassadorFixture(options: {
|
||||
const originalMainDeck = game.deck.map(card => card.id).sort();
|
||||
const originalCache = game.richeseCache!.map(card => card.id).sort();
|
||||
const originalTreachery = basicSemutaAmbassadorInventory(game);
|
||||
// Four still-undealt starts plus the first still-unopened normal purchase.
|
||||
// Still-undealt starts plus the first still-unopened normal purchase.
|
||||
// Ordinary cards avoid optional card-use controls during the source path.
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 5);
|
||||
assert.equal(ordinary.length, 5);
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind))
|
||||
.slice(0, game.players.length + 1);
|
||||
assert.equal(ordinary.length, game.players.length + 1);
|
||||
if (options.ecazTruth) ordinary[0] = game.deck.find(card => card.effect === 'truthtrance')!;
|
||||
const deal = ordinary.map(card => card.id);
|
||||
const dealOrder = deal.map((id, position) => {
|
||||
@@ -204,7 +216,7 @@ export function createBasicSemutaAmbassadorFixture(options: {
|
||||
const normalPurchase = transition({ actor: owner, action: { type: 'bid', amount: 1, allyPayment: 0 } },
|
||||
state => basicSemutaAmbassadorPlayer(state, owner).hand.length === 2 && basicSemutaAmbassadorClean(state));
|
||||
assert.equal(basicSemutaAmbassadorPlayer(game).spice, basicSemutaAmbassadorPlayer(normalPurchase.before).spice - 1);
|
||||
assert.deepEqual(basicSemutaAmbassadorPlayer(game).hand.map(card => card.id), [deal[0], deal[4]]);
|
||||
assert.deepEqual(basicSemutaAmbassadorPlayer(game).hand.map(card => card.id), [deal[0], deal[game.players.length]]);
|
||||
reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
const territory = 'habbanya_ridge_sietch', sector = 17;
|
||||
const placement = transition({ actor: owner, action: { type: 'decision', token: token.id, territory } },
|
||||
@@ -259,6 +271,6 @@ export function createBasicSemutaAmbassadorFixture(options: {
|
||||
preReplacementDeckTop, balances: basicSemutaAmbassadorBalances(game),
|
||||
entrantContinuation: { actor: entrant, action: { type: 'endMovement' } } as BasicSemutaAmbassadorStep,
|
||||
actions, sourceActionCount: actions.length,
|
||||
staging: [`Only five unopened original main-deck positions (four starts and the first normal purchase), two unopened ordinary land Spice positions and original scalar lotteries are selected. Nine explicitly recorded draws select the original Ambassador packet on the final original traitor choice. Other scalar draws retain Fisher–Yates order; UUID/byte entropy remains native. Public Basic Ecaz/CHOAM/Richese/Guild setup retains the selected Ecaz+CHOAM decks (35 main plus 10 genuine native cache cards) and no optional modules. Guild buys the physical Semuta Drug in the real Once Around cache auction for 1; Ecaz pays 1 for the original first ordinary card and 1 for its actual placement. Guild then pays 1 for the non-allied original reserve entry. Native/BG-copy choices stop at the original cards grant. No dealt hands, resources, armies, clocks, alliances, deaths, token custody, pending reactions or receipts are installed. No discard, CHOAM payout, Ixian replacement or Semuta commitment has happened in this source snapshot. Optional source selection buys ${cacheCardId} instead and deals original Truthtrance to Ecaz=${!!options.ecazTruth}.`] };
|
||||
staging: [`Only ${game.players.length + 1} unopened original main-deck positions (${game.players.length} starts and the first normal purchase), two unopened ordinary land Spice positions and original scalar lotteries are selected. Nine explicitly recorded draws select the original Ambassador packet on the final original traitor choice. Other scalar draws retain Fisher–Yates order; UUID/byte entropy remains native. Public Basic Ecaz/CHOAM/Richese/Guild${options.overlapOrder ? '/Moritani' : ''} setup retains the selected Ecaz+CHOAM decks (35 main plus 10 genuine native cache cards) and no optional modules. Guild buys the physical Semuta Drug in the real Once Around cache auction for 1; Ecaz pays 1 for the original first ordinary card and 1 for its actual placement. Guild then pays 1 for the non-allied original reserve entry. Native/BG-copy choices stop at the original cards grant. No dealt hands, resources, armies, clocks, alliances, deaths, token custody, pending reactions or receipts are installed. No discard, CHOAM payout, Ixian replacement or Semuta commitment has happened in this source snapshot. Optional source selection buys ${cacheCardId} instead and deals original Truthtrance to Ecaz=${!!options.ecazTruth}.`] };
|
||||
}
|
||||
export type BasicSemutaAmbassadorFixture = ReturnType<typeof createBasicSemutaAmbassadorFixture>;
|
||||
@@ -0,0 +1,190 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Action, Game } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import {
|
||||
actBasicSemutaAmbassador, basicSemutaAmbassadorClean,
|
||||
basicSemutaAmbassadorInventory, basicSemutaAmbassadorPlayer,
|
||||
createBasicSemutaAmbassadorFixture, nextBasicSemutaAmbassadorStep,
|
||||
type BasicSemutaAmbassadorEffect, type BasicSemutaAmbassadorStep,
|
||||
} from './fixture-basic-semuta-ambassador';
|
||||
|
||||
export type SemutaArrivalOverlapOptions = {
|
||||
effect: BasicSemutaAmbassadorEffect;
|
||||
first: 'ambassador' | 'terror';
|
||||
arrival?: 'shipment' | 'movement';
|
||||
terror?: 'sabotage' | 'robbery';
|
||||
copied?: boolean;
|
||||
fullHand?: boolean;
|
||||
};
|
||||
export const actSemutaArrivalOverlap = actBasicSemutaAmbassador;
|
||||
export const player = basicSemutaAmbassadorPlayer;
|
||||
export const inventory = basicSemutaAmbassadorInventory;
|
||||
|
||||
/** 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 {
|
||||
if (!game.response && !game.phaseOpening && game.decision) {
|
||||
const d = game.decision;
|
||||
if (d.kind === 'moritaniPlacement' || d.kind === 'moritaniTerror') return null;
|
||||
if (d.kind === 'richeseCache') {
|
||||
const card = game.richeseCache!.find(card => card.id !== SEMUTA_DRUG_ID); assert.ok(card);
|
||||
return { actor: d.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: card.id, method: 'onceAround', direction: 'counterclockwise' } };
|
||||
}
|
||||
if (d.kind === 'richeseUnbid') return { actor: d.player,
|
||||
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||
}
|
||||
return nextBasicSemutaAmbassadorStep(game);
|
||||
}
|
||||
export function advanceSemutaArrivalOverlap(game: Game, until: (state: Game) => boolean,
|
||||
actions: BasicSemutaAmbassadorStep[] = []): Game {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaArrivalOverlapStep(game);
|
||||
assert.ok(step, `Supply the live native shared-entry choice: ${JSON.stringify({
|
||||
turn: game.turn, phase: game.phase, decision: game.decision, response: game.response?.kind,
|
||||
})}`);
|
||||
game = actSemutaArrivalOverlap(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original shared-entry programme exceeded its finite boundary');
|
||||
}
|
||||
|
||||
/** Five genuine public Basic seats, original setup and cache purchase, real
|
||||
* turn-one marker placements, then a paid turn-two entry. No clock, order,
|
||||
* force, token or reaction receipt is installed. */
|
||||
export function createSemutaArrivalOverlapFixture(options: SemutaArrivalOverlapOptions) {
|
||||
const original = createBasicSemutaAmbassadorFixture({ effect: options.effect,
|
||||
copied: options.copied, overlapOrder: options.first });
|
||||
const prefixEnd = original.actions.findIndex(step => step.actor === original.entrant && step.action.type === 'ship');
|
||||
assert.ok(prefixEnd >= 0);
|
||||
const prefixActions = structuredClone(original.actions.slice(0, prefixEnd));
|
||||
const branchActions: BasicSemutaAmbassadorStep[] = [];
|
||||
let game = structuredClone(original.beforeArrival);
|
||||
const originalInventory = inventory(game);
|
||||
const owner = original.owner, entrant = original.entrant, moritani = 'moritani', claimant = 'richese';
|
||||
const effect = options.effect, copied = !!options.copied;
|
||||
const terror = options.terror ?? 'sabotage', first = options.first;
|
||||
const arrival = options.arrival ?? 'shipment';
|
||||
const territory = original.territory, sector = original.sector;
|
||||
const act = (actor: string, action: Action) => {
|
||||
game = actSemutaArrivalOverlap(game, actor, action, branchActions);
|
||||
};
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
game = advanceSemutaArrivalOverlap(game, until, branchActions);
|
||||
};
|
||||
// Abandon only the inherited unplayed branch, not an already submitted entry.
|
||||
act(entrant, { type: 'endMovement' });
|
||||
reach(state => state.turn === 1 && state.phase === 8 &&
|
||||
!state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement');
|
||||
const beforeTerrorPlacement = structuredClone(game);
|
||||
const terrorToken = game.moritaniTerror!.tokens.find(token => token.kind === terror && token.status === 'available');
|
||||
assert.ok(terrorToken);
|
||||
const terrorPlacement: BasicSemutaAmbassadorStep = { actor: moritani,
|
||||
action: { type: 'decision', token: terrorToken.id, territory } };
|
||||
act(terrorPlacement.actor, terrorPlacement.action);
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === entrant &&
|
||||
basicSemutaAmbassadorClean(state));
|
||||
assert.equal(game.storm, 3, 'The first native zero dials and subsequent legal one dials retain the source route');
|
||||
assert.equal(game.order.indexOf(owner) < game.order.indexOf(moritani), first === 'ambassador');
|
||||
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === original.token)!.location, territory);
|
||||
assert.equal(game.moritaniTerror!.tokens.find(token => token.id === terrorToken.id)!.location, territory);
|
||||
assert.ok(!player(game, entrant).shipped && player(game, entrant).moved === 0);
|
||||
const beforeStaging = structuredClone(game);
|
||||
const held = player(game, entrant).hand.findIndex(card => card.id === SEMUTA_DRUG_ID); assert.ok(held >= 0);
|
||||
player(game, claimant).hand.push(player(game, entrant).hand.splice(held, 1)[0]);
|
||||
const fill = (actor: string) => {
|
||||
while (player(game, actor).hand.length < 4) {
|
||||
const index = game.deck.findIndex(card => ['projectile', 'poison', 'shield', 'snooper', 'worthless'].includes(card.kind));
|
||||
assert.ok(index >= 0); player(game, actor).hand.push(game.deck.splice(index, 1)[0]);
|
||||
}
|
||||
};
|
||||
if (terror === 'robbery') fill(moritani);
|
||||
if (options.fullHand) fill(claimant);
|
||||
const beforeShipment = structuredClone(game);
|
||||
const shipment: BasicSemutaAmbassadorStep = { actor: entrant, action: { type: 'ship',
|
||||
territory: arrival === 'shipment' ? territory : 'habbanya_ridge_flat', sector,
|
||||
amount: 1, elite: 0, allyPayment: 0 } };
|
||||
let arrivalStep = shipment;
|
||||
if (arrival === 'movement') {
|
||||
act(shipment.actor, shipment.action); reach(basicSemutaAmbassadorClean);
|
||||
assert.equal(player(game, entrant).spice, player(beforeShipment, entrant).spice - 1);
|
||||
arrivalStep = { actor: entrant, action: { type: 'move', from: 'habbanya_ridge_flat:17',
|
||||
amount: 1, territory, sector } };
|
||||
}
|
||||
assert.deepEqual(inventory(game), originalInventory);
|
||||
const chosenCards = player(game, owner).hand.slice(0, effect === 'choam' ? 2 : 1).map(card => card.id);
|
||||
assert.equal(chosenCards.length, effect === 'choam' ? 2 : 1);
|
||||
const entrantCard = player(game, entrant).hand[0]; assert.ok(entrantCard);
|
||||
return { game, arrivalStep, effect, first, arrival, terror, copied,
|
||||
owner, beneficiary: owner, moritani, entrant, claimant,
|
||||
token: original.token, terrorToken: terrorToken.id, semuta: SEMUTA_DRUG_ID,
|
||||
territory, sector, location: territory + ':' + sector, chosenCards,
|
||||
entrantCard: entrantCard.id, overflowDiscard: player(game, moritani).hand[0].id,
|
||||
gift: player(game, moritani).hand[0].id, originalInventory,
|
||||
lobby: original.lobby, setup: original.setup, afterSetup: original.afterSetup,
|
||||
beforeTerrorPlacement, terrorPlacement, beforeStaging, beforeShipment, shipment,
|
||||
prefixActions, branchActions, actions: [...prefixActions, ...branchActions],
|
||||
sourceActions: prefixActions.length + branchActions.length,
|
||||
sourceActionCount: prefixActions.length + branchActions.length,
|
||||
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
|
||||
fullHand: !!options.fullHand,
|
||||
staging: [
|
||||
'Fresh five-seat Basic Ecaz/CHOAM/Richese/Guild/Moritani E2/E3 setup uses the original 35-card main deck and ten-card cache with no optional modules. Six unopened main-card positions, two unopened ordinary Spice positions and scalar lotteries are selected. Nine original Ambassador packet draws precede native Moritani token shuffling. Actual lobby circle choices create the requested owner order; no playing order is assigned.',
|
||||
'Guild actually buys original cache Semuta for one spice. Ecaz buys one ordinary card and pays one spice for its real turn-one Habbanya Ridge Sietch placement. The inherited unplayed turn-one Guild entry is omitted, Guild ends its turn, and the real native Mentat opportunity places the selected Terror at that same destination. Native subsequent one-plus-one Storm dials and quiet controls reach the original turn-two Guild movement turn.',
|
||||
'At that clean boundary, label the conserved prior transfer of the physical paid Semuta from Guild to Richese. Robbery additionally fills Moritani to its four-card limit from original ordinary deck cards; optional full-hand cases also fill Richese. These acquisitions are explicit staging, not played trades or auctions. No wallet, force, token, clock, alliance, discard, pending frame or receipt is installed. A movement branch really pays for its Ridge Flat reserve shipment before the caller-owned final move; a shipment branch leaves the actual paid destination entry to the caller.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaArrivalOverlapFixture = ReturnType<typeof createSemutaArrivalOverlapFixture>;
|
||||
|
||||
/** Only the current event: continuing it must never also pass a later group. */
|
||||
export function passCurrentSemuta(source: Game): Game {
|
||||
const pending = source.pendingTreacheryDiscard; assert.equal(pending?.reaction?.stage, 'offer');
|
||||
const event = pending!.batch.event; let game = source;
|
||||
for (const p of source.players) {
|
||||
const current = game.pendingTreacheryDiscard, reaction = current?.reaction;
|
||||
if (!current || current.batch.event !== event || reaction?.stage !== 'offer') break;
|
||||
if (!reaction.passed.includes(p.id))
|
||||
game = actSemutaArrivalOverlap(game, p.id, { type: 'semutaPass', event });
|
||||
}
|
||||
return game;
|
||||
}
|
||||
export function claimOverlapSemuta(source: Game, actor: string, card: string): Game {
|
||||
const event = source.pendingTreacheryDiscard!.batch.event;
|
||||
let game = actSemutaArrivalOverlap(source, actor, { type: 'semutaCommit', event });
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = actSemutaArrivalOverlap(game, actor, { type: 'semutaSelect', event, card });
|
||||
return game;
|
||||
}
|
||||
export function openOverlapAmbassadorGrant(source: Game, f: SemutaArrivalOverlapFixture): Game {
|
||||
let game = source;
|
||||
if (!game.pendingArrivalOverlap) game = actSemutaArrivalOverlap(game, f.arrivalStep.actor, f.arrivalStep.action);
|
||||
assert.ok(game.pendingArrivalOverlap);
|
||||
if (game.pendingTerrorEntry?.stage === 'offer')
|
||||
game = actSemutaArrivalOverlap(game, f.moritani, { type: 'decision', decline: true });
|
||||
const entry = game.pendingAmbassador; assert.equal(entry?.stage, 'offer');
|
||||
game = actSemutaArrivalOverlap(game, f.owner, { type: 'decision', event: entry!.event,
|
||||
trigger: true, beneficiary: f.owner, choice: 'effect' });
|
||||
if (f.copied) game = actSemutaArrivalOverlap(game, f.owner,
|
||||
{ type: 'decision', event: entry!.event, effect: f.effect });
|
||||
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 });
|
||||
if (f.terror === 'robbery') {
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'robbery');
|
||||
game = actSemutaArrivalOverlap(game, f.moritani, { type: 'decision', choice: 'card' });
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'discard');
|
||||
game = actSemutaArrivalOverlap(game, f.moritani, { type: 'decision', card: f.overflowDiscard });
|
||||
}
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'terrorDiscard');
|
||||
return game;
|
||||
}
|
||||
export function finishOverlapTerror(source: Game, f: SemutaArrivalOverlapFixture, options: { give?: boolean } = {}): Game {
|
||||
if (f.terror !== 'sabotage') return source;
|
||||
assert.equal(source.pendingTerrorEntry?.stage, 'gift');
|
||||
return actSemutaArrivalOverlap(source, f.moritani, options.give
|
||||
? { type: 'decision', card: f.gift } : { type: 'decision', decline: true });
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement, type ReactElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { SemutaReaction } from '../components/semuta-reaction';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
createSemutaArrivalOverlapFixture as fixture,
|
||||
actSemutaArrivalOverlap as act,
|
||||
player, inventory,
|
||||
openOverlapAmbassadorGrant as grant,
|
||||
openOverlapTerrorDiscard as terrorDiscard,
|
||||
finishOverlapTerror as finishTerror,
|
||||
} from './fixture-semuta-arrival-overlap';
|
||||
|
||||
type Props = Parameters<typeof SemutaReaction>[0];
|
||||
type Control = { children?: unknown; disabled?: boolean; onClick: () => void };
|
||||
function controls(node: unknown): Control[] {
|
||||
if (Array.isArray(node)) return node.flatMap(controls);
|
||||
if (!node || typeof node !== 'object' || !('props' in node)) return [];
|
||||
const props = (node as ReactElement<{ children?: unknown; disabled?: boolean; onClick?: () => void }>).props;
|
||||
return [...(props.onClick ? [{ ...props, onClick: props.onClick }] : []), ...controls(props.children)];
|
||||
}
|
||||
function label(node: unknown): string {
|
||||
if (Array.isArray(node)) return node.map(label).join('');
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
if (!node || typeof node !== 'object' || !('props' in node)) return '';
|
||||
return label((node as ReactElement<{ children?: unknown }>).props.children);
|
||||
}
|
||||
const markup = (props: Props) => renderToStaticMarkup(createElement(SemutaReaction, props));
|
||||
function click(game: Game, actor: string, text: string, expected: Action): Action {
|
||||
const sent: Action[] = [], props = { game: viewGame(game, actor), busy: false,
|
||||
act: (action: Action) => { sent.push(action); } };
|
||||
const control = controls(SemutaReaction(props)).find(button => label(button.children) === text);
|
||||
assert.ok(control, `The actual shared-entry discard offers ${text}`);
|
||||
assert.equal(control.disabled, false);
|
||||
const busy = controls(SemutaReaction({ ...props, busy: true }))
|
||||
.find(button => label(button.children) === text);
|
||||
assert.ok(busy); assert.equal(busy.disabled, true);
|
||||
busy.onClick(); assert.deepEqual(sent, [], 'Busy callbacks cannot advance a suspended native reaction');
|
||||
control.onClick(); assert.deepEqual(sent, [expected]);
|
||||
return sent[0];
|
||||
}
|
||||
function offerAction(game: Game, actor: string, commit = false): Action {
|
||||
const reaction = viewGame(game, actor).semutaReaction;
|
||||
assert.ok(reaction?.stage === 'offer');
|
||||
return click(game, actor, commit ? 'Commit Semuta Drug' : 'Continue',
|
||||
{ type: commit ? 'semutaCommit' : 'semutaPass', event: reaction.event });
|
||||
}
|
||||
function continueGroup(source: Game): Game {
|
||||
const pending = source.pendingTreacheryDiscard;
|
||||
assert.equal(pending?.reaction?.stage, 'offer');
|
||||
const event = pending!.batch.event;
|
||||
let game = source;
|
||||
for (const seat of source.players) {
|
||||
const current = game.pendingTreacheryDiscard, reaction = current?.reaction;
|
||||
if (!current || current.batch.event !== event || reaction?.stage !== 'offer') break;
|
||||
if (!reaction.passed.includes(seat.id)) game = act(game, seat.id, offerAction(game, seat.id));
|
||||
}
|
||||
return game;
|
||||
}
|
||||
function readOffer(game: Game, actor: string, native: 'ambassador' | 'terror', canCommit: boolean) {
|
||||
const before = structuredClone(game);
|
||||
assert.ok(game.pendingArrivalOverlap);
|
||||
for (let reading = 0; reading < 2; reading++) {
|
||||
const view = viewGame(game, actor), html = markup({ game: view, busy: false, act() {} });
|
||||
assert.equal(view.arrivalOverlap?.active, native);
|
||||
assert.match(html, /Continue when you are ready/);
|
||||
assert.match(html, />Continue<\/button>/);
|
||||
assert.equal(html.includes('>Commit Semuta Drug</button>'), canCommit);
|
||||
}
|
||||
assert.deepEqual(game, before, 'Reading persistent text cannot finish either originally earned opportunity');
|
||||
}
|
||||
function claim(game: Game, actor: string, wanted: string): Game {
|
||||
game = act(game, actor, offerAction(game, actor, true));
|
||||
const reaction = viewGame(game, actor).semutaReaction;
|
||||
if (reaction?.stage === 'select') {
|
||||
const before = structuredClone(game);
|
||||
const html = markup({ game: viewGame(game, actor), busy: false, act() {} });
|
||||
assert.match(html, /Choose one eligible freshly discarded/);
|
||||
assert.doesNotMatch(html, />Continue<\/button>/);
|
||||
assert.match(html, /This choice cannot be declined or canceled/);
|
||||
assert.deepEqual(game, before);
|
||||
const card = reaction.candidates.find(card => card.id === wanted); assert.ok(card);
|
||||
game = act(game, actor, click(game, actor, `Claim ${card.name} (${card.id})`,
|
||||
{ type: 'semutaSelect', event: reaction.event, card: card.id }));
|
||||
}
|
||||
assert.ok(player(game, actor).hand.some(card => card.id === wanted));
|
||||
return game;
|
||||
}
|
||||
function arrived(game: Game, entrant: string, location: string) {
|
||||
assert.equal(player(game, entrant).forces[location], 1);
|
||||
return { forces: structuredClone(player(game, entrant).forces), reserves: player(game, entrant).reserves,
|
||||
tanks: player(game, entrant).tanks, spice: player(game, entrant).spice, moved: player(game, entrant).moved };
|
||||
}
|
||||
function finished(game: Game, f: ReturnType<typeof fixture>, originalInventory: string[]) {
|
||||
assert.equal(game.pendingArrivalOverlap, null);
|
||||
assert.equal(game.pendingAmbassador, null);
|
||||
assert.equal(game.pendingTerrorEntry, null);
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.equal(game.active, f.entrant);
|
||||
assert.equal(game.decision, null);
|
||||
assert.deepEqual(inventory(game), originalInventory);
|
||||
assert.equal(SemutaReaction({ game: viewGame(game, f.claimant), busy: false, act() {} }), null);
|
||||
return act(game, f.entrant, { type: 'endMovement' });
|
||||
}
|
||||
|
||||
void test('CHOAM Select retires its shared-entry discard before the original Sabotage opportunity uses Continue', () => {
|
||||
const f = fixture({ effect: 'choam', first: 'ambassador', arrival: 'shipment', terror: 'sabotage' });
|
||||
const originalInventory = inventory(f.game);
|
||||
let game = grant(act(f.game, f.arrivalStep.actor, f.arrivalStep.action), f);
|
||||
const originalArrival = arrived(game, f.entrant, f.location);
|
||||
const spice = player(game, f.owner).spice;
|
||||
game = act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, cards: f.chosenCards });
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ambassador');
|
||||
assert.equal(player(game, f.owner).spice, spice + 6);
|
||||
const firstEvent = game.pendingTreacheryDiscard!.batch.event;
|
||||
readOffer(game, f.claimant, 'ambassador', true);
|
||||
game = claim(game, f.claimant, f.chosenCards[0]);
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'offer');
|
||||
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
|
||||
assert.equal(game.pendingAmbassador, null);
|
||||
assert.equal(player(game, f.owner).spice, spice + 6);
|
||||
game = terrorDiscard(game, f);
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'terrorDiscard');
|
||||
assert.notEqual(game.pendingTreacheryDiscard!.batch.event, firstEvent);
|
||||
readOffer(game, f.claimant, 'terror', false);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, f.claimant, { type: 'semutaCommit', event: firstEvent }));
|
||||
assert.deepEqual(game, before, 'The first discard cannot acknowledge or claim the second native event');
|
||||
game = continueGroup(game);
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'gift');
|
||||
game = finishTerror(game, f);
|
||||
assert.deepEqual(arrived(game, f.entrant, f.location), originalArrival);
|
||||
assert.equal(player(game, f.owner).spice, spice + 6);
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
|
||||
finished(game, f, originalInventory);
|
||||
});
|
||||
|
||||
void test('Sabotage Commit claims its sole card before the second-owner Ix replacement waits for Continue', () => {
|
||||
const f = fixture({ effect: 'ixians', first: 'terror', arrival: 'movement', terror: 'sabotage' });
|
||||
const originalInventory = inventory(f.game);
|
||||
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
|
||||
const originalArrival = arrived(game, f.entrant, f.location);
|
||||
game = terrorDiscard(game, f);
|
||||
const discarded = game.pendingTreacheryDiscard!.batch.entries[0].card.id;
|
||||
readOffer(game, f.claimant, 'terror', true);
|
||||
game = claim(game, f.claimant, discarded);
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'gift');
|
||||
assert.ok(!game.pendingAmbassador, 'Gift still belongs to the first original owner');
|
||||
game = finishTerror(game, f);
|
||||
assert.equal(game.pendingAmbassador?.stage, 'offer');
|
||||
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
|
||||
game = grant(game, f);
|
||||
const top = game.deck[0].id, size = game.deck.length, spice = player(game, f.owner).spice;
|
||||
game = act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, cards: f.chosenCards });
|
||||
assert.equal(game.deck.length, size);
|
||||
readOffer(game, f.claimant, 'ambassador', false);
|
||||
game = continueGroup(game);
|
||||
assert.equal(game.deck.length, size - 1);
|
||||
assert.ok(player(game, f.owner).hand.some(card => card.id === top));
|
||||
assert.equal(player(game, f.owner).spice, spice);
|
||||
assert.deepEqual(arrived(game, f.entrant, f.location), originalArrival);
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
|
||||
finished(game, f, originalInventory);
|
||||
});
|
||||
|
||||
void test('Robbery Continue preserves its single draw while a later copied CHOAM Select claims a different fresh group', () => {
|
||||
const f = fixture({ effect: 'choam', first: 'terror', arrival: 'shipment', terror: 'robbery', copied: true });
|
||||
const originalInventory = inventory(f.game);
|
||||
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
|
||||
const originalArrival = arrived(game, f.entrant, f.location);
|
||||
const size = game.deck.length;
|
||||
game = terrorDiscard(game, f);
|
||||
assert.equal(game.deck.length, size - 1);
|
||||
const firstEvent = game.pendingTreacheryDiscard!.batch.event;
|
||||
readOffer(game, f.claimant, 'terror', true);
|
||||
game = continueGroup(game);
|
||||
assert.equal(game.pendingAmbassador?.stage, 'offer');
|
||||
game = grant(game, f);
|
||||
const spice = player(game, f.owner).spice;
|
||||
game = act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, cards: f.chosenCards });
|
||||
assert.notEqual(game.pendingTreacheryDiscard!.batch.event, firstEvent);
|
||||
readOffer(game, f.claimant, 'ambassador', true);
|
||||
game = claim(game, f.claimant, f.chosenCards[1]);
|
||||
assert.equal(player(game, f.owner).spice, spice + 6);
|
||||
assert.equal(game.deck.length, size - 1, 'The first owner does not draw again after the copied grant');
|
||||
assert.deepEqual(arrived(game, f.entrant, f.location), originalArrival);
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
|
||||
finished(game, f, originalInventory);
|
||||
});
|
||||
|
||||
void test('first-owner Ix Continue draws once before the second-owner Robbery Commit closes its own discard', () => {
|
||||
const f = fixture({ effect: 'ixians', first: 'ambassador', arrival: 'movement', terror: 'robbery' });
|
||||
const originalInventory = inventory(f.game);
|
||||
let game = grant(act(f.game, f.arrivalStep.actor, f.arrivalStep.action), f);
|
||||
const originalArrival = arrived(game, f.entrant, f.location);
|
||||
const top = game.deck[0].id, size = game.deck.length;
|
||||
game = act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, cards: f.chosenCards });
|
||||
readOffer(game, f.claimant, 'ambassador', true);
|
||||
game = continueGroup(game);
|
||||
assert.equal(game.deck.length, size - 1);
|
||||
assert.ok(player(game, f.owner).hand.some(card => card.id === top));
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'offer');
|
||||
game = terrorDiscard(game, f);
|
||||
const discarded = game.pendingTreacheryDiscard!.batch.entries[0].card.id;
|
||||
assert.equal(game.deck.length, size - 2);
|
||||
readOffer(game, f.claimant, 'terror', true);
|
||||
game = claim(game, f.claimant, discarded);
|
||||
assert.equal(game.deck.length, size - 2);
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
|
||||
assert.deepEqual(arrived(game, f.entrant, f.location), originalArrival);
|
||||
finished(game, f, originalInventory);
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
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,
|
||||
} from './fixture-semuta-arrival-overlap';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function arrived(game: Game, f: SemutaArrivalOverlapFixture) {
|
||||
assert.equal(player(game, f.entrant).forces[f.location], 1);
|
||||
assert.equal(player(game, f.entrant).shipped, true);
|
||||
assert.equal(player(game, f.entrant).moved, f.arrival === 'movement' ? 1 : 0);
|
||||
assert.equal(player(game, f.entrant).spice, f.balances[f.entrant] - Number(f.arrival === 'shipment'));
|
||||
for (const p of game.players)
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, count) => sum + count, 0), 20);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
function ambassadorDiscard(source: Game, f: SemutaArrivalOverlapFixture) {
|
||||
const entry = source.pendingAmbassador; assert.equal(entry?.stage, 'cards');
|
||||
const game = act(source, f.owner, { type: 'decision', event: entry!.event, cards: f.chosenCards });
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ambassador');
|
||||
assert.equal(game.pendingTreacheryDiscard.reaction?.stage, 'offer');
|
||||
assert.equal(game.pendingAmbassador, null);
|
||||
assert.ok(game.pendingArrivalOverlap, 'The original ordered parent remains while the discard is read');
|
||||
assert.deepEqual(viewGame(game, f.claimant).semutaReaction!.candidates, []);
|
||||
arrived(game, f); return game;
|
||||
}
|
||||
function done(game: Game, f: SemutaArrivalOverlapFixture) {
|
||||
assert.ok(!game.pendingArrivalOverlap && !game.pendingAmbassador && !game.pendingTerrorEntry);
|
||||
assert.ok(!game.pendingTreacheryDiscard && !game.decision && !game.response);
|
||||
assert.equal(game.active, f.entrant); arrived(game, f);
|
||||
game = act(game, f.entrant, { type: 'endMovement' });
|
||||
assert.ok(!game.movementRemaining?.includes(f.entrant));
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
|
||||
for (const first of ['ambassador', 'terror'] as const)
|
||||
for (const arrival of ['shipment', 'movement'] as const)
|
||||
void test(`actual Basic ${arrival} snapshots both original native owners, ${first} first from real lobby circles`, () => {
|
||||
const f = fixture({ effect: 'choam', first, arrival });
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, false);
|
||||
assert.equal(f.afterSetup.status, 'playing'); assert.equal(f.game.turn, 2);
|
||||
assert.equal(f.game.players.length, 5); assert.equal(f.game.semutaPreview, true);
|
||||
assert.ok(f.actions.some(step => step.action.type === 'seatPosition'));
|
||||
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);
|
||||
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);
|
||||
assert.equal(overlap.ambassadorToken, f.token);
|
||||
assert.deepEqual(overlap.terrorTokens, [f.terrorToken]);
|
||||
assert.equal(game.decision?.player, first === 'ambassador' ? f.owner : f.moritani);
|
||||
arrived(game, f);
|
||||
reject(game, f.entrant, { type: 'endMovement' });
|
||||
game = first === 'ambassador'
|
||||
? act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, decline: true })
|
||||
: act(game, f.moritani, { type: 'decision', decline: true });
|
||||
assert.equal(game.pendingArrivalOverlap?.stage, 'second'); arrived(game, f);
|
||||
game = first === 'ambassador'
|
||||
? act(game, f.moritani, { type: 'decision', decline: true })
|
||||
: act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, decline: true });
|
||||
done(game, f);
|
||||
});
|
||||
|
||||
void test('CHOAM first recovers one private card before Sabotage, pays once and cannot spend the same Semuta twice', () => {
|
||||
const f = fixture({ effect: 'choam', first: 'ambassador', arrival: 'movement' });
|
||||
let game = grant(f.game, f);
|
||||
const beforeDiscard = structuredClone(game);
|
||||
game = ambassadorDiscard(game, f);
|
||||
const firstEvent = game.pendingTreacheryDiscard!.batch.event;
|
||||
const overlap = structuredClone(game.pendingArrivalOverlap);
|
||||
assert.equal(player(game, f.owner).spice, player(beforeDiscard, f.owner).spice + 6);
|
||||
reject(game, f.moritani, { type: 'decision', reveal: true });
|
||||
game = act(game, f.claimant, { type: 'semutaCommit', event: firstEvent });
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'select');
|
||||
assert.deepEqual(viewGame(game, f.claimant).semutaReaction!.candidates.map(card => card.id), f.chosenCards);
|
||||
assert.deepEqual(game.pendingArrivalOverlap, overlap);
|
||||
for (const p of game.players.filter(p => p.id !== f.claimant))
|
||||
assert.deepEqual(viewGame(game, p.id).semutaReaction!.candidates, []);
|
||||
reject(game, f.owner, { type: 'semutaSelect', event: firstEvent, card: f.chosenCards[0] });
|
||||
game = act(game, f.claimant, { type: 'semutaSelect', event: firstEvent, card: f.chosenCards[0] });
|
||||
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'offer');
|
||||
assert.equal(player(game, f.owner).spice, player(beforeDiscard, f.owner).spice + 6);
|
||||
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.chosenCards[0]));
|
||||
game = terrorDiscard(game, f);
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.notEqual(game.pendingTreacheryDiscard.batch.event, firstEvent);
|
||||
for (const p of game.players) assert.equal(viewGame(game, p.id).semutaReaction!.canCommit, false);
|
||||
reject(game, f.claimant, { type: 'semutaCommit', event: firstEvent });
|
||||
game = finishTerror(pass(game), f, { give: true });
|
||||
assert.ok(player(game, f.entrant).hand.some(card => card.id === f.gift));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
|
||||
assert.equal(player(game, f.owner).spice, player(beforeDiscard, f.owner).spice + 6);
|
||||
done(game, f);
|
||||
});
|
||||
|
||||
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);
|
||||
const top = game.deck[0].id, size = game.deck.length;
|
||||
game = terrorDiscard(game, f);
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal(game.pendingTreacheryDiscard.continuation.kind, 'terrorDiscard');
|
||||
assert.equal(game.pendingArrivalOverlap?.stage, 'first');
|
||||
assert.equal(game.deck.length, size - 1);
|
||||
assert.ok(player(game, f.moritani).hand.some(card => card.id === top));
|
||||
const terrorEvent = game.pendingTreacheryDiscard.batch.event;
|
||||
game = pass(game);
|
||||
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
|
||||
assert.equal(game.pendingAmbassador?.stage, 'offer');
|
||||
game = grant(game, f); const replacement = game.deck[0].id;
|
||||
game = ambassadorDiscard(game, f);
|
||||
reject(game, f.claimant, { type: 'semutaCommit', event: terrorEvent });
|
||||
assert.equal(game.deck.length, size - 1, 'Ixian replacement has not drawn during the fresh discard');
|
||||
game = claim(game, f.claimant, f.chosenCards[0]);
|
||||
assert.equal(player(game, f.claimant).hand.length, 4);
|
||||
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.chosenCards[0]));
|
||||
assert.equal(game.deck.length, size - 2);
|
||||
assert.ok(player(game, f.owner).hand.some(card => card.id === replacement));
|
||||
assert.ok(player(game, f.moritani).hand.some(card => card.id === top));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
|
||||
done(game, f);
|
||||
});
|
||||
|
||||
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);
|
||||
game = terrorDiscard(game, f);
|
||||
const terrorEvent = game.pendingTreacheryDiscard!.batch.event;
|
||||
assert.equal(game.pendingTreacheryDiscard!.batch.entries[0].card.id, f.entrantCard);
|
||||
game = claim(game, f.claimant, f.entrantCard);
|
||||
assert.equal(game.pendingArrivalOverlap?.stage, 'first');
|
||||
game = finishTerror(game, f);
|
||||
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
|
||||
game = grant(game, f);
|
||||
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'removed');
|
||||
game = ambassadorDiscard(game, f);
|
||||
assert.notEqual(game.pendingTreacheryDiscard!.batch.event, terrorEvent);
|
||||
for (const p of game.players) assert.equal(viewGame(game, p.id).semutaReaction!.canCommit, false);
|
||||
game = pass(game);
|
||||
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.entrantCard));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
|
||||
done(game, f);
|
||||
});
|
||||
|
||||
void test('Ixian first passes before Robbery overflow recovery without replaying either draw', () => {
|
||||
const f = fixture({ effect: 'ixians', first: 'ambassador', terror: 'robbery', arrival: 'movement' });
|
||||
let game = grant(f.game, f);
|
||||
const size = game.deck.length;
|
||||
const ixianDraw = game.deck[0].id;
|
||||
game = ambassadorDiscard(game, f);
|
||||
const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
game = pass(game);
|
||||
assert.equal(game.deck.length, size - 1);
|
||||
assert.ok(player(game, f.owner).hand.some(card => card.id === ixianDraw));
|
||||
const robberyDraw = game.deck[0].id;
|
||||
game = terrorDiscard(game, f);
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.notEqual(game.pendingTreacheryDiscard.batch.event, event);
|
||||
assert.equal(game.deck.length, size - 2);
|
||||
assert.ok(player(game, f.moritani).hand.some(card => card.id === robberyDraw));
|
||||
game = claim(game, f.claimant, f.overflowDiscard);
|
||||
assert.equal(game.deck.length, size - 2);
|
||||
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.overflowDiscard));
|
||||
done(game, f);
|
||||
});
|
||||
|
||||
void test('an empty copied CHOAM grant opens no empty fresh group and retains the second native Terror offer', () => {
|
||||
const f = fixture({ effect: 'choam', first: 'ambassador', copied: true });
|
||||
let game = grant(f.game, f); const spice = player(game, f.owner).spice;
|
||||
game = act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, cards: [] });
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
assert.equal(player(game, f.owner).spice, spice);
|
||||
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'offer');
|
||||
game = act(game, f.moritani, { type: 'decision', decline: true });
|
||||
done(game, f);
|
||||
});
|
||||
|
||||
void test('the Ambassador discard must keep the original shared entry sector and active owner', () => {
|
||||
const f = fixture({ effect: 'choam', first: 'ambassador', arrival: 'movement' });
|
||||
const game = ambassadorDiscard(grant(f.game, f), f);
|
||||
const action: Action = { type: 'semutaPass', event: game.pendingTreacheryDiscard!.batch.event };
|
||||
const moved = structuredClone(game), continuation = moved.pendingTreacheryDiscard!.continuation;
|
||||
assert.equal(continuation.kind, 'ambassador');
|
||||
if (continuation.kind === 'ambassador') continuation.entry.sector = 18;
|
||||
reject(moved, f.claimant, action);
|
||||
const advanced = structuredClone(game);
|
||||
advanced.pendingArrivalOverlap!.stage = 'second';
|
||||
reject(advanced, f.claimant, action);
|
||||
});
|
||||
|
||||
void test('the Terror discard must retain the original shared entry amount before Semuta resumes it', () => {
|
||||
const f = fixture({ effect: 'ixians', first: 'terror', terror: 'robbery' });
|
||||
const game = terrorDiscard(act(f.game, f.arrivalStep.actor, f.arrivalStep.action), f);
|
||||
const action: Action = { type: 'semutaPass', event: game.pendingTreacheryDiscard!.batch.event };
|
||||
const changed = structuredClone(game), continuation = changed.pendingTreacheryDiscard!.continuation;
|
||||
assert.equal(continuation.kind, 'terrorDiscard');
|
||||
if (continuation.kind === 'terrorDiscard') continuation.entry.amount++;
|
||||
reject(changed, f.claimant, action);
|
||||
});
|
||||
@@ -150,8 +150,12 @@ for (const answer of ['yes', 'no'] as const) void test(`actual bought two-group
|
||||
if (answer === 'yes') {
|
||||
game = act(game, f.target, second);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true, 'Physical commitment precedes final card disposal');
|
||||
// Native Ecaz retains its existing final-move Semuta boundary; the
|
||||
// physical card still retires once before the ordinary entry resumes.
|
||||
// Completed native Basic flights now offer their physical card before
|
||||
// the original arrival; the already fulfilled promise survives that pause.
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ornithopterDiscard');
|
||||
assert.equal(game.pendingTreacheryDiscard.reaction?.stage, 'offer');
|
||||
game = passSemuta(game);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
game = act(game, f.target, { type: 'endMovement' });
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user