Enable advanced mixed-faction paired arrivals
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 09:42:27 +11:00
1 parent 49f9ebdea0
commit eb68f28a3a
9 files changed
+312 -65

No files matched your search

+13
View File
@@ -17,6 +17,19 @@ open mode gates.
## Current checkpoint and work
**Advanced mixed-family paired arrivals — 29 October. Prototyped / Partial.**
An Advanced native-faction table with Ecaz and Moritani can now retain both
Ambassador and Terror opportunities when one movement enters their shared
stronghold. CHOAM's prevention decision resolves before the original move;
both owner decisions then follow the existing provisional storm order without
repeating the move. Focused mixed-family fixtures exercise Tleilaxu, Ixian and
Fremen movement, both owner orders, actual Robbery and Duke acquisition, and
CHOAM prevention and cancellation. Six related offline files pass 118 cases;
the existing unsupported saved-retry recovery case also passes. Type/lint and
production build pass. Advanced Fremen and Guild Ambassador child arrivals,
other module combinations, deployed acceptance and complete rules remain open.
[Scope](MORITANI_ENTRY_TIMING.md#advanced-mixed-family-native-arrivals--29-october-2026).
**Public Semuta with Homeworlds — 29 October. Prototyped / Partial.**
Fresh Basic and Advanced CHOAM-deck Richese games with Homeworlds now use the
existing bounded Semuta reaction. Supported Sapho ordering of simultaneous
+12
View File
@@ -2,6 +2,18 @@
Reviewed 2026-09-06. This supplements `MORITANI_TERROR_RULES.md`; it does not enable combined-faction play or certify an implementation order.
## Advanced mixed-family native arrivals — 29 October 2026
The existing paired-entry queue now also accepts Advanced games with Ecaz,
Moritani and other selected native faction decks when no optional modules or
separate preview overlays are active. CHOAM can first prevent a movement into
a territory containing both tokens; if the move proceeds, the original
movement commits once and both eligible owners receive their opportunities in
the provisional storm order described below. Preventing the move leaves its
forces and both tokens in place. Fremen and Guild Ambassador effects that
create another arrival remain guarded in this Advanced overlap; further
module combinations and full rules acceptance remain open.
## Basic mixed-family independent cards — 28 October 2026
The existing native arrival queue also accepts the independent Ecaz
+8
View File
@@ -1,5 +1,13 @@
# Rule decision index
## Advanced mixed-family native arrivals — 29 October 2026
Advanced native-faction tables can use the existing paired Ecaz Ambassador and
Moritani Terror entry queue after CHOAM's movement-prevention opportunity.
The owners' order remains a provisional digital interpretation, not a
publisher ruling; nested Fremen and Guild Ambassador arrivals remain guarded.
[Scope and timing](MORITANI_ENTRY_TIMING.md#advanced-mixed-family-native-arrivals--29-october-2026).
## Public Semuta with Homeworlds — 29 October 2026
Fresh Basic and Advanced Richese games with the CHOAM deck and Homeworlds use
+13
View File
@@ -24855,8 +24855,21 @@ function bareBasicNativeArrivalOverlap(g: Game): boolean {
!g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker &&
(!g.ecazTreachery || selectedIndependentCardsProfile(g));
}
function bareAdvancedNativeArrivalOverlap(g: Game): boolean {
// The native faction decks share the same entry queue in Advanced play.
// Optional modules retain their existing, separately scoped profiles.
return g.advanced && selectedPhaseModuleRoster(g) && !!byFaction(g, 'ecaz') && !!byFaction(g, 'moritani') &&
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards &&
!g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker &&
!g.ecazTreachery && !g.semutaPreview && !g.semutaAdvancedOrdinaryPreview && !g.semutaRecruitsPreview &&
!g.advancedPreview && !g.kullPreview && !g.nexusKullPreview &&
!g.guildBetrayalPreview && !g.richeseBetrayalPreview && !g.nexusIxianReplacementPreview &&
!g.nexusIxianBetrayalPreview && !g.nexusHarkonnenBetrayalPreview &&
!g.twoPlayerControllers && !g.shortAllianceVictory && !g.coalitionPreview;
}
function arrivalOverlapModeSupported(g: Game, revivalArrival?: RevivalEntryParent): boolean {
if (bareBasicNativeArrivalOverlap(g)) return true;
if (bareAdvancedNativeArrivalOverlap(g)) return true;
if (homeworldPhaseModulesProfile(g) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if (revivalArrival && g.homeworlds && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if (bareBasicSemutaProfile(g) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
+1
View File
@@ -1723,6 +1723,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Unallied Ecaz may propose an alliance to an unallied entrant. Acceptance activates alliance abilities immediately. Basic then offers an optional Duke loan; Advanced automatically adds an available Duke under supplied PDF p30, without another acknowledgement. The first version releases a surviving loan after one battle, provisionally; an unused living loan is set aside at turn end. Unavailable Duke never prevents consent.',
'When another eligible faction enters a marked stronghold, Ecaz may trigger its Ambassador for itself or its current ally, or leave it in place. Ecaz itself, its ally, advisors and the faction matching the physical Ambassador do not trigger it. Ordinary shipment and native Guild transport settle their own income, BG Intrusion and accompaniment controls before queued entry, without replaying payment or forces. Actual busy controls and unsupported paired or module combinations remain guarded.',
'In supported Basic games with Ecaz and Moritani, a single entry that earns both an Ambassador and Terror opportunity retains both in their existing provisional storm order, including selected native factions and the independent Ecaz cards. A Fremen relocation or Guild shipment from the Ambassador resolves its own child arrival before the original queue continues. If that child reveals the same Terror token, it cannot trigger again for the original entrant; if it is left hidden, the original opportunity remains. Unsupported module interactions remain guarded.',
'Supported Advanced games with both Ecaz and Moritani and other native faction decks also offer both entry reactions in provisional storm order. CHOAM may prevent an eligible move first; otherwise the move happens once before the token decisions. Fremen and Guild Ambassador effects that create another arrival remain unavailable in this Advanced overlap.',
'Accompaniment joining retained BG fighters creates a distinct eligible child entry before the original shipper. Declining the child leaves the live token for the original entry; consuming it does not create a token to offer again. Advisors remain exempt. This existing child-first, live-token composition is explicitly provisional, not a publisher priority ruling.',
'Emperor grants five bank spice. Atreides records the entrant’s current Treachery hand privately for the beneficiary; Harkonnen records one randomly selected held Traitor identity, or Face Dancer identity for a Tleilaxu entrant. These snapshots remain in the recipient’s private inspection history, identify when they were taken, and do not become a live view of later changes. Opening, enlarging or closing them needs no game acknowledgement.',
'CHOAM lets the beneficiary choose any available hand cards, including none, for three spice each. Ixians require one available card to discard before drawing its replacement. Card controls respect projected reservations. After committing an Ixian exchange, a voluntary interruption must leave a card available for its required discard; a queued Truthtrance counts as already committed. A Bene Gesserit trigger permanently removes its token, then the beneficiary chooses an available effect outside the original supply for this group; unsupported copies remain visibly blocked.',
@@ -37,7 +37,9 @@ async function fixture(t: test.TestContext) {
ornithopter: 'range3',
});
assert.equal(game.decision?.kind, 'choamMovement');
// Restore the checkpoint's legacy topology after its genuine unspent declaration.
// Restore an unsupported legacy Semuta topology after its genuine
// unspent declaration; the ordinary mixed-faction profile is now supported.
game.semutaPreview = true;
addArrivalTerror(game);
game.version = 31;
const pending = arrivalReload(game),
+38 -38
View File
@@ -28,47 +28,46 @@ function reject(g: Game, player: string, action: Action, pattern?: RegExp) {
}
const overlap = /Ambassadors combined with another arrival reaction/;
void test('the reproduced Tleilaxu arrival and native Ix/Fremen movement declarations reject known unsupported overlap before prevention windows or RNG', (t) => {
const games = ['tleilaxu', 'ixians', 'fremen'].map((faction) => {
const g = movementArrivalGame(faction as 'tleilaxu' | 'ixians' | 'fremen');
if (faction !== 'tleilaxu') nativeArrivalSource(g);
if (faction === 'fremen') addArrivalAmbassador(g, 'atreides');
holdArrivalCard(g, 'choam', 'Karama');
return g;
});
const random = t.mock.method(crypto, 'getRandomValues', () => {
throw Error('Unsupported arrival sampled RNG');
});
const uuid = t.mock.method(crypto, 'randomUUID', () => {
throw Error('Unsupported arrival allocated an event');
});
for (const g of games) {
reject(g, g.active!, arrivalMove(g), overlap);
assert.equal(g.pendingChoamMove, undefined);
assert.equal(g.pendingIxMove, undefined);
assert.equal(g.pendingFremenMove, undefined);
assert.equal(g.response, null);
assert.equal(g.decision, null);
void test('Tleilaxu and native Ix/Fremen declarations open their prevention windows before a paired arrival commits', () => {
for (const faction of ['tleilaxu', 'ixians', 'fremen'] as const) {
const before = movementArrivalGame(faction);
if (faction !== 'tleilaxu') nativeArrivalSource(before);
if (faction === 'fremen') addArrivalAmbassador(before, 'atreides');
holdArrivalCard(before, 'choam', 'Karama');
const forces = structuredClone(arrivalPlayer(before, faction).forces);
const elites = structuredClone(arrivalPlayer(before, faction).elites);
const ambassadors = structuredClone(before.ecazAmbassadors);
const terror = structuredClone(before.moritaniTerror);
const g = applyAction(before, faction, arrivalMove(before));
assert.deepEqual(arrivalPlayer(g, faction).forces, forces);
assert.deepEqual(arrivalPlayer(g, faction).elites, elites);
assert.equal(arrivalPlayer(g, faction).moved, 0);
assert.deepEqual(g.ecazAmbassadors, ambassadors);
assert.deepEqual(g.moritaniTerror, terror);
if (faction === 'tleilaxu') {
assert.equal(g.decision?.kind, 'choamMovement');
assert.ok(g.pendingChoamMove);
} else {
assert.equal(g.response?.kind, faction === 'ixians' ? 'ixMovement' : 'fremenMovement');
assert.ok(faction === 'ixians' ? g.pendingIxMove : g.pendingFremenMove);
}
}
assert.equal(random.mock.callCount(), 0);
assert.equal(uuid.mock.callCount(), 0);
});
void test('known unsupported entry rejects before putting a newly played Ornithopter into escrow or creating its event', (t) => {
const g = movementArrivalGame(),
card = holdArrivalCard(g, 'tleilaxu', 'Ornithopter');
const uuid = t.mock.method(crypto, 'randomUUID', () => {
throw Error('Movement card allocated an event');
});
reject(
g,
void test('a paired arrival can put a newly played Ornithopter into escrow before the prevention choice', () => {
const before = movementArrivalGame(),
card = holdArrivalCard(before, 'tleilaxu', 'Ornithopter');
const g = applyAction(
before,
'tleilaxu',
{ ...arrivalMove(g), movementCard: card, ornithopter: 'range3' },
overlap,
{ ...arrivalMove(before), movementCard: card, ornithopter: 'range3' },
);
assert.ok(arrivalPlayer(g, 'tleilaxu').hand.some((c) => c.id === card));
assert.equal(g.ornithopter, undefined);
assert.equal(uuid.mock.callCount(), 0);
assert.equal(g.decision?.kind, 'choamMovement');
assert.ok(g.ornithopter);
assert.equal(arrivalPlayer(g, 'tleilaxu').hand.some((c) => c.id === card), false);
assert.equal(g.discard.some((c) => c.id === card), false);
assert.deepEqual(arrivalPlayer(g, 'tleilaxu').forces, { 'arrakeen:10': 3 });
assert.equal(arrivalPlayer(g, 'tleilaxu').moved, 0);
});
for (const tokens of ['ambassador', 'terror'] as const)
@@ -132,8 +131,9 @@ void test('a saved CHOAM decline releases an uncommitted unsupported arrival wit
ornithopter: 'range3',
});
const flight = structuredClone(g.ornithopter);
// Model the persisted checkpoint: the unsupported second token was already
// present in a legacy saved CHOAM window, whose declaration spent nothing.
// Model a legacy saved CHOAM window where a still-unsupported Semuta
// overlay and second token were already present after an unspent declaration.
g.semutaPreview = true;
addArrivalTerror(g);
const before = structuredClone(g),
resources = structuredClone(arrivalResources(g));
@@ -0,0 +1,189 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import { applyAction, viewGame, type Game } from '../game/engine';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
import {
addArrivalAmbassador,
arrivalMove,
arrivalPlayer,
arrivalReload,
holdArrivalCard,
movementArrivalGame,
nativeArrivalSource,
} from './deferred-movement-arrival-fixture';
type Mover = 'tleilaxu' | 'ixians' | 'fremen';
type Reaction = 'ambassador' | 'terror';
function table(mover: Mover, first: Reaction = 'ambassador') {
const g = movementArrivalGame(mover);
if (mover !== 'tleilaxu') nativeArrivalSource(g);
// A Fremen entrant does not trigger the Fremen Ambassador.
if (mover === 'fremen') addArrivalAmbassador(g, 'atreides');
const order = g.order.filter(id => id !== 'ecaz' && id !== 'moritani');
g.order = first === 'ambassador'
? ['ecaz', 'moritani', ...order]
: ['moritani', 'ecaz', ...order];
return g;
}
function declineReaction(g: Game, reaction: Reaction): Game {
if (reaction === 'ambassador') {
assert.ok(g.pendingAmbassador);
return applyAction(arrivalReload(g), 'ecaz', {
type: 'decision', event: g.pendingAmbassador.event, decline: true,
});
}
assert.ok(g.pendingTerrorEntry);
return applyAction(arrivalReload(g), 'moritani', { type: 'decision', decline: true });
}
function assertMovedOnce(g: Game, mover: Mover) {
assert.deepEqual(arrivalPlayer(g, mover).forces, { 'carthag:11': 3 });
assert.equal(arrivalPlayer(g, mover).moved, 1);
if (mover === 'ixians')
assert.deepEqual(arrivalPlayer(g, mover).elites!.forces, { 'carthag:11': 1 });
}
for (const mover of ['tleilaxu', 'ixians', 'fremen'] as const)
for (const first of ['ambassador', 'terror'] as const) {
void test(`${mover} movement through CHOAM decline resolves ${first} first once`, () => {
let g = table(mover, first);
const source = structuredClone(arrivalPlayer(g, mover).forces);
const ambassadors = structuredClone(g.ecazAmbassadors);
const terror = structuredClone(g.moritaniTerror);
g = applyAction(g, mover, arrivalMove(g));
assert.equal(g.decision?.kind, 'choamMovement');
assert.equal(viewGame(g, 'choam').decision?.kind, 'choamMovement');
assert.deepEqual(arrivalPlayer(g, mover).forces, source);
assert.equal(arrivalPlayer(g, mover).moved, 0);
assert.deepEqual(g.ecazAmbassadors, ambassadors);
assert.deepEqual(g.moritaniTerror, terror);
g = applyAction(arrivalReload(g), 'choam', { type: 'decision', decline: true });
assert.equal(g.pendingChoamMove, null);
assert.equal(g.pendingArrivalOverlap?.first, first);
assert.equal(g.pendingArrivalOverlap?.stage, 'first');
assert.equal(viewGame(g, mover).arrivalOverlap?.first, first);
assert.equal(g.decision?.kind, first === 'ambassador' ? 'ecazAmbassador' : 'moritaniTerror');
assertMovedOnce(g, mover);
g = declineReaction(g, first);
const second = first === 'ambassador' ? 'terror' : 'ambassador';
assert.equal(g.pendingArrivalOverlap?.stage, 'second');
assert.equal(g.decision?.kind, second === 'ambassador' ? 'ecazAmbassador' : 'moritaniTerror');
assertMovedOnce(g, mover);
g = declineReaction(g, second);
assert.equal(g.pendingArrivalOverlap, null);
assert.equal(g.pendingAmbassador, null);
assert.equal(g.pendingTerrorEntry, null);
assert.equal(g.decision, null);
assert.equal(g.active, mover);
assertMovedOnce(g, mover);
assert.deepEqual(g.ecazAmbassadors, ambassadors);
assert.deepEqual(g.moritaniTerror, terror);
assert.throws(() => applyAction(g, mover, arrivalMove(g)));
});
}
void test('Baliset prevents the overlapping move without spending movement or arrival tokens', () => {
let g = table('tleilaxu');
const baliset = holdArrivalCard(g, 'choam', 'Baliset');
holdArrivalCard(g, 'tleilaxu', 'Karama');
const source = structuredClone(arrivalPlayer(g, 'tleilaxu').forces);
const ambassadors = structuredClone(g.ecazAmbassadors);
const terror = structuredClone(g.moritaniTerror);
const remaining = structuredClone(g.movementRemaining);
g = applyAction(g, 'tleilaxu', arrivalMove(g));
g = applyAction(g, 'choam', {
type: 'card', card: baliset, mode: 'choam', target: 'tleilaxu', territory: 'carthag',
});
assert.equal(g.response?.kind, 'choamWorthless');
assert.ok(viewGame(g, 'tleilaxu').responseControls?.cancelCards.length);
g = applyAction(arrivalReload(g), 'tleilaxu', { type: 'passResponse' });
assert.equal(g.response, null);
assert.equal(g.pendingChoamMove, null);
assert.equal(g.pendingArrivalOverlap ?? null, null);
assert.deepEqual(arrivalPlayer(g, 'tleilaxu').forces, source);
assert.equal(arrivalPlayer(g, 'tleilaxu').moved, 0);
assert.deepEqual(g.movementRemaining, remaining);
assert.deepEqual(g.ecazAmbassadors, ambassadors);
assert.deepEqual(g.moritaniTerror, terror);
assert.equal(g.discard.filter(card => card.id === baliset).length, 1);
});
void test('CHOAM bot profiles have a legal path through the overlapping movement offer', () => {
const initial = table('tleilaxu');
const g = applyAction(initial, 'tleilaxu', arrivalMove(initial));
for (const difficulty of DIFFICULTIES) {
const view = viewGame(g, 'choam');
view.players.find(p => p.id === 'choam')!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action, difficulty);
const next = applyAction(g, 'choam', action);
assert.equal(next.pendingArrivalOverlap?.stage, 'first');
assertMovedOnce(next, 'tleilaxu');
}
});
void test('Robbery and the Ecaz Ambassador resolve their effects after CHOAM allows movement', () => {
let g = table('tleilaxu', 'terror');
const terror = createTerrorState(() => 0.2);
const robbery = terror.tokens.find(token => token.kind === 'robbery')!;
g.moritaniTerror = placeTerror(terror, robbery.id, 'carthag', 2);
const ambassadors = createAmbassadors(() => 0.2);
const ecaz = ambassadors.tokens.find(token => token.effect === 'ecaz')!;
g.ecazAmbassadors = placeAmbassador(ambassadors, ecaz.id, {
turn: 2, availableSpice: 20,
destination: { id: 'carthag', stronghold: true, inStorm: false, allowed: true },
}).state;
const moverSpice = arrivalPlayer(g, 'tleilaxu').spice;
const moritaniSpice = arrivalPlayer(g, 'moritani').spice;
const stolen = Math.ceil(moverSpice / 2);
g = applyAction(g, 'tleilaxu', arrivalMove(g));
g = applyAction(g, 'choam', { type: 'decision', decline: true });
assert.equal(g.pendingArrivalOverlap?.first, 'terror');
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
assertMovedOnce(g, 'tleilaxu');
g = applyAction(g, 'moritani', { type: 'decision', reveal: true });
assert.equal(g.pendingTerrorEntry?.stage, 'robbery');
g = applyAction(g, 'moritani', { type: 'decision', choice: 'spice' });
assert.equal(arrivalPlayer(g, 'tleilaxu').spice, moverSpice - stolen);
assert.equal(arrivalPlayer(g, 'moritani').spice, moritaniSpice + stolen);
assert.equal(g.moritaniTerror!.tokens.find(token => token.id === robbery.id)?.status, 'removed');
assert.equal(g.pendingArrivalOverlap?.stage, 'second');
assertMovedOnce(g, 'tleilaxu');
assert.ok(g.pendingAmbassador);
g = applyAction(g, 'ecaz', {
type: 'decision', event: g.pendingAmbassador.event, trigger: true,
beneficiary: 'ecaz', choice: 'duke',
});
assert.equal(g.dukeVidal?.controller, 'ecaz');
assert.equal(g.ecazAmbassadors!.tokens.find(token => token.id === ecaz.id)?.zone, 'supply');
assert.equal(g.pendingArrivalOverlap, null);
assert.equal(arrivalPlayer(g, 'tleilaxu').spice, moverSpice - stolen);
assert.equal(arrivalPlayer(g, 'moritani').spice, moritaniSpice + stolen);
assertMovedOnce(g, 'tleilaxu');
});
void test('Karama cancels Baliset and resumes both reactions for the original move', () => {
let g = table('tleilaxu');
const baliset = holdArrivalCard(g, 'choam', 'Baliset');
const karama = holdArrivalCard(g, 'tleilaxu', 'Karama');
g = applyAction(g, 'tleilaxu', arrivalMove(g));
g = applyAction(g, 'choam', {
type: 'card', card: baliset, mode: 'choam', target: 'tleilaxu', territory: 'carthag',
});
assert.equal(g.response?.kind, 'choamWorthless');
g = applyAction(arrivalReload(g), 'tleilaxu', { type: 'card', card: karama, mode: 'cancel' });
assert.equal(g.discard.filter(card => card.id === karama).length, 1);
assert.ok(arrivalPlayer(g, 'choam').hand.some(card => card.id === baliset));
assert.equal(g.pendingArrivalOverlap?.first, 'ambassador');
assertMovedOnce(g, 'tleilaxu');
g = declineReaction(g, 'ambassador');
assert.equal(g.pendingArrivalOverlap?.stage, 'second');
g = declineReaction(g, 'terror');
assert.equal(g.pendingArrivalOverlap, null);
assertMovedOnce(g, 'tleilaxu');
});
+35 -26
View File
@@ -598,8 +598,8 @@ function balisetFlight(mode: 'range3' | 'twoGroups', conflict = false) {
});
assert.equal(g.response?.kind, 'choamWorthless');
assert.equal(g.pendingChoamWorthless?.movement, true);
// Fresh declarations now reject this topology before opening CHOAM. Model
// an already-persisted window (or changed topology) to retain cost preflight coverage.
// Model a saved prevention window whose destination gained the paired
// arrival tokens after the original uncommitted declaration.
if (conflict) addLegacyConflict();
assert.deepEqual(physical(g), inventory);
return { g, orni, baliset, printed, worthless, destination };
@@ -617,37 +617,46 @@ function allowConversion(initial: Game) {
for (const form of ['printed', 'worthless'] as const)
for (const mode of ['range3', 'twoGroups'] as const)
void test(`${form} cancellation of real Baliset rejects competing Ambassador and Terror arrival before cost (${mode})`, (t) => {
const { g, printed, worthless, orni, baliset } = balisetFlight(
void test(`${form} cancellation of real Baliset commits one ${mode} flight and serializes both arrival reactions`, () => {
const { g, printed, worthless, orni, baliset, destination } = balisetFlight(
mode,
true,
);
const before = structuredClone(g);
const inventory = physical(g);
const random = t.mock.method(crypto, 'getRandomValues', () => {
throw Error('Rejected arrival preflight drew randomness.');
const spent = form === 'printed' ? printed : worthless;
let done = applyAction(g, form === 'printed' ? 'p' : 'b', {
type: 'card',
mode: 'cancel',
card: spent,
});
const uuid = t.mock.method(crypto, 'randomUUID', () => {
throw Error('Rejected arrival preflight created an event.');
});
assert.throws(() =>
applyAction(g, form === 'printed' ? 'p' : 'b', {
type: 'card',
mode: 'cancel',
card: form === 'printed' ? printed : worthless,
}));
assert.deepEqual(g, before);
assert.deepEqual(physical(g), inventory);
assert.equal(g.pendingKarama ?? null, null);
assert.equal(g.ornithopter?.card.id, orni);
assert.equal(
g.discard.some((c) =>
[printed, worthless, baliset, orni].includes(c.id),
),
false,
);
assert.equal(random.mock.callCount(), 0);
assert.equal(uuid.mock.callCount(), 0);
if (form === 'worthless') {
assert.equal(done.response?.kind, 'worthlessKarama');
assert.equal(seat(done, 'p').forces[destination] ?? 0, 0);
done = allowConversion(reload(done));
}
assert.equal(seat(done, 'p').forces[destination], 1);
assert.equal(seat(done, 'p').forces[source], mode === 'twoGroups' ? 1 : 2);
assert.equal(seat(done, 'p').moved, mode === 'twoGroups' ? 2 : 1);
assert.equal(done.ornithopter, null);
assert.equal(done.pendingChoamMove, null);
assert.equal(done.discard.filter((c) => c.id === orni).length, 1);
assert.equal(done.discard.filter((c) => c.id === spent).length, 1);
assert.ok(seat(done, 'c').hand.some((c) => c.id === baliset));
assert.deepEqual(physical(done), inventory);
assert.equal(done.decision?.kind, 'ecazAmbassador');
done = applyAction(reload(done), 'e', {
type: 'decision',
event: done.pendingAmbassador!.event,
decline: true,
});
assert.equal(done.decision?.kind, 'moritaniTerror');
done = applyAction(reload(done), 'm', { type: 'decision', decline: true });
assert.equal(done.decision, null);
assert.equal(seat(done, 'p').forces[destination], 1);
assert.equal(seat(done, 'p').moved, mode === 'twoGroups' ? 2 : 1);
assert.deepEqual(physical(done), inventory);
});
for (const form of ['printed', 'worthless'] as const)