Resume native Basic arrivals after Ornithopter recovery

Allow the completed flight discard in the existing bare Basic E3 profile.
Keep physical movement and its original arrival, then handle distinct
Ambassador or Terror fresh groups without replaying effects or Semuta cost.
This commit is contained in:
Quintine committed 2026-10-24 17:06:16 +11:00
1 parent 047011e243
commit 7b9938219d
12 files changed
+975 -8

No files matched your search

@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import {
createBasicSemutaAmbassadorFixture, actBasicSemutaAmbassador,
advanceBasicSemutaAmbassador, basicSemutaAmbassadorClean,
basicSemutaAmbassadorInventory, basicSemutaAmbassadorPlayer,
type BasicSemutaAmbassadorEffect, type BasicSemutaAmbassadorStep,
} from './fixture-basic-semuta-ambassador';
export const SEMUTA_AMBASSADOR_ORNITHOPTER = 'richese-ornithopter';
export const actSemutaOrnithopterAmbassador = actBasicSemutaAmbassador;
export const semutaOrnithopterAmbassadorPlayer = basicSemutaAmbassadorPlayer;
export const semutaOrnithopterAmbassadorClean = basicSemutaAmbassadorClean;
export type SemutaOrnithopterAmbassadorStep = BasicSemutaAmbassadorStep;
/** A live played flight is physical escrow, not a retired receipt's copy. */
export function semutaOrnithopterAmbassadorInventory(game: Game): string[] {
const ids = basicSemutaAmbassadorInventory(game);
if (game.ornithopter) ids.push(game.ornithopter.card.id);
assert.equal(new Set(ids).size, ids.length, 'Every original physical card has one current location');
return ids.sort();
}
/** Acknowledge only this current fresh group; never choose an Ambassador grant. */
export function passSemutaOrnithopterAmbassador(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 reaction = game.pendingTreacheryDiscard?.reaction;
if (!reaction || reaction.stage !== 'offer') break;
if (!reaction.passed.includes(p.id))
game = actSemutaOrnithopterAmbassador(game, p.id, { type: 'semutaPass', event });
}
return game;
}
/** Sole-card recovery finishes natively; a real multi-card group needs selection. */
export function claimSemutaOrnithopterAmbassador(source: Game, actor: string, card: string): Game {
const event = source.pendingTreacheryDiscard!.batch.event;
let game = actSemutaOrnithopterAmbassador(source, actor, { type: 'semutaCommit', event });
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = actSemutaOrnithopterAmbassador(game, actor, { type: 'semutaSelect', event, card });
return game;
}
/** Fork the actual pre-shipment source. The shared constructor's subsequent
* normal-move/trigger branch is not part of this branch's action history. */
export function createSemutaOrnithopterAmbassadorFixture(options: {
effect: BasicSemutaAmbassadorEffect;
mode?: 'range3' | 'twoGroups';
copied?: boolean;
}) {
const mode = options.mode ?? 'range3', effect = options.effect, copied = options.copied ?? false;
const original = createBasicSemutaAmbassadorFixture({ effect, copied,
arrival: 'movement', cacheCard: 'ornithopter' });
const prefixEnd = original.actions.findIndex(step => step.actor === original.entrant && step.action.type === 'ship');
assert.ok(prefixEnd >= 0, 'The inherited prefix ends before its first actual Guild shipment');
const prefixActions = structuredClone(original.actions.slice(0, prefixEnd));
const branchActions: SemutaOrnithopterAmbassadorStep[] = [];
let game = structuredClone(original.beforeArrival);
const originalInventory = semutaOrnithopterAmbassadorInventory(game);
const claimant = 'richese', entrant = original.entrant;
const index = game.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID);
assert.ok(index >= 0);
basicSemutaAmbassadorPlayer(game, claimant).hand.push(game.richeseCache!.splice(index, 1)[0]);
assert.ok(game.players.every(p => p.hand.length <= 4));
const beforeShipment = structuredClone(game);
const shipment: SemutaOrnithopterAmbassadorStep = { actor: entrant,
action: { ...original.shipment.step.action, amount: mode === 'twoGroups' ? 2 : 1 } };
game = actSemutaOrnithopterAmbassador(game, shipment.actor, shipment.action, branchActions);
game = advanceBasicSemutaAmbassador(game, basicSemutaAmbassadorClean, branchActions);
const afterShipment = structuredClone(game);
assert.equal(basicSemutaAmbassadorPlayer(game, entrant).shipped, true);
let firstMove: SemutaOrnithopterAmbassadorStep | null = null;
if (mode === 'twoGroups') {
firstMove = { actor: entrant, action: { type: 'move', from: 'habbanya_ridge_flat:17',
amount: 1, territory: 'habbanya_ridge_flat', sector: 18,
movementCard: SEMUTA_AMBASSADOR_ORNITHOPTER, ornithopter: 'twoGroups' } };
game = actSemutaOrnithopterAmbassador(game, entrant, firstMove.action, branchActions);
game = advanceBasicSemutaAmbassador(game, basicSemutaAmbassadorClean, branchActions);
assert.equal(game.ornithopter?.completed, 1);
assert.equal(basicSemutaAmbassadorPlayer(game, entrant).moved, 1);
assert.equal(basicSemutaAmbassadorPlayer(game, entrant).forces['habbanya_ridge_flat:18'], 1);
}
const move: Action = { ...original.arrival.step.action,
...(mode === 'range3' ? { movementCard: SEMUTA_AMBASSADOR_ORNITHOPTER, ornithopter: 'range3' }
: { ornithopterEvent: game.ornithopter!.event }) };
assert.equal(basicSemutaAmbassadorPlayer(game, entrant).forces['habbanya_ridge_flat:17'], 1);
assert.ok(!game.pendingAmbassador);
assert.ok(basicSemutaAmbassadorClean(game));
assert.deepEqual(semutaOrnithopterAmbassadorInventory(game), originalInventory);
return { game, original, prefixActions, branchActions,
actions: [...prefixActions, ...branchActions], sourceActionCount: prefixActions.length + branchActions.length,
sourcePrefixActionCount: prefixActions.length, branchActionCount: branchActions.length,
beforeShipment, shipment, afterShipment, firstMove, move, mode, effect, copied,
owner: original.owner, beneficiary: original.beneficiary, entrant, claimant,
ornithopter: SEMUTA_AMBASSADOR_ORNITHOPTER, semuta: SEMUTA_DRUG_ID,
token: original.token, territory: original.territory, sector: original.sector,
location: original.location, originalInventory,
endMovement: { type: 'endMovement' } satisfies Action,
staging: [
'Reuse only the genuine public Basic Ecaz/CHOAM/Richese/Guild prefix before Guild shipment: original selected deal/Spice positions and scalar Ambassador packet, paid canonical Ornithopter cache purchase1, normal Ecaz purchase1 and actual placement1. The constructor’s later ordinary-movement/trigger branch is not replayed or counted in this branch.',
'At that clean boundary, move only the original cache Semuta to Richese hand as an explicitly conserved prior acquisition. No card copy, wallet, force, phase, alliance, token, pending reaction or receipt is installed.',
mode === 'twoGroups'
? 'Actually ship two Guild reserves, play the paid original Ornithopter and move one original counter from Habbanya Ridge Flat17 to its printed sector18. The first group has no new-territory arrival. Its original flight event binds the remaining final group to Habbanya Sietch17; that final move remains a caller action.'
: 'Actually ship one Guild reserve to Habbanya Ridge Flat17. The paid original range-three Ornithopter move into the real Ambassador at Habbanya Sietch17 remains a caller action.',
],
};
}
export type SemutaOrnithopterAmbassadorFixture = ReturnType<typeof createSemutaOrnithopterAmbassadorFixture>;
/** The first discarded card has already retired before this actual entry opens. */
export function openSemutaOrnithopterAmbassadorGrant(
source: Game, fixture: Pick<SemutaOrnithopterAmbassadorFixture,
'owner' | 'beneficiary' | 'effect' | 'copied' | 'entrant' | 'token'>,
actions: SemutaOrnithopterAmbassadorStep[] = [],
): Game {
let game = source;
assert.equal(game.pendingAmbassador?.stage, 'offer');
assert.equal(game.pendingAmbassador.arrivalCause, 'movement');
assert.equal(game.pendingAmbassador.entrant, fixture.entrant);
assert.equal(game.pendingAmbassador.token, fixture.token);
const event = game.pendingAmbassador.event;
game = actSemutaOrnithopterAmbassador(game, fixture.owner,
{ type: 'decision', event, trigger: true, beneficiary: fixture.beneficiary, choice: 'effect' }, actions);
if (fixture.copied) {
assert.equal(game.pendingAmbassador?.stage, 'copy');
assert.ok(game.pendingAmbassador.copyChoices.includes(fixture.effect));
game = actSemutaOrnithopterAmbassador(game, fixture.beneficiary,
{ type: 'decision', event, effect: fixture.effect }, actions);
}
assert.equal(game.pendingAmbassador?.stage, 'cards');
assert.equal(game.pendingAmbassador.effect, fixture.effect);
assert.equal(game.pendingAmbassador.arrivalCause, 'movement');
assert.equal(viewGame(game, fixture.beneficiary).decision?.kind, 'ecazAmbassador');
return game;
}
+228
View File
@@ -0,0 +1,228 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { TERRITORIES } from '../game/board';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { actTruthMovement } from './fixture-truth-movement';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type SemutaOrnithopterTerrorOptions = {
kind?: 'sabotage' | 'robbery';
mode?: 'range3' | 'twoGroups';
fullHand?: boolean;
};
export type SemutaOrnithopterTerrorStep = { actor: string; action: Action };
export const SEMUTA_TERROR_ORNITHOPTER = 'richese-ornithopter';
export const actSemutaOrnithopterTerror = actTruthMovement;
export const semutaOrnithopterTerrorPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const semutaOrnithopterTerrorClean = (game: Game) =>
!game.response && !game.phaseOpening && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard;
export function semutaOrnithopterTerrorInventory(game: Game): string[] {
const ids = [...game.deck, ...game.discard, ...(game.ornithopter ? [game.ornithopter.card] : []), ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []),
...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Each original physical card retains one location');
return ids;
}
/** Only actual native controls. Terror placement/revelation, card choices and
* the Guild's shipped movement remain explicit caller choices. */
export function nextSemutaOrnithopterTerrorStep(game: Game): SemutaOrnithopterTerrorStep | null {
if (!game.response && !game.phaseOpening && game.decision) {
const d = game.decision, event = game.richeseBidding?.event;
if (['moritaniPlacement', 'moritaniTerror'].includes(d.kind)) return null;
if (d.kind === 'moritaniSetup' && game.status === 'setup' && !game.setupStage) {
const destination = TERRITORIES.find(t => t.id === 'oh_gap'); assert.ok(destination);
return { actor: d.player, action: { type: 'decision',
territory: destination.id, sector: destination.sectors[0] } };
}
if (d.kind === 'richeseCache') {
const card = game.turn === 1
? game.richeseCache?.find(card => card.id === SEMUTA_TERROR_ORNITHOPTER)
: game.richeseCache?.find(card => card.id !== SEMUTA_DRUG_ID);
assert.ok(card);
return { actor: d.player, action: { type: 'decision', event, card: card.id,
method: 'onceAround', direction: 'clockwise' } };
}
if (d.kind === 'richeseUnbid')
return { actor: d.player, action: { type: 'decision', event, keep: false } };
}
if (game.status === 'playing' && semutaOrnithopterTerrorClean(game)) {
if (game.phase === 0 && game.stormPending === null) {
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined); assert.ok(actor);
return { actor, action: { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 } };
}
const lot = game.richeseAuction;
if (lot && !lot.outcome && lot.cardId === SEMUTA_TERROR_ORNITHOPTER &&
lot.active === 'guild' && lot.bid === 0)
return { actor: 'guild', action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } };
if (game.phase === 5 && game.active === 'guild' &&
semutaOrnithopterTerrorPlayer(game, 'guild').shipped) return null;
}
return nextRicheseStrongholdsNativeStep(game);
}
export function advanceSemutaOrnithopterTerror(game: Game, until: (state: Game) => boolean,
actions: SemutaOrnithopterTerrorStep[] = []): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Terror source ended before its owned boundary');
const next = nextSemutaOrnithopterTerrorStep(game);
assert.ok(next, 'Supply the original Terror, placement or owned movement choice explicitly');
game = actSemutaOrnithopterTerror(game, next.actor, next.action, actions);
}
throw Error('The native Ornithopter/Terror programme did not reach its finite boundary');
}
/** Continue this fresh group only, never the later native Terror decision. */
export function passSemutaOrnithopterTerror(source: Game): Game {
const pending = source.pendingTreacheryDiscard;
assert.equal(pending?.reaction?.stage, 'offer');
const event = pending!.batch.event;
let game = source;
for (const player of source.players) {
const current = game.pendingTreacheryDiscard, reaction = current?.reaction;
if (!current || current.batch.event !== event || reaction?.stage !== 'offer') break;
if (!reaction.passed.includes(player.id))
game = actSemutaOrnithopterTerror(game, player.id, { type: 'semutaPass', event });
}
return game;
}
export function claimSemutaOrnithopterTerror(source: Game, actor: string, card: string): Game {
const event = source.pendingTreacheryDiscard!.batch.event;
let game = actSemutaOrnithopterTerror(source, actor, { type: 'semutaCommit', event });
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = actSemutaOrnithopterTerror(game, actor, { type: 'semutaSelect', event, card });
return game;
}
/** Genuine Basic native setup, paid cache purchase, Mentat placement and
* funded shipment. Only unopened deck/Spice ordering/scalar lotteries and
* labelled conserved Semuta/overflow/full-hand acquisitions are controlled. */
export function createSemutaOrnithopterTerrorFixture(options: SemutaOrnithopterTerrorOptions = {}) {
const kind = options.kind ?? 'sabotage', mode = options.mode ?? 'range3';
const owner = 'moritani', claimant = 'richese', entrant = 'guild';
const territory = 'carthag', sector = 11, origin = 'imperial_basin:10';
let game = createGame('SEMUTAORNITHOPTERTERROR',
newPlayer(owner, 'Moritani', 'moritani'), false, ['ecaz', 'choam']);
joinGame(game, newPlayer(claimant, 'Richese', 'richese'));
joinGame(game, newPlayer(entrant, 'Guild', 'guild'));
const actions: SemutaOrnithopterTerrorStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaOrnithopterTerror(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => {
game = advanceSemutaOrnithopterTerror(game, until, actions);
};
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
assert.equal(game.semutaPreview, true);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills &&
!game.techTokens && !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalInventory = semutaOrnithopterTerrorInventory(game);
const originalMainDeck = game.deck.map(card => card.id).sort();
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 3);
assert.equal(ordinary.length, 3);
const deal = ordinary.map(card => card.id);
const dealOrder = deal.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
for (const [index, player] of game.players.entries())
assert.deepEqual(player.hand.map(card => card.id), [deal[index]]);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card &&
!card.discovery && !['oh_gap', 'tueks_sietch', 'imperial_basin', 'arrakeen', 'carthag'].includes(card.territory));
assert.ok(from >= position);
const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
const beforeCache = structuredClone(game), acquisitionStart = actions.length;
reach(state => semutaOrnithopterTerrorClean(state) &&
semutaOrnithopterTerrorPlayer(state, entrant).hand.some(card => card.id === SEMUTA_TERROR_ORNITHOPTER));
const acquisition = { before: beforeCache, steps: structuredClone(actions.slice(acquisitionStart)),
after: structuredClone(game) };
assert.equal(semutaOrnithopterTerrorPlayer(game, entrant).spice,
semutaOrnithopterTerrorPlayer(beforeCache, entrant).spice - 1);
reach(state => state.turn === 1 && state.phase === 8 &&
!state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement');
const beforePlacement = structuredClone(game);
const terror = game.moritaniTerror!.tokens.find(token => token.kind === kind && token.status === 'available');
assert.ok(terror);
const token = terror.id, placementStep: SemutaOrnithopterTerrorStep = {
actor: owner, action: { type: 'decision', token, territory },
};
act(placementStep.actor, placementStep.action);
reach(state => state.turn === 2 && state.phase === 5 && state.active === entrant &&
semutaOrnithopterTerrorClean(state));
const beforeShipment = structuredClone(game);
assert.equal(game.moritaniTerror!.tokens.find(row => row.id === token)!.status, 'placed');
assert.equal(game.moritaniTerror!.tokens.find(row => row.id === token)!.location, territory);
assert.equal(game.moritaniTerror!.placementTurn, 1);
const shipmentStep: SemutaOrnithopterTerrorStep = { actor: entrant, action: {
type: 'ship', territory: 'imperial_basin', sector: 10, amount: mode === 'twoGroups' ? 2 : 1,
elite: 0, allyPayment: 0,
} };
act(shipmentStep.actor, shipmentStep.action); reach(semutaOrnithopterTerrorClean);
const afterShipment = structuredClone(game);
assert.equal(semutaOrnithopterTerrorPlayer(game, entrant).spice,
semutaOrnithopterTerrorPlayer(beforeShipment, entrant).spice - Number(shipmentStep.action.amount));
const cache = game.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID); assert.ok(cache >= 0);
semutaOrnithopterTerrorPlayer(game, claimant).hand.push(game.richeseCache!.splice(cache, 1)[0]);
if (kind === 'robbery') while (semutaOrnithopterTerrorPlayer(game, owner).hand.length < 4) {
const index = game.deck.findIndex(card => ['projectile', 'poison', 'shield', 'snooper', 'worthless'].includes(card.kind));
assert.ok(index >= 0); semutaOrnithopterTerrorPlayer(game, owner).hand.push(game.deck.splice(index, 1)[0]);
}
if (options.fullHand) while (semutaOrnithopterTerrorPlayer(game, claimant).hand.length < 4) {
const index = game.deck.findIndex(card => ['projectile', 'poison', 'shield', 'snooper', 'worthless'].includes(card.kind));
assert.ok(index >= 0); semutaOrnithopterTerrorPlayer(game, claimant).hand.push(game.deck.splice(index, 1)[0]);
}
assert.ok(game.players.every(player => player.hand.length <= 4));
const gift = semutaOrnithopterTerrorPlayer(game, owner).hand[0].id;
const overflowDiscard = gift;
let firstMove: { before: Game; step: SemutaOrnithopterTerrorStep; after: Game } | null = null;
if (mode === 'twoGroups') {
const before = structuredClone(game), step: SemutaOrnithopterTerrorStep = { actor: entrant, action: {
type: 'move', movementCard: SEMUTA_TERROR_ORNITHOPTER, ornithopter: 'twoGroups',
from: origin, amount: 1, territory: 'arrakeen', sector: 10,
} };
act(step.actor, step.action); reach(semutaOrnithopterTerrorClean);
firstMove = { before, step, after: structuredClone(game) };
assert.equal(game.ornithopter!.completed, 1);
}
const move: Action = { type: 'move', from: origin, amount: 1, territory, sector,
...(mode === 'range3'
? { movementCard: SEMUTA_TERROR_ORNITHOPTER, ornithopter: 'range3' }
: { ornithopterEvent: game.ornithopter!.event }) };
const entrantCard = semutaOrnithopterTerrorPlayer(game, entrant).hand
.find(card => card.id !== SEMUTA_TERROR_ORNITHOPTER);
assert.ok(entrantCard);
assert.deepEqual(semutaOrnithopterTerrorInventory(game), originalInventory);
assert.ok(!game.pendingTerrorEntry);
return { game, move, kind, mode, fullHand: !!options.fullHand, owner, entrant, claimant,
ornithopter: SEMUTA_TERROR_ORNITHOPTER, semuta: SEMUTA_DRUG_ID, token, territory, sector,
origin, location: territory + ':' + sector, gift, overflowDiscard, entrantCard: entrantCard.id,
deckTop: structuredClone(game.deck[0]), originalInventory, originalMainDeck,
lobby, setup, beforeStartingDeal, deal, dealOrder, afterSetup, spiceOrder,
acquisition, beforePlacement, placementStep, beforeShipment, shipmentStep, afterShipment, firstMove,
actions, sourceActionCount: actions.length,
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
claimantHandSize: semutaOrnithopterTerrorPlayer(game, claimant).hand.length,
staging: [
'Original public Basic Moritani/Richese/Guild setup uses the selected E2/E3 decks and native cache. Only three unopened starting-card positions, two unopened ordinary Spice positions and scalar lotteries are selected. Native Moritani places its six starting counters in printed Oh Gap; no force, wallet, phase, token or pending receipt is installed.',
'Guild actually buys the original cache Ornithopter for one spice during turn one. The native turn-one Mentat opportunity actually places the selected hidden Terror in Carthag; actual first-turn zero dials, next-turn1+1 dials and quiet native controls reach turn two. Guild then pays for its real one/two-force Imperial Basin shipment. Two-group mode performs its real first move to empty Arrakeen before the caller-owned final movement.',
'At the clean shipped boundary, explicitly conserve the original cache Semuta into Richese hand. Robbery additionally fills Moritani to its actual four-card limit using original ordinary deck cards, and optional full-hand cases likewise fill Richese. These are labelled prior-card acquisitions, not a played auction/trade. No card identity is duplicated. The final move, Semuta choices, Terror revelation, actual Robbery draw/discard and optional Sabotage gift remain caller actions.',
],
};
}
export type SemutaOrnithopterTerrorFixture = ReturnType<typeof createSemutaOrnithopterTerrorFixture>;
+134
View File
@@ -0,0 +1,134 @@
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 {
createSemutaOrnithopterAmbassadorFixture as fixture,
actSemutaOrnithopterAmbassador as act,
semutaOrnithopterAmbassadorPlayer as player,
semutaOrnithopterAmbassadorInventory as inventory,
passSemutaOrnithopterAmbassador as pass,
claimSemutaOrnithopterAmbassador as claim,
openSemutaOrnithopterAmbassadorGrant as grant,
type SemutaOrnithopterAmbassadorFixture,
} from './fixture-semuta-ornithopter-ambassador';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before);
}
function movement(game: Game, f: SemutaOrnithopterAmbassadorFixture) {
assert.equal(player(game, f.entrant).moved, f.mode === 'twoGroups' ? 2 : 1);
assert.equal(player(game, f.entrant).ornithopterExtraMoves ?? 0, f.mode === 'twoGroups' ? 1 : 0);
assert.equal(player(game, f.entrant).forces[f.location], 1);
assert.equal(player(game, f.entrant).forces['habbanya_ridge_flat:17'] ?? 0, 0);
assert.equal(player(game, f.entrant).forces['habbanya_ridge_flat:18'] ?? 0, f.mode === 'twoGroups' ? 1 : 0);
assert.equal(game.active, f.entrant); assert.equal(game.ornithopter, null);
assert.equal(player(game, f.entrant).spice, player(f.game, f.entrant).spice);
assert.deepEqual(inventory(game), f.originalInventory);
}
for (const mode of ['range3', 'twoGroups'] as const)
for (const effect of ['choam', 'ixians'] as const)
for (const firstClaim of [false, true]) void test(
`native Basic ${mode} retires Ornithopter before ${effect} with ${firstClaim ? 'flight' : 'grant'} recovery`, () => {
const f = fixture({ mode, effect }), source = structuredClone(f.game);
const pending = act(source, f.entrant, f.move), first = pending.pendingTreacheryDiscard;
assert.equal(first?.reaction?.stage, 'offer');
assert.equal(first.continuation.kind, 'ornithopterDiscard');
assert.equal(first.batch.cause, 'ornithopter:move');
assert.deepEqual(first.batch.entries.map(entry => entry.card.id), [f.ornithopter]);
assert.ok(!pending.pendingAmbassador, 'The original arrival waits for card disposal');
movement(pending, f); assert.deepEqual(source, f.game);
let game = firstClaim ? claim(pending, f.claimant, f.ornithopter) : pass(pending);
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(game.pendingAmbassador?.stage, 'offer');
assert.equal(game.pendingAmbassador.arrivalCause, 'movement');
movement(game, f);
rejected(game, f.claimant, { type: 'semutaCommit', event: first.batch.event });
game = grant(game, f);
const event = game.pendingAmbassador!.event;
const cards = player(game, f.beneficiary).hand.slice(0, effect === 'choam' ? 2 : 1).map(card => card.id);
const beforeIncome = player(game, f.beneficiary).spice;
const beforeDeck = structuredClone(game.deck), replacement = game.deck[0]; assert.ok(replacement);
const native = act(game, f.beneficiary, { type: 'decision', event, cards });
const next = native.pendingTreacheryDiscard;
assert.equal(next?.reaction?.stage, 'offer'); assert.equal(next.continuation.kind, 'ambassador');
assert.notEqual(next.batch.event, first.batch.event);
assert.equal(next.batch.cause, `ambassador:${effect}`);
assert.deepEqual(next.batch.entries.map(entry => entry.card.id), cards);
assert.ok(next.batch.entries.every(entry => !entry.publicFace));
assert.equal(player(native, f.beneficiary).spice, beforeIncome + (effect === 'choam' ? cards.length * 3 : 0));
assert.deepEqual(native.deck, beforeDeck, 'The replacement draw waits for this second fresh group');
for (const p of native.players) assert.deepEqual(viewGame(native, p.id).semutaReaction!.candidates, []);
assert.equal(viewGame(native, f.claimant).semutaReaction!.canCommit, !firstClaim);
rejected(native, f.claimant, { type: 'semutaCommit', event: first.batch.event });
game = firstClaim ? pass(native) : claim(native, f.claimant, cards[0]);
assert.equal(game.pendingTreacheryDiscard, null); assert.equal(game.pendingAmbassador, null);
assert.equal(player(game, f.beneficiary).spice, beforeIncome + (effect === 'choam' ? cards.length * 3 : 0));
assert.equal(player(game, f.claimant).hand.some(card => card.id === f.ornithopter), firstClaim);
assert.equal(player(game, f.claimant).hand.some(card => card.id === cards[0]), !firstClaim);
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
assert.equal(game.discard.filter(card => card.id === f.ornithopter).length, Number(!firstClaim));
if (effect === 'ixians') {
assert.ok(player(game, f.beneficiary).hand.some(card => card.id === replacement.id));
assert.deepEqual(game.deck, beforeDeck.slice(1));
} else assert.deepEqual(game.deck, beforeDeck);
movement(game, f);
game = act(game, f.entrant, f.endMovement);
assert.ok(!game.movementRemaining?.includes(f.entrant));
assert.deepEqual(inventory(game), f.originalInventory);
});
for (const mode of ['range3', 'twoGroups'] as const) void test(
`native Basic ${mode} with an empty CHOAM grant creates no second discard or payout`, () => {
const f = fixture({ mode, effect: 'choam' });
let game = pass(act(f.game, f.entrant, f.move));
game = grant(game, f);
const spice = player(game, f.beneficiary).spice, deck = structuredClone(game.deck);
game = act(game, f.beneficiary, { type: 'decision', event: game.pendingAmbassador!.event, cards: [] });
assert.ok(!game.pendingTreacheryDiscard); assert.equal(game.pendingAmbassador, null);
assert.equal(player(game, f.beneficiary).spice, spice); assert.deepEqual(game.deck, deck);
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.semuta));
movement(game, f);
});
void test('an actual BG-copy Ixian Ambassador follows the retired two-group card into one original replacement draw', () => {
const f = fixture({ mode: 'twoGroups', effect: 'ixians', copied: true });
let game = pass(act(f.game, f.entrant, f.move)); game = grant(game, f);
const chosen = player(game, f.beneficiary).hand[0].id, deck = structuredClone(game.deck);
game = act(game, f.beneficiary, { type: 'decision', event: game.pendingAmbassador!.event, cards: [chosen] });
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
game = claim(game, f.claimant, chosen);
assert.ok(player(game, f.beneficiary).hand.some(card => card.id === deck[0].id));
assert.deepEqual(game.deck, deck.slice(1));
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'removed');
movement(game, f);
});
void test('all existing profiles legally answer both native fresh groups without replaying the completed flight', () => {
const f = fixture({ effect: 'choam', mode: 'twoGroups', copied: true });
for (const difficulty of DIFFICULTIES) {
let game = act(f.game, f.entrant, f.move);
const finish = (source: Game) => {
let current = source;
for (let count = 0; current.pendingTreacheryDiscard && count < 16; count++) {
const reaction = current.pendingTreacheryDiscard.reaction!;
const actor = reaction.stage === 'select' ? reaction.player
: current.players.find(p => !reaction.passed.includes(p.id))!.id;
const view = viewGame(current, actor); view.players.find(p => p.id === actor)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action);
current = act(current, actor, action);
}
assert.equal(current.pendingTreacheryDiscard, null); return current;
};
game = finish(game); game = grant(game, f);
const cards = player(game, f.beneficiary).hand.slice(0, 2).map(card => card.id);
const spice = player(game, f.beneficiary).spice;
game = act(game, f.beneficiary, { type: 'decision', event: game.pendingAmbassador!.event, cards });
game = finish(game);
assert.equal(player(game, f.beneficiary).spice, spice + 6);
assert.equal(game.pendingAmbassador, null); movement(game, f);
}
});
@@ -0,0 +1,163 @@
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 {
createSemutaOrnithopterAmbassadorFixture as fixture,
actSemutaOrnithopterAmbassador as act,
openSemutaOrnithopterAmbassadorGrant as grant,
semutaOrnithopterAmbassadorPlayer as player,
semutaOrnithopterAmbassadorInventory as inventory,
} from './fixture-semuta-ornithopter-ambassador';
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 actionFromControl(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 native fresh group 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 acknowledge, commit or select');
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 actionFromControl(game, actor, commit ? 'Commit Semuta Drug' : 'Continue',
{ type: commit ? 'semutaCommit' : 'semutaPass', event: reaction.event });
}
function continueGroup(source: Game): Game {
const pending = source.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage === 'offer');
const event = pending.batch.event;
let game = source;
for (const seat of source.players) {
const current = game.pendingTreacheryDiscard;
if (!current || current.batch.event !== event) break;
assert.equal(current.reaction?.stage, 'offer');
if (!current.reaction!.passed.includes(seat.id))
game = act(game, seat.id, offerAction(game, seat.id));
}
return game;
}
function assertMoved(game: Game, actor: string, twoGroups: boolean) {
const mover = player(game, actor);
assert.equal(mover.moved, twoGroups ? 2 : 1);
assert.equal(mover.ornithopterExtraMoves ?? 0, twoGroups ? 1 : 0);
assert.equal(mover.forces['habbanya_ridge_sietch:17'], 1);
if (twoGroups) assert.equal(mover.forces['habbanya_ridge_flat:18'], 1);
assert.equal(game.ornithopter, null);
}
void test('native two-group Continue then CHOAM Commit/Select keeps separate events and the original six-spice credit', () => {
const f = fixture({ effect: 'choam', mode: 'twoGroups' });
let game = act(f.game, f.entrant, f.move);
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ornithopterDiscard');
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const firstEvent = game.pendingTreacheryDiscard!.batch.event, moved = structuredClone(player(game, f.entrant).forces);
const original = structuredClone(game);
for (let reading = 0; reading < 2; reading++) {
const html = markup({ game: viewGame(game, f.claimant), busy: false, act() {} });
assert.match(html, /Continue when you are ready/);
assert.match(html, />Continue<\/button>/);
assert.match(html, />Commit Semuta Drug<\/button>/);
}
assert.deepEqual(game, original, 'Reading the retirement offer never advances its original arrival');
game = continueGroup(game);
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(game.pendingAmbassador?.stage, 'offer');
assertMoved(game, f.entrant, true);
game = grant(game, f);
const cards = player(game, f.beneficiary).hand.slice(0, 2).map(card => card.id);
assert.equal(cards.length, 2);
const spice = player(game, f.beneficiary).spice;
game = act(game, f.beneficiary,
{ type: 'decision', event: game.pendingAmbassador!.event, cards });
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ambassador');
const secondEvent = game.pendingTreacheryDiscard!.batch.event;
assert.notEqual(secondEvent, firstEvent);
assert.equal(player(game, f.beneficiary).spice, spice + 6);
assert.deepEqual(viewGame(game, f.claimant).semutaReaction!.candidates, []);
const beforeCommit = structuredClone(game);
assert.throws(() => act(game, f.claimant, { type: 'semutaCommit', event: firstEvent }));
assert.deepEqual(game, beforeCommit, 'The retired flight event cannot claim the later private grant');
game = act(game, f.claimant, offerAction(game, f.claimant, true));
const selection = viewGame(game, f.claimant).semutaReaction!;
assert.equal(selection.stage, 'select'); assert.equal(selection.event, secondEvent);
assert.deepEqual(selection.candidates.map(card => card.id).sort(), [...cards].sort());
const beforeReading = structuredClone(game);
for (let reading = 0; reading < 2; reading++) {
const html = markup({ game: viewGame(game, f.claimant), busy: false, act() {} });
assert.match(html, /Choose one eligible freshly discarded/);
assert.match(html, /This choice cannot be declined or canceled/);
assert.doesNotMatch(html, />Continue<\/button>/);
}
assert.deepEqual(game, beforeReading, 'Selection persists without another payout or movement');
const card = selection.candidates.find(card => card.id === cards[0]); assert.ok(card);
const selected = actionFromControl(game, f.claimant, `Claim ${card.name} (${card.id})`,
{ type: 'semutaSelect', event: secondEvent, card: card.id });
game = act(game, f.claimant, selected);
assert.equal(game.pendingTreacheryDiscard, null); assert.equal(game.pendingAmbassador, null);
assert.equal(player(game, f.beneficiary).spice, spice + 6);
assert.ok(player(game, f.claimant).hand.some(held => held.id === card.id));
assert.equal(game.discard.filter(held => held.id === f.semuta).length, 1);
assert.equal(game.discard.filter(held => held.id === f.ornithopter).length, 1);
assert.deepEqual(player(game, f.entrant).forces, moved);
assertMoved(game, f.entrant, true);
assert.deepEqual(inventory(game), f.originalInventory);
});
void test('native first-card Commit auto-claims Ornithopter before the BG-copied Ix replacement waits for actual Continues', () => {
const f = fixture({ effect: 'ixians', mode: 'range3', copied: true });
let game = act(f.game, f.entrant, f.move);
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const firstEvent = game.pendingTreacheryDiscard!.batch.event;
game = act(game, f.claimant, offerAction(game, f.claimant, true));
assert.equal(game.pendingTreacheryDiscard, null, 'The actual one-card flight group needs no selection stage');
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.ornithopter));
assert.ok(!player(game, f.claimant).hand.some(card => card.id === f.semuta));
assertMoved(game, f.entrant, false);
game = grant(game, f);
const discard = player(game, f.beneficiary).hand[0].id;
const next = structuredClone(game.deck[0]); assert.ok(next);
const deckSize = game.deck.length, spice = player(game, f.beneficiary).spice;
game = act(game, f.beneficiary,
{ type: 'decision', event: game.pendingAmbassador!.event, cards: [discard] });
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ambassador');
assert.notEqual(game.pendingTreacheryDiscard!.batch.event, firstEvent);
assert.equal(game.deck.length, deckSize); assert.equal(game.deck[0].id, next.id);
const before = structuredClone(game);
for (const seat of game.players) {
const view = viewGame(game, seat.id);
assert.equal(view.semutaReaction!.canCommit, false, 'The single Semuta was actually spent on the first card');
const html = markup({ game: view, busy: false, act() {} });
assert.match(html, /Continue when you are ready/);
assert.doesNotMatch(html, />Commit Semuta Drug<\/button>/);
}
assert.deepEqual(game, before, 'Reading the second group cannot draw its pending replacement');
game = continueGroup(game);
assert.equal(game.pendingTreacheryDiscard, null); assert.equal(game.pendingAmbassador, null);
assert.equal(game.deck.length, deckSize - 1);
assert.ok(player(game, f.beneficiary).hand.some(card => card.id === next.id));
assert.equal(player(game, f.beneficiary).spice, spice);
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.ornithopter));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
assertMoved(game, f.entrant, false);
assert.deepEqual(inventory(game), f.originalInventory);
});
+151
View File
@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import {
createSemutaOrnithopterTerrorFixture as fixture, actSemutaOrnithopterTerror as act,
semutaOrnithopterTerrorPlayer as player, semutaOrnithopterTerrorInventory as inventory,
passSemutaOrnithopterTerror as pass, claimSemutaOrnithopterTerror as claim,
type SemutaOrnithopterTerrorFixture,
} from './fixture-semuta-ornithopter-terror';
const balances = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.spice]));
const moves = (game: Game) => game.log.filter(row => row.text.startsWith('Guild moved ')).length;
function physical(game: Game, f: SemutaOrnithopterTerrorFixture) {
assert.equal(player(game, f.entrant).forces[f.location], 1);
assert.equal(player(game, f.entrant).forces[f.origin] ?? 0, 0);
assert.equal(player(game, f.entrant).forces['arrakeen:10'] ?? 0, f.mode === 'twoGroups' ? 1 : 0);
assert.equal(player(game, f.entrant).moved, f.mode === 'twoGroups' ? 2 : 1);
assert.equal(player(game, f.entrant).ornithopterExtraMoves ?? 0, f.mode === 'twoGroups' ? 1 : 0);
assert.equal(game.ornithopter, null);
for (const p of game.players)
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, amount) => sum + amount, 0), 20);
assert.deepEqual(inventory(game), f.originalInventory);
assert.deepEqual(balances(game), f.balances);
}
function start(f: SemutaOrnithopterTerrorFixture) {
const game = act(f.game, f.entrant, f.move), pending = game.pendingTreacheryDiscard;
assert.equal(pending?.continuation.kind, 'ornithopterDiscard');
assert.equal(pending.reaction?.stage, 'offer');
assert.ok(!game.pendingTerrorEntry, 'Original Terror arrival waits for the played-card discard');
assert.equal(game.moritaniTerror!.tokens.find(token => token.id === f.token)!.status, 'placed');
assert.equal(moves(game), moves(f.game), 'The committed movement log belongs to the original later arrival suffix');
physical(game, f); return game;
}
function nativeDiscard(source: Game, f: SemutaOrnithopterTerrorFixture) {
assert.equal(source.pendingTreacheryDiscard, null);
assert.equal(source.pendingTerrorEntry?.stage, 'offer');
assert.equal(source.pendingTerrorEntry.cause, 'movement');
assert.equal(source.pendingTerrorEntry.token, f.token);
assert.equal(source.decision?.player, f.owner);
assert.equal(moves(source), moves(f.game) + 1);
let game = act(source, f.owner, { type: 'decision', reveal: true });
if (f.kind === 'robbery') {
assert.equal(game.pendingTerrorEntry?.stage, 'robbery');
const size = game.deck.length;
assert.equal(size, f.game.deck.length);
game = act(game, f.owner, { type: 'decision', choice: 'card' });
assert.equal(game.pendingTerrorEntry?.stage, 'discard');
assert.equal(player(game, f.owner).hand.length, 5);
assert.ok(player(game, f.owner).hand.some(card => card.id === f.deckTop.id));
assert.equal(game.deck.length, size - 1, 'Robbery performs its original top draw exactly once');
game = act(game, f.owner, { type: 'decision', card: f.overflowDiscard });
}
const pending = game.pendingTreacheryDiscard;
assert.equal(pending?.continuation.kind, 'terrorDiscard');
assert.equal(pending.reaction?.stage, 'offer');
assert.equal(pending.batch.entries.length, 1);
assert.equal(pending.batch.entries[0].publicFace, false);
assert.equal(pending.batch.entries[0].card.id, f.kind === 'sabotage' ? f.entrantCard : f.overflowDiscard);
assert.equal(game.moritaniTerror!.tokens.find(token => token.id === f.token)!.status, 'removed');
assert.equal(game.moritaniTerror!.tokens.filter(token => token.status === 'removed').length, 1);
physical(game, f); return game;
}
function finish(source: Game, f: SemutaOrnithopterTerrorFixture, give = false) {
let game = source;
if (f.kind === 'sabotage') {
assert.equal(game.pendingTerrorEntry?.stage, 'gift');
assert.equal(game.decision?.player, f.owner);
game = act(game, f.owner, give
? { type: 'decision', card: f.gift }
: { type: 'decision', decline: true });
if (give) assert.ok(player(game, f.entrant).hand.some(card => card.id === f.gift));
}
assert.ok(!game.pendingTerrorEntry);
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(game.decision, null); assert.equal(game.response, null);
assert.equal(game.active, f.entrant);
assert.equal(moves(game), moves(f.game) + 1);
if (f.kind === 'robbery') {
assert.equal(game.deck.length, f.game.deck.length - 1);
assert.equal(player(game, f.owner).hand.length, 4);
}
physical(game, f);
const before = structuredClone(game);
assert.throws(() => act(game, f.owner, { type: 'decision', reveal: true }));
assert.deepEqual(game, before, 'A completed native Terror effect cannot be revealed or applied again');
return act(game, f.entrant, { type: 'endMovement' });
}
for (const kind of ['sabotage', 'robbery'] as const)
for (const mode of ['range3', 'twoGroups'] as const)
void test('native Basic ' + kind + ' follows ' + mode + ' Ornithopter discard before its own private Semuta claim', () => {
const f = fixture({ kind, mode });
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, false);
assert.equal(f.afterSetup.status, 'playing');
let game = start(f);
const firstEvent = game.pendingTreacheryDiscard!.batch.event;
game = pass(game);
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.semuta));
assert.ok(!player(game, f.claimant).hand.some(card => card.id === f.ornithopter));
game = nativeDiscard(game, f);
assert.notEqual(game.pendingTreacheryDiscard!.batch.event, firstEvent);
assert.deepEqual(viewGame(game, f.claimant).semutaReaction!.candidates, [],
'The private native discard is not inspected before committing Semuta');
const wanted = kind === 'sabotage' ? f.entrantCard : f.overflowDiscard;
game = claim(game, f.claimant, wanted);
assert.ok(player(game, f.claimant).hand.some(card => card.id === wanted));
assert.equal(player(game, f.claimant).hand.length, f.claimantHandSize);
assert.equal(game.discard.filter(card => card.id === f.ornithopter).length, 1);
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
game = finish(game, f, kind === 'sabotage');
assert.deepEqual(inventory(game), f.originalInventory);
});
for (const kind of ['sabotage', 'robbery'] as const)
void test('claiming a full-hand ' + kind + ' entrant flight preserves native arrival without a second Semuta spend', () => {
const f = fixture({ kind, mode: 'twoGroups', fullHand: true });
let game = start(f);
game = claim(game, f.claimant, f.ornithopter);
assert.equal(player(game, f.claimant).hand.length, 4);
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.ornithopter));
game = nativeDiscard(game, f);
for (const p of game.players) assert.equal(viewGame(game, p.id).semutaReaction!.canCommit, false);
game = pass(game);
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.ornithopter));
game = finish(game, f);
assert.deepEqual(inventory(game), f.originalInventory);
});
void test('the retired Ornithopter fresh event cannot claim the following private Terror group', () => {
const f = fixture(), first = start(f), stale = first.pendingTreacheryDiscard!.batch.event;
const game = nativeDiscard(pass(first), f), before = structuredClone(game);
assert.notEqual(game.pendingTreacheryDiscard!.batch.event, stale);
assert.throws(() => act(game, f.claimant, { type: 'semutaCommit', event: stale }));
assert.deepEqual(game, before);
const selected = claim(game, f.claimant, f.entrantCard);
assert.ok(player(selected, f.claimant).hand.some(card => card.id === f.entrantCard));
});
void test('full-hand recovery of the Robbery overflow keeps the already completed top draw and movement', () => {
const f = fixture({ kind: 'robbery', fullHand: true });
let game = nativeDiscard(pass(start(f)), f);
const topAfterDraw = game.deck[0].id;
assert.equal(player(game, f.owner).hand.length, 4);
game = claim(game, f.claimant, f.overflowDiscard);
assert.equal(player(game, f.claimant).hand.length, 4);
assert.equal(game.deck[0].id, topAfterDraw);
assert.ok(player(game, f.owner).hand.some(card => card.id === f.deckTop.id));
game = finish(game, f);
assert.deepEqual(inventory(game), f.originalInventory);
});