Support Semuta on Discovery Skills Ambassador discards
Verify and publish checkpoint container / container (push) Successful in 11m47s
Verify and publish checkpoint container / container (push) Successful in 11m47s
This commit is contained in:
1 parent
528b152f7d
commit
6204efb7e4
8 files changed
+367
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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' ||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user