Support Semuta on Discovery Skills Ambassador discards
Verify and publish checkpoint container / container (push) Successful in 11m47s

This commit is contained in:
Quintine committed 2026-10-30 18:02:06 +11:00
1 parent 528b152f7d
commit 6204efb7e4
8 files changed
+367 -2

No files matched your search

+10
View File
@@ -17,6 +17,16 @@ open mode gates.
## Current checkpoint and work
**Basic Ambassador discards with Discovery and Skills — 30 October. Prototyped / Partial.**
Selected Richese/Discovery/Skills games now offer Semuta for supported CHOAM
and Ixian Ambassador discards on ordinary arrivals, paired Terror entries and
clean Fremen worm rides. Claim/pass preserves original credit, replacement and
continuations. Public-start fixtures stage conserved cards and marker placement;
40 cases across six related files pass, including a simulated older pending
receipt without a reaction. Other nested sources, Advanced Ambassador
interactions, full games and deployed acceptance remain open.
[Scope](SEMUTA_SOURCE_UPDATE_20260907.md#basic-ambassador-discards-with-discovery-and-skills--30-october-2026).
**Semuta with Discovery and Leader Skills — 30 October. Prototyped / Partial.**
Fresh selected-deck Basic and Advanced games with Richese can now use supported
Semuta reactions while both modules are active. Ten related files pass 53
+8
View File
@@ -1,5 +1,13 @@
# Rule decision index
## Basic Ambassador discards with Discovery and Skills — 30 October 2026
The existing private Semuta reaction now composes with supported CHOAM and
Ixian Ambassador discards in this Basic profile, preserving the original entry
and its credit or replacement. The combined ordering is an online
interpretation, not a new publisher ruling.
[Scope](SEMUTA_SOURCE_UPDATE_20260907.md#basic-ambassador-discards-with-discovery-and-skills--30-october-2026).
## Semuta with selected Discovery and Skills — 30 October 2026
Richese's existing Semuta protocol now joins supported fresh Discovery/Skills
+16 -1
View File
@@ -1,5 +1,20 @@
# Semuta Drug source update and implementation boundary
## Basic Ambassador discards with Discovery and Skills — 30 October 2026
**Prototyped / Partial.** In the selected Richese, Discovery and Leader Skills
profile, a Basic CHOAM or Ixian Ambassador discard can now offer its private
cards to Semuta during an ordinary shipment or movement, a supported paired
Ambassador/Terror entry, or a clean Fremen worm ride. Claim or pass retains
CHOAM's already-paid credit, delays the Ixian replacement until the reaction
finishes, and resumes the original entry once. The paired-entry cases continue
to the original Terror event. The focused public-start fixtures stage conserved
cards and Ambassador placement; the movements and worm ride are actual actions.
The combined timing composes the printed effects as an online interpretation,
not a separate publisher ruling. Advanced, other nested parents and complete
mode and deployed acceptance remain open. Already-pending discards without a
Semuta reaction are not retroactively offered one.
## Selected Discovery and Skills — 30 October 2026
**Prototyped / Partial.** Fresh Basic and Advanced selected-deck games with
@@ -13,7 +28,7 @@ arrival remains available when Richese is seated.
Public-start fixtures exercise those paths with original training and real
actions; unopened Spice order, Station/token placement and conserved card
custody are staged. Unsupported Ambassador/Terror and nested discard sources
custody are staged. Other Ambassador/Terror and nested discard sources
remain partial; this does not certify all Semuta interactions, complete games
or deployed behavior. Existing games are not converted.
+3 -1
View File
@@ -9811,7 +9811,9 @@ function semutaOfferSupported(
validAmbassadorResume(g, continuation.entry) && !g.pendingArrivalOverlap &&
(!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction);
if (ambassadorDiscard &&
(!(bareBasicSemutaProfile(g) || basicIndependentRecruitsSemutaProfile(g) && !g.pendingArrivalOverlap) ||
(!(bareBasicSemutaProfile(g) || !g.advanced && selectedDiscoverySkillsSemutaProfile(g) &&
(!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction) ||
basicIndependentRecruitsSemutaProfile(g) && !g.pendingArrivalOverlap) ||
!(ambassadorWormDiscard || g.phase === 5 && continuation.entry.resume === 'none' &&
['shipment', 'movement'].includes(continuation.entry.arrivalCause ?? '')) ||
continuation.entry.stage !== 'cards' ||
+1
View File
@@ -1360,6 +1360,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'In fresh Basic games with Richese, classic opponents, the CHOAM deck and Tech Tokens, the existing Semuta preview is also enabled. Other module combinations remain separate; existing started games are not retrofitted.',
'Fresh Basic and Advanced Richese games with the CHOAM deck and Leader Skills now use the existing bounded Semuta reaction. Optional Tech Tokens require three seats; Advanced may also select Stronghold Cards. Supported discards retain their original continuations, while other discard sources remain unfinished. Existing games are not converted.',
'Fresh Basic and Advanced Richese games with the CHOAM deck and Discoveries also use the bounded Semuta reaction. Shrine role costs and a committed Sapho Testing Station reorder retain their original effects and continue after claim or pass. Other producers and combinations remain unfinished; existing games are not converted.',
'In supported Basic games with both Discoveries and Leader Skills, CHOAM and Ixian Ambassador discards can also offer Semuta during ordinary movement or shipment, a paired Ambassador/Terror entry, and a clean Fremen worm ride. CHOAM credit stays paid; Ixian replacement and the original entry continue once after claim or pass. Other nested sources and Advanced Ambassador interactions remain unfinished.',
'Fresh Basic and Advanced Richese games with the CHOAM deck and Homeworlds also use the bounded Semuta reaction. A supported Sapho change to simultaneous Truthtrance order pauses for Semuta, then resumes the same questions. Other discard sources and module combinations remain unfinished; existing games are not converted.',
'Fresh Advanced Richese preview games with only the CHOAM expansion deck and classic or CHOAM opponents enable Semuta reactions for supported ordinary Hajr, Ghola, Harvester and Weather Control discards, completed Box and Distrans, simple definite Truthtrance answers, clean Residual Poison, completed CHOAM sales, supported printed CHOAM Worthless costs and supported battle cleanup. Other discard parents and optional-module combinations remain unfinished. Existing games are not retrofitted.',
'Mandatory and winner battle discards also pause before native Advanced Harkonnen capture. Claim or Continue completes the original casualties and card cleanup before the capture offer; it does not sample a captive early or repeat capture. Other pending rewards remain outside this bounded path.',
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { passArrivalOrder, passCurrentSemuta } from './fixture-semuta-arrival-overlap';
import { act, player, semutaDiscoverySkillsOverlapFixture } from './semuta-discovery-skills-overlap-fixture';
// Select a legal Ambassador cohort and prior card acquisitions at the clean
// pre-arrival boundary. The shipment, Ambassador grant, discard and Terror
// continuation are all original player actions, never installed as receipts.
for (const effect of ['choam', 'ixians'] as const) for (const outcome of ['claim', 'pass'] as const)
void test(`Basic Discovery and Skills: ${effect} Ambassador ${outcome} resumes original Terror`, () => {
const f = semutaDiscoverySkillsOverlapFixture(false);
let game = f.game;
const ambassadors = game.ecazAmbassadors!;
const previous = ambassadors.tokens.find(t => t.id === f.token)!;
const marker = ambassadors.tokens.find(t => t.effect === effect)!;
if (!ambassadors.cohort.includes(marker.id)) {
const replacedId = ambassadors.cohort[0];
const replaced = ambassadors.tokens.find(t => t.id === replacedId)!;
assert.equal(replaced.zone, 'supply');
replaced.zone = 'pool'; marker.zone = 'supply';
ambassadors.cohort[0] = marker.id;
}
assert.equal(previous.zone, 'placed'); assert.equal(marker.zone, 'supply');
previous.zone = 'supply'; previous.location = null;
marker.zone = 'placed'; marker.location = f.destination;
const cache = game.richeseCache!;
const semutaIndex = cache.findIndex(card => card.id === SEMUTA_DRUG_ID);
assert.ok(semutaIndex >= 0);
player(game, 'richese').hand.push(cache.splice(semutaIndex, 1)[0]);
const owner = player(game, 'ecaz');
while (owner.hand.length < 2) { const card = game.deck.shift(); assert.ok(card); owner.hand.push(card); }
const chosen = owner.hand.slice(0, effect === 'choam' ? 2 : 1).map(card => card.id);
game = passArrivalOrder(act(game, 'guild', f.shipment));
game = f.advance(game, state => !!state.pendingAmbassador || !!state.pendingTerrorEntry);
assert.equal(game.pendingArrivalOverlap?.first, 'ambassador');
assert.equal(game.pendingAmbassador?.token, marker.id);
game = act(game, 'ecaz', {type:'decision', event:game.pendingAmbassador!.event,
trigger:true, beneficiary:'ecaz', choice:'effect'});
assert.equal(game.pendingAmbassador?.stage, 'cards');
const balance = player(game, 'ecaz').spice;
const deckSize = game.deck.length, replacement = game.deck[0]?.id;
game = act(game, 'ecaz', {type:'decision', event:game.pendingAmbassador!.event, cards:chosen});
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ambassador');
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(game.pendingArrivalOverlap?.stage, 'first');
assert.deepEqual(viewGame(game, 'guild').semutaReaction!.candidates, []);
assert.deepEqual(viewGame(game, 'richese').semutaReaction!.candidates, []);
assert.equal(player(game, 'ecaz').spice, balance + (effect === 'choam' ? 6 : 0));
assert.equal(game.deck.length, deckSize, 'The Ixian replacement must wait for the discard reaction');
if (outcome === 'claim') {
const event = game.pendingTreacheryDiscard!.batch.event;
game = act(game, 'richese', {type:'semutaCommit', event});
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
assert.deepEqual(viewGame(game, 'richese').semutaReaction!.candidates.map(card => card.id), chosen);
assert.deepEqual(viewGame(game, 'guild').semutaReaction!.candidates, []);
game = act(game, 'richese', {type:'semutaSelect', event, card:chosen[0]});
}
assert.ok(player(game, 'richese').hand.some(card => card.id === chosen[0]));
} else game = passCurrentSemuta(game);
assert.equal(player(game, 'ecaz').spice, balance + (effect === 'choam' ? 6 : 0));
assert.equal(game.deck.length, deckSize - Number(effect === 'ixians'));
if (effect === 'ixians') assert.ok(player(game, 'ecaz').hand.some(card => card.id === replacement));
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
assert.equal(game.pendingTerrorEntry?.token, f.terror);
game = act(game, 'moritani', {type:'decision', reveal:true});
game = act(game, 'moritani', {type:'decision', choice:'spice'});
assert.equal(game.pendingArrivalOverlap ?? null, null);
assert.equal(game.pendingTreacheryDiscard ?? null, null);
assert.equal(player(game, 'guild').forces[`${f.destination}:${f.sector}`], 1);
assert.equal(game.moritaniTerror!.tokens.find(token => token.id === f.terror)?.status, 'removed');
});
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { territory } from '../game/board';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { selectedDiscoverySkillsStart } from './selected-discovery-skills-fixture';
import { actBasicSemutaAmbassador as act, nextBasicSemutaAmbassadorStep,
basicSemutaAmbassadorPlayer as player, basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
const owner = 'ecaz', holder = 'guild', rider = 'fremen';
type Effect = 'choam' | 'ixians';
/** The original lobby and Skills setup, real paid Ambassador placement, Spice
* Blow, Nexus and Fremen ride. Conserved staging chooses the supplied token,
* transfers Semuta and one Fremen force, and orders the unopened Spice deck. */
function source(effect: Effect) {
let g = selectedDiscoverySkillsStart({advanced:false,
factions:[owner,'richese',rider,holder], expansions:['ecaz','choam']}).game;
assert.equal(g.semutaPreview,true);
assert.ok(g.discoveries && g.leaderSkills);
const packet = g.ecazAmbassadors!;
const selected = packet.tokens.find(token => token.effect === effect)!;
if (!packet.cohort.includes(selected.id)) {
const displaced = packet.tokens.find(token => token.id === packet.cohort[0])!;
assert.equal(displaced.zone,'supply');
packet.cohort[0] = selected.id;
displaced.zone = 'pool'; selected.zone = 'supply';
}
const guild = player(g,holder), semutaIndex = g.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID);
assert.ok(semutaIndex >= 0 && guild.hand.length);
const semuta = g.richeseCache!.splice(semutaIndex,1)[0];
g.deck.push(guild.hand[0]); guild.hand[0] = semuta;
const fremen = player(g,rider), sourceForce = Object.keys(fremen.forces).find(key => fremen.forces[key] > 0)!;
assert.ok(sourceForce);
fremen.forces[sourceForce]--;
if (!fremen.forces[sourceForce]) delete fremen.forces[sourceForce];
fremen.forces['the_great_flat:15'] = (fremen.forces['the_great_flat:15'] ?? 0) + 1;
for (const [position, predicate] of [
[0, (card: Game['spiceDeck'][number]) => 'territory' in card && card.territory === 'the_great_flat'],
[1, (card: Game['spiceDeck'][number]) => 'worm' in card && !card.greatMaker],
] as const) {
const index = g.spiceDeck.findIndex((card, i) => i >= position && predicate(card));
assert.ok(index >= position);
[g.spiceDeck[position],g.spiceDeck[index]] = [g.spiceDeck[index],g.spiceDeck[position]];
}
const reach = (condition: (game: Game) => boolean) => {
for (let n = 0; n < 2000; n++) {
if (condition(g)) return;
assert.notEqual(g.status,'finished');
const next = g.decision?.kind === 'richeseCache' ? nextRicheseStrongholdsNativeStep(g) : nextBasicSemutaAmbassadorStep(g);
assert.ok(next, `Missing action at ${g.status}/${g.turn}/${g.phase}/${g.decision?.kind}`);
g = act(g,next.actor,next.action);
}
throw Error('The mixed game did not reach the worm ride');
};
reach(state => state.phase === 4 && !state.response && state.decision?.kind === 'ecazPlacement');
const destination = ['carthag','arrakeen','habbanya_ridge_sietch','tueks_sietch']
.find(id => territory(id).sectors.some(sector => sector !== g.storm))!;
const sector = territory(destination).sectors.find(value => value !== g.storm)!;
g = act(g,owner,{type:'decision',token:selected.id,territory:destination});
reach(state => !state.response && state.decision?.kind === 'ecazPlacement');
g = act(g,owner,{type:'decision',decline:true});
reach(state => state.turn === 2 && state.decision?.kind === 'wormRide' && state.decision.player === rider);
assert.equal(g.decision?.kind === 'wormRide' && g.decision.territory,'the_great_flat');
assert.notEqual(g.storm,15);
g = act(g,rider,{type:'decision',accept:true,territory:destination,sector,forces:{'the_great_flat:15':1}});
assert.equal(g.pendingAmbassador?.arrivalCause,'wormRide');
assert.equal(g.pendingAmbassador?.resume,'wormRide');
const event = g.pendingAmbassador!.event;
g = act(g,owner,{type:'decision',event,trigger:true,beneficiary:owner,choice:'effect'});
assert.equal(g.pendingAmbassador?.stage,'cards');
const cards = player(g,owner).hand.slice(0,effect === 'choam' ? 2 : 1).map(card => card.id);
assert.ok(cards.length >= 1);
return {g,event,cards,destination,sector,original:inventory(g)};
}
for (const effect of ['choam','ixians'] as const) for (const claim of [false,true])
void test(`Basic Discovery and Skills: worm ride ${effect} Semuta ${claim ? 'claim' : 'pass'}`, () => {
const f = source(effect), before = structuredClone(f.g);
const spice = player(before,owner).spice, top = before.deck[0]?.id;
let g = act(before,owner,{type:'decision',event:f.event,cards:f.cards});
const pending = g.pendingTreacheryDiscard!;
assert.equal(pending?.continuation.kind,'ambassador');
assert.equal(pending?.reaction?.stage,'offer');
assert.equal(pending.batch.cause,`ambassador:${effect}`);
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id),f.cards);
assert.ok(pending.batch.entries.every(entry => entry.discardedBy === owner && !entry.publicFace));
assert.deepEqual(g.deck,before.deck);
assert.equal(player(g,owner).spice,spice + (effect === 'choam' ? 3*f.cards.length : 0));
assert.equal(player(g,rider).forces[`${f.destination}:${f.sector}`],1);
for (const p of g.players) assert.deepEqual(viewGame(g,p.id).semutaReaction!.candidates,[]);
const event = pending.batch.event;
if (claim) {
g = act(g,holder,{type:'semutaCommit',event});
if (g.pendingTreacheryDiscard?.reaction?.stage === 'select')
g = act(g,holder,{type:'semutaSelect',event,card:f.cards[0]});
} else for (const p of g.players) g = act(g,p.id,{type:'semutaPass',event});
assert.equal(g.pendingTreacheryDiscard,null);
assert.equal(g.pendingAmbassador,null);
assert.equal(player(g,rider).forces[`${f.destination}:${f.sector}`],1);
assert.equal(player(g,owner).spice,spice + (effect === 'choam' ? 3*f.cards.length : 0));
assert.equal(player(g,holder).hand.some(card => card.id === f.cards[0]),claim);
if (effect === 'ixians') assert.ok(player(g,owner).hand.some(card => card.id === top));
assert.deepEqual(inventory(g),f.original);
assert.throws(() => act(g,holder,{type:'semutaCommit',event}));
assert.notEqual(g.decision?.kind,'ecazAmbassador');
});
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { placeAmbassador } from '../game/ecaz-ambassadors';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador';
import { semutaDiscoverySkillsOverlapFixture, act, clean, player } from './semuta-discovery-skills-overlap-fixture';
/** Stage physical cards and the original Ambassador cohort on a fresh public
* Discovery + Skills setup. No cards or tokens are created or destroyed. The
* arrival, Ambassador decision, discard and reaction are actual game actions. */
function fixture(effect: 'choam' | 'ixians') {
const source = semutaDiscoverySkillsOverlapFixture(false);
let game = structuredClone(source.beforeMarkers);
const originalCards = inventory(game);
const ambassadors = game.ecazAmbassadors!;
const token = ambassadors.tokens.find(row => row.effect === effect)!;
if (token.zone === 'pool') {
const replacement = ambassadors.tokens.find(row => row.zone === 'supply' && row.effect !== 'ecaz')!;
assert.ok(replacement);
const position = ambassadors.cohort.indexOf(replacement.id);
assert.ok(position >= 0);
ambassadors.cohort[position] = token.id;
token.zone = 'supply'; replacement.zone = 'pool';
}
assert.equal(token.zone, 'supply');
const originalTokenIds = ambassadors.tokens.map(row => row.id).sort();
// Choose Semuta from the actual physical cache, deck, discard or a hand;
// exchange it with an existing Richese card if necessary.
const holders = [game.deck, game.discard, game.richeseCache!, ...game.players.map(row => row.hand)];
const richese = player(game, 'richese');
const semutaSource = holders.find(cards => cards.some(card => card.id === SEMUTA_DRUG_ID));
assert.ok(semutaSource);
if (semutaSource !== richese.hand) {
const index = semutaSource.findIndex(card => card.id === SEMUTA_DRUG_ID);
const [semuta] = semutaSource.splice(index, 1);
if (richese.hand.length >= 4) semutaSource.push(richese.hand.pop()!);
richese.hand.push(semuta);
}
const ecaz = player(game, 'ecaz');
while (ecaz.hand.length < 2) { const card = game.deck.shift(); assert.ok(card); ecaz.hand.push(card); }
assert.deepEqual(inventory(game), originalCards);
const territory = source.destination, sector = source.sector;
assert.notEqual(game.storm, sector);
game.ecazAmbassadors = placeAmbassador(ambassadors, token.id, {
turn: game.turn, availableSpice: ecaz.spice,
destination: { id: territory, stronghold: true, inStorm: false, allowed: true },
}).state;
// A normal shipment followed by actual movement into the stronghold tests
// resumption of the original movement instead of merely an entry snapshot.
game = act(game, 'guild', {type:'ship', territory:'habbanya_ridge_flat', sector, amount:1, elite:0, allyPayment:0});
game = source.advance(game, state => state.phase === 5 && state.active === 'guild' && clean(state));
game = act(game, 'guild', {type:'move', from:`habbanya_ridge_flat:${sector}`, territory, sector, amount:1});
game = source.advance(game, state => !state.response && state.pendingAmbassador?.stage === 'offer');
const event = game.pendingAmbassador!.event;
assert.equal(game.pendingAmbassador!.arrivalCause, 'movement');
assert.equal(game.pendingAmbassador!.token, token.id);
game = act(game, 'ecaz', {type:'decision', event, trigger:true, beneficiary:'ecaz', choice:'effect'});
assert.equal(game.pendingAmbassador?.stage, 'cards');
assert.equal(game.pendingAmbassador?.effect, effect);
const chosen = player(game, 'ecaz').hand.slice(0, effect === 'choam' ? 2 : 1).map(card => card.id);
assert.equal(chosen.length, effect === 'choam' ? 2 : 1);
return {game, event, chosen, originalCards, originalTokenIds, advance:source.advance, location:`${territory}:${sector}`};
}
for (const effect of ['choam', 'ixians'] as const) void test(
`Basic Discovery + Skills native ${effect} Ambassador discard offers Semuta and resumes movement`, () => {
const f = fixture(effect), source = structuredClone(f.game);
const ecazSpice = player(source, 'ecaz').spice;
const pending = act(source, 'ecaz', {type:'decision', event:f.event, cards:f.chosen});
assert.deepEqual(source, f.game);
const receipt = pending.pendingTreacheryDiscard!;
assert.equal(receipt?.reaction?.stage, 'offer');
assert.equal(receipt.continuation.kind, 'ambassador');
assert.equal(receipt.batch.cause, `ambassador:${effect}`);
assert.deepEqual(receipt.batch.entries.map(row => row.card.id), f.chosen);
assert.ok(receipt.batch.entries.every(row => !row.publicFace && row.discardedBy === 'ecaz'));
assert.equal(player(pending, 'ecaz').spice, ecazSpice + (effect === 'choam' ? 6 : 0));
assert.deepEqual(pending.deck, source.deck);
const event = receipt.batch.event;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, 'richese');
view.players.find(row => row.id === 'richese')!.bot = difficulty;
const choice = botActions(view).find(row => row.type === 'semutaCommit' || row.type === 'semutaPass');
assert.ok(choice, difficulty);
assert.doesNotThrow(() => applyAction(pending, 'richese', choice), difficulty);
}
for (const claim of [false, true]) {
let resumed: Game = structuredClone(pending);
if (claim) {
resumed = act(resumed, 'richese', {type:'semutaCommit', event});
if (resumed.pendingTreacheryDiscard?.reaction?.stage === 'select')
resumed = act(resumed, 'richese', {type:'semutaSelect', event, card:f.chosen[0]});
} else for (const seat of resumed.players) resumed = act(resumed, seat.id, {type:'semutaPass', event});
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.equal(resumed.pendingAmbassador, null);
assert.equal(resumed.active, 'guild');
assert.equal(player(resumed, 'guild').moved, 1);
assert.equal(player(resumed, 'guild').forces[f.location], 1);
assert.equal(player(resumed, 'ecaz').spice, ecazSpice + (effect === 'choam' ? 6 : 0));
assert.equal(player(resumed, 'richese').hand.some(card => card.id === f.chosen[0]), claim);
assert.equal(resumed.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
assert.equal(resumed.discard.some(card => card.id === f.chosen[0]), !claim);
if (effect === 'ixians') {
assert.ok(player(resumed, 'ecaz').hand.some(card => card.id === source.deck[0].id));
assert.deepEqual(resumed.deck.map(card => card.id), source.deck.slice(1).map(card => card.id));
} else assert.deepEqual(resumed.deck, source.deck);
assert.deepEqual(inventory(resumed), f.originalCards);
assert.deepEqual(resumed.ecazAmbassadors!.tokens.map(row => row.id).sort(), f.originalTokenIds);
resumed = f.advance(resumed, state => clean(state));
resumed = act(resumed, 'guild', {type:'endMovement'});
assert.ok(!resumed.movementRemaining?.includes('guild'));
assert.deepEqual(inventory(resumed), f.originalCards);
}
});
for (const effect of ['choam', 'ixians'] as const) void test(
`legacy Basic Discovery + Skills ${effect} Ambassador receipt without a reaction continues automatically`, () => {
const f = fixture(effect), originalSpice = player(f.game, 'ecaz').spice;
const pending = act(f.game, 'ecaz', {type:'decision', event:f.event, cards:f.chosen});
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'ambassador');
assert.ok(pending.pendingTreacheryDiscard?.reaction);
// Model the serialized shape of an older already-committed receipt; the
// actual discard, payout, token and continuation are left untouched.
const legacy = structuredClone(pending);
delete legacy.pendingTreacheryDiscard!.reaction;
assert.equal(viewGame(legacy, 'guild').automaticContinuationPending, true);
const resumed = applyAction(legacy, 'guild', {type:'advanceBots'});
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.equal(resumed.pendingAmbassador, null);
assert.equal(resumed.active, 'guild');
assert.equal(player(resumed, 'guild').moved, 1);
assert.equal(player(resumed, 'guild').forces[f.location], 1);
assert.equal(player(resumed, 'ecaz').spice, originalSpice + (effect === 'choam' ? 6 : 0));
assert.ok(player(resumed, 'richese').hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.ok(f.chosen.every(id => resumed.discard.filter(card => card.id === id).length === 1));
if (effect === 'ixians') {
assert.ok(player(resumed, 'ecaz').hand.some(card => card.id === f.game.deck[0].id));
assert.deepEqual(resumed.deck.map(card => card.id), f.game.deck.slice(1).map(card => card.id));
} else assert.deepEqual(resumed.deck, f.game.deck);
assert.deepEqual(inventory(resumed), f.originalCards);
assert.deepEqual(resumed.ecazAmbassadors!.tokens.map(row => row.id).sort(), f.originalTokenIds);
const finished = act(f.advance(resumed, state => clean(state)), 'guild', {type:'endMovement'});
assert.ok(!finished.movementRemaining?.includes('guild'));
assert.deepEqual(inventory(finished), f.originalCards);
});