Connect fresh Basic Semuta and Ambassador discards

Initialize the existing neutral Semuta protocol during genuine fresh public bare Basic Richese setup with selected native families. Retain original roster/deck/freshness checks, separate optional/Advanced profiles and unchanged existing saves.

Retain the original Ambassador arrival cause and admit clean ordinary shipment/movement CHOAM and Ixian private discard batches, including native BG copies. Keep CHOAM credit committed once; pause before exactly one Ix replacement. Existing owner-only commitment/selection, physical Semuta/full-hand exchange and original entrant continuation remain.

Verify original public setup, actual one-spice Semuta auction, ordinary purchase, paid placement, paid shipment and actual movement. Exercise claim/pass controls, busy protection and all four existing legal profiles without strategic tuning. Repair nearby stale tests to acknowledge actual battle outcomes and retain the existing independent-card/Discovery combination. Full modes, wider nested/optional interactions, comprehensive assurance and deployed acceptance remain open. No saved-room/reset/restart/deployment operation.
This commit is contained in:
Quintine committed 2026-10-24 14:08:45 +11:00
1 parent 1f3e71fe03
commit b4a556d5d3
12 files changed
+673 -34

No files matched your search

+12 -4
View File
@@ -6,6 +6,7 @@ import {
} from '../game/engine';
import { FACTIONS, type FactionId } from '../game/catalog';
import { baseDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
import { createLeaderSkills } from '../game/leader-skills';
@@ -54,7 +55,7 @@ function completedSetup(initial: Game, difficulty: Difficulty = 'Easy') {
assert.equal(game.setupStage, undefined);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)].map(card => card.id).sort();
assert.deepEqual(cards, baseDeck().map(card => card.id).sort());
assert.deepEqual(cards, [...baseDeck(), ...(initial.ecazTreachery ? ecazTreacheryCards() : [])].map(card => card.id).sort());
const traitors = [...game.traitorReserve!, ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices])];
assert.equal(new Set(traitors).size, traitors.length);
assert.deepEqual(traitors.sort(), game.players.flatMap(p => p.leaders.map(leader => leader.id)).sort());
@@ -121,13 +122,20 @@ void test('only the host can select a boolean rules mode, changes clear human re
completedSetup(act(ready(game), game.host, { type: 'start' }));
});
void test('unsupported independent cards and Discovery reject the whole start without silently dropping a module', () => {
void test('supported independent cards and Discovery preserve both selected modules through public Basic setup', () => {
let game = act(lobby(['atreides', 'emperor']), 'atreides',
{ type: 'moduleOption', module: 'ecazTreachery', enabled: true });
game = act(game, game.host, { type: 'moduleOption', module: 'discoveries', enabled: true });
game = ready(game);
reject(game, game.host, { type: 'start' });
assert.ok(game.players.every(p => !p.hand.length && !p.traitors.length));
const before = structuredClone(game), started = act(game, game.host, { type: 'start' });
assert.deepEqual(game, before); assert.equal(started.status, 'setup');
assert.equal(started.advanced, false); assert.equal(started.ecazTreachery, true);
assert.equal(started.discoveryEnabled, true); assert.ok(started.discoveries);
assert.ok(started.spiceDeck.some(card => 'discovery' in card && card.discovery));
const cards = [...started.deck, ...started.players.flatMap(p => p.hand)];
for (const card of ecazTreacheryCards()) assert.ok(cards.some(held => held.id === card.id));
assert.equal(started.semutaPreview, undefined);
completedSetup(started);
});
void test('Advanced starts require the exact host opt-in and cannot dispatch audit initializers or hidden bypass options', () => {
+190
View File
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { faction, type FactionId } from '../game/catalog';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createBasicSemutaAmbassadorFixture, actBasicSemutaAmbassador as act,
advanceBasicSemutaAmbassador as advance, basicSemutaAmbassadorPlayer as player,
basicSemutaAmbassadorInventory as inventory, basicSemutaAmbassadorBalances as balances } from './fixture-basic-semuta-ambassador';
function lobby(roster: FactionId[], advanced = false) {
const expansions = [...new Set(roster.map(id => faction(id).expansion).filter(id => id !== 'base'))];
let game = createGame('BASICSEMUTAENTRY', newPlayer(roster[0], roster[0], roster[0]), advanced, expansions);
for (const id of roster.slice(1)) joinGame(game, newPlayer(id, id, id));
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
return game;
}
void test('fresh public bare Basic Richese starts initialize Semuta before the original setup', () => {
for (const roster of [['richese', 'guild'], ['ecaz', 'richese', 'choam', 'guild'],
['ecaz', 'ixians', 'choam', 'richese', 'moritani', 'tleilaxu']] as FactionId[][]) {
const source = lobby(roster), before = structuredClone(source);
const started = applyAction(source, source.host, { type: 'start' });
assert.equal(started.semutaPreview, true); assert.equal(started.status, 'setup');
assert.equal(started.advanced, false); assert.equal(started.richeseCache?.length, 10);
assert.ok(started.players.every(p => p.hand.length === 0));
assert.equal(viewGame(started, source.host).semutaPreview, true);
assert.deepEqual(source, before);
}
});
void test('automatic Semuta initialization does not spread to Advanced, optional-module or non-Richese starts', () => {
const advanced = lobby(['richese', 'guild'], true);
const optional = lobby(['richese', 'guild']); optional.lobbyOptions = { leaderSkills: true };
for (const source of [advanced, optional, lobby(['ecaz', 'choam', 'guild'])]) {
const started = applyAction(source, source.host, source.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
assert.equal(started.semutaPreview, undefined);
}
const dirty = lobby(['richese', 'guild']); dirty.semutaPreview = true;
assert.throws(() => applyAction(dirty, dirty.host, { type: 'start' }));
});
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function pass(game: Game) {
const event = game.pendingTreacheryDiscard!.batch.event;
for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event });
return game;
}
function claim(game: Game, actor: string, target: string) {
const event = game.pendingTreacheryDiscard!.batch.event;
game = act(game, actor, { type: 'semutaCommit', event });
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = act(game, actor, { type: 'semutaSelect', event, card: target });
return game;
}
for (const effect of ['choam', 'ixians'] as const) for (const copied of [false, true]) {
for (const claims of [false, true]) void test(
`public Basic ${copied ? 'BG-copy' : 'native'} ${effect} discards ${claims ? 'claim' : 'pass'} before exactly one original continuation`, () => {
const f = createBasicSemutaAmbassadorFixture({ effect, copied });
assert.equal(f.game.pendingAmbassador!.arrivalCause, 'shipment');
const source = structuredClone(f.game), sourceBalances = balances(source);
const pending = act(source, f.discardStep.actor, f.discardStep.action);
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(e => e.card.id), f.chosenCards);
assert.ok(receipt.batch.entries.every(e => !e.publicFace && e.discardedBy === f.beneficiary));
assert.equal(pending.pendingAmbassador, null); assert.equal(pending.active, f.entrant);
const payout = effect === 'choam' ? 6 : 0;
assert.equal(player(pending).spice, sourceBalances[f.beneficiary] + payout);
assert.deepEqual(pending.deck, source.deck, 'Ix replacement waits for this reaction');
for (const p of pending.players) {
const reaction = viewGame(pending, p.id).semutaReaction!;
assert.deepEqual(reaction.candidates, []); assert.equal(reaction.canCommit, p.id === f.holder);
}
rejected(pending, f.holder, { type: 'semutaSelect', event: receipt.batch.event, card: f.chosenCards[0] });
rejected(pending, f.entrant, f.entrantContinuation.action);
rejected(pending, f.beneficiary, f.discardStep.action);
let resumed: Game;
if (claims && effect === 'choam') {
const committed = act(pending, f.holder, { type: 'semutaCommit', event: receipt.batch.event });
assert.equal(committed.pendingTreacheryDiscard?.reaction?.stage, 'select');
assert.deepEqual(viewGame(committed, f.holder).semutaReaction!.candidates.map(c => c.id), f.chosenCards);
for (const p of committed.players.filter(p => p.id !== f.holder))
assert.deepEqual(viewGame(committed, p.id).semutaReaction!.candidates, []);
rejected(committed, f.beneficiary, { type: 'semutaSelect', event: receipt.batch.event, card: f.chosenCards[0] });
rejected(committed, f.holder, { type: 'semutaPass', event: receipt.batch.event });
resumed = act(committed, f.holder, { type: 'semutaSelect', event: receipt.batch.event, card: f.chosenCards[0] });
} else resumed = claims ? claim(pending, f.holder, f.chosenCards[0]) : pass(pending);
assert.equal(resumed.pendingTreacheryDiscard, null); assert.equal(resumed.pendingAmbassador, null);
assert.equal(resumed.active, f.entrant); assert.equal(resumed.phase, 5);
assert.equal(player(resumed).spice, sourceBalances[f.beneficiary] + payout);
assert.equal(player(resumed, f.entrant).spice, sourceBalances[f.entrant]);
assert.equal(player(resumed, f.entrant).forces[f.location], 1);
assert.equal(player(resumed, f.holder).hand.some(c => c.id === f.chosenCards[0]), claims);
assert.equal(resumed.discard.filter(c => c.id === f.semuta).length, Number(claims));
assert.equal(resumed.discard.some(c => c.id === f.chosenCards[0]), !claims);
if (effect === 'ixians') {
assert.ok(player(resumed).hand.some(c => c.id === f.preReplacementDeckTop.id));
assert.deepEqual(resumed.deck.map(c => c.id), source.deck.slice(1).map(c => c.id));
} else assert.deepEqual(resumed.deck, source.deck);
assert.deepEqual(inventory(resumed), f.originalTreachery);
rejected(resumed, f.holder, { type: 'semutaCommit', event: receipt.batch.event });
resumed = advance(resumed, state => !state.response && !state.phaseOpening && !state.decision);
const finished = act(resumed, f.entrant, f.entrantContinuation.action);
assert.ok(!finished.movementRemaining?.includes(f.entrant));
assert.deepEqual(inventory(finished), f.originalTreachery);
});
}
void test('CHOAM may discard nothing without opening an empty Semuta event or crediting spice', () => {
const f = createBasicSemutaAmbassadorFixture({ effect: 'choam' });
const game = act(f.game, f.beneficiary, { type: 'decision', event: f.event, cards: [] });
assert.equal(game.pendingTreacheryDiscard, undefined); assert.equal(game.pendingAmbassador, null);
assert.deepEqual(balances(game), f.balances); assert.deepEqual(inventory(game), f.originalTreachery);
});
for (const effect of ['choam', 'ixians'] as const) void test(`actual ordinary movement entry resumes after ${effect} Semuta recovery`, () => {
const f = createBasicSemutaAmbassadorFixture({ effect, arrival: 'movement' });
assert.equal(f.game.pendingAmbassador!.arrivalCause, 'movement');
assert.equal(player(f.game, f.entrant).moved, 1);
const pending = act(f.game, f.discardStep.actor, f.discardStep.action);
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
let game = claim(pending, f.holder, f.chosenCards[0]);
assert.equal(game.pendingAmbassador, null); assert.equal(game.active, f.entrant);
assert.equal(player(game, f.entrant).moved, 1);
assert.equal(player(game, f.entrant).forces[f.location], 1);
game = advance(game, state => !state.response && !state.phaseOpening && !state.decision);
game = act(game, f.entrant, f.entrantContinuation.action);
assert.ok(!game.movementRemaining?.includes(f.entrant));
assert.deepEqual(inventory(game), f.originalTreachery);
});
void test('older entries without an arrival witness and nested transports retain the original non-Semuta path', () => {
const f = createBasicSemutaAmbassadorFixture({ effect: 'ixians' });
for (const cause of [undefined, 'guildTransport', 'advisor', 'wormRide'] as const) {
const source = structuredClone(f.game); source.pendingAmbassador!.arrivalCause = cause;
const game = act(source, f.discardStep.actor, f.discardStep.action);
assert.equal(game.pendingTreacheryDiscard, null); assert.equal(game.pendingAmbassador, null);
assert.ok(player(game).hand.some(c => c.id === f.preReplacementDeckTop.id));
assert.deepEqual(balances(game), f.balances); assert.deepEqual(inventory(game), f.originalTreachery);
}
const old = structuredClone(f.game); delete old.semutaPreview;
const resumed = act(old, f.discardStep.actor, f.discardStep.action);
assert.equal(resumed.semutaPreview, undefined); assert.equal(resumed.pendingTreacheryDiscard, null);
});
void test('a controlled full hand exchanges Semuta atomically without consuming the beneficiary replacement slot', () => {
const f = createBasicSemutaAmbassadorFixture({ effect: 'ixians', copied: true });
const game = structuredClone(f.game), owner = player(game, f.holder);
while (owner.hand.length < 4) owner.hand.push(game.deck.pop()!);
const pending = act(game, f.discardStep.actor, f.discardStep.action);
assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true);
const result = claim(pending, f.holder, f.chosenCards[0]);
assert.equal(player(result, f.holder).hand.length, 4);
assert.ok(player(result).hand.some(c => c.id === f.preReplacementDeckTop.id));
assert.deepEqual(inventory(result), f.originalTreachery);
});
void test('a controlled beneficiary holding Semuta cannot retrieve its own fresh private discard', () => {
const f = createBasicSemutaAmbassadorFixture({ effect: 'choam' }), game = structuredClone(f.game);
const holder = player(game, f.holder), semuta = holder.hand.splice(holder.hand.findIndex(c => c.id === f.semuta), 1)[0];
player(game).hand.push(semuta);
const pending = act(game, f.discardStep.actor, f.discardStep.action), event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(viewGame(pending, f.beneficiary).semutaReaction!.canCommit, false);
rejected(pending, f.beneficiary, { type: 'semutaCommit', event });
const result = pass(pending); assert.ok(player(result).hand.some(c => c.id === f.semuta));
assert.deepEqual(inventory(result), f.originalTreachery);
});
void test('all four existing profiles legally complete actual Ambassador Semuta reactions', () => {
const f = createBasicSemutaAmbassadorFixture({ effect: 'choam', copied: true });
for (const difficulty of DIFFICULTIES) {
let game = act(f.game, f.discardStep.actor, f.discardStep.action), count = 0;
while (game.pendingTreacheryDiscard && count++ < 12) {
const reaction = game.pendingTreacheryDiscard.reaction!;
const actor = reaction.stage === 'select' ? reaction.player :
game.players.find(p => !reaction.passed.includes(p.id))!.id;
const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action); game = act(game, actor, action);
}
assert.ok(count < 12); assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(game.pendingAmbassador, null); assert.equal(game.active, f.entrant);
assert.deepEqual(inventory(game), f.originalTreachery);
}
});
+261
View File
@@ -0,0 +1,261 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type BasicSemutaAmbassadorEffect = 'choam' | 'ixians';
export type BasicSemutaAmbassadorStep = { actor: string; action: Action };
export type BasicSemutaAmbassadorTransition = { before: Game; step: BasicSemutaAmbassadorStep; after: Game };
export const basicSemutaAmbassadorPlayer = (game: Game, actor = 'ecaz') => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const basicSemutaAmbassadorClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
export const basicSemutaAmbassadorBalances = (game: Game) => Object.fromEntries(
game.players.map(player => [player.id, player.spice]),
);
export const basicSemutaAmbassadorInventory = (game: Game) => {
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(player => player.hand)];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Every original physical Treachery card has one current slot');
return ids;
};
/** Only original scalar lotteries are selected. Nine supplied rolls are scoped
* to native packet creation; UUID and all other byte entropy remains native. */
function withOriginalScalarLottery<T>(run: () => T, packet: readonly number[] = []): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1)
array[0] = cursor < packet.length ? packet[cursor++] : 0xffffffff;
else Reflect.apply(original, crypto, [array]);
return array;
};
try {
const result = run(); assert.equal(cursor, packet.length, 'The original packet consumed its nine selected draws');
return result;
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function actBasicSemutaAmbassador(game: Game, actor: string, action: Action,
actions: BasicSemutaAmbassadorStep[] = []): Game {
const next = withOriginalScalarLottery(() => applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) }); return next;
}
/** The source owns its auction, placement and effect selections. Continuations
* close actual native windows without playing Semuta or choosing a card grant. */
export function nextBasicSemutaAmbassadorStep(game: Game): BasicSemutaAmbassadorStep | null {
if (game.pendingTreacheryDiscard) {
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
if (reaction?.stage !== 'offer') return null;
const actor = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(actor);
return { actor: actor.id, action: { type: 'semutaPass', event: pending.batch.event } };
}
if (!game.response && !game.phaseOpening) {
if (game.decision?.kind === 'ecazAmbassador' || game.decision?.kind === 'richeseCache') return null;
if (game.decision?.kind === 'ecazPlacement')
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
if (game.status === 'setup' && game.setupStage === 'forces' &&
basicSemutaAmbassadorPlayer(game).reserves === 20)
return { actor: 'ecaz', action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } };
}
return nextRicheseStrongholdsNativeStep(game);
}
export function advanceBasicSemutaAmbassador(game: Game, until: (state: Game) => boolean,
actions: BasicSemutaAmbassadorStep[] = []): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Basic programme ended before its next control');
const next = nextBasicSemutaAmbassadorStep(game);
assert.ok(next, `Supply the original auction or Ambassador choice before advancing: ${JSON.stringify({
status: game.status, turn: game.turn, phase: game.phase, active: game.active, decision: game.decision,
entry: game.pendingAmbassador ? { stage: game.pendingAmbassador.stage,
effect: game.pendingAmbassador.effect, entrant: game.pendingAmbassador.entrant } : null,
discard: game.pendingTreacheryDiscard?.reaction?.stage,
})}`);
game = actBasicSemutaAmbassador(game, next.actor, next.action, actions);
}
throw Error('The original Basic Semuta/Ambassador programme did not reach its finite boundary');
}
/** Select the original ten-token Fisher–Yates packet, never replace its state.
* A BG source deliberately excludes the eventual copied effect from its cohort. */
function originalPacket(effect: BasicSemutaAmbassadorEffect, copied: boolean) {
const pool = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = [copied ? 'beneGesserit' : effect,
'atreides', 'emperor', 'fremen', 'harkonnen'];
const desired = [...supply, ...pool.filter(effect => !supply.includes(effect))];
const current = [...pool], rolls: number[] = [];
for (let i = current.length - 1; i > 0; i--) {
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[current[i], current[j]] = [current[j], current[i]];
}
assert.deepEqual(current, desired); assert.equal(rolls.length, 9);
return { rolls, supply };
}
/** Fresh public Basic E2/E3 source. Guild really buys Semuta from the original
* ten-card Richese cache. Ecaz buys one normal card and pays for one placement;
* Guild then pays for an actual entry at that marker. Stop at the native cards
* grant BEFORE any discard, payout, replacement draw or Semuta commitment. */
export function createBasicSemutaAmbassadorFixture(options: {
effect: BasicSemutaAmbassadorEffect; copied?: boolean; arrival?: 'shipment' | 'movement';
}) {
const effect = options.effect, copied = options.copied ?? false, packet = originalPacket(effect, copied);
const arrivalKind = options.arrival ?? 'shipment';
let game = createGame('BASICSEMUTAAMBASSADOR', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz', 'choam']);
for (const faction of ['choam', 'richese', 'guild'] as const)
joinGame(game, newPlayer(faction, faction, faction));
const owner = 'ecaz', beneficiary = owner, holder = 'guild', entrant = holder;
const actions: BasicSemutaAmbassadorStep[] = [];
const act = (actor: string, action: Action) => { game = actBasicSemutaAmbassador(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceBasicSemutaAmbassador(game, until, actions); };
const transition = (step: BasicSemutaAmbassadorStep, until: (state: Game) => boolean): BasicSemutaAmbassadorTransition => {
const before = structuredClone(game); act(step.actor, step.action); reach(until);
return { before, step: structuredClone(step), after: structuredClone(game) };
};
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, 'A fresh public bare Basic Richese start owns the Semuta marker');
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));
assert.equal(game.deck.length, 35); assert.equal(game.richeseCache?.length, 10);
assert.ok(game.richeseCache!.some(card => card.id === SEMUTA_DRUG_ID));
const originalMainDeck = game.deck.map(card => card.id).sort();
const originalCache = game.richeseCache!.map(card => card.id).sort();
const originalTreachery = basicSemutaAmbassadorInventory(game);
// Four still-undealt starts plus the first still-unopened normal purchase.
// Ordinary cards avoid optional card-use controls during the source path.
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 5);
assert.equal(ordinary.length, 5);
const 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);
let packetCreation: BasicSemutaAmbassadorTransition | null = null;
for (let count = 0; game.status === 'setup' && count < 80; count++) {
const next = nextBasicSemutaAmbassadorStep(game); assert.ok(next);
const initializesForces = game.setupStage === 'traitors' && next.action.type === 'traitor' &&
game.players.filter(player => player.traitorChoices.length > 0).length === 1;
if (initializesForces) {
const before = structuredClone(game);
game = withOriginalScalarLottery(() => applyAction(game, next.actor, next.action), packet.rolls);
actions.push(structuredClone(next));
packetCreation = { before, step: structuredClone(next), after: structuredClone(game) };
} else act(next.actor, next.action);
}
assert.equal(game.status, 'playing'); assert.ok(packetCreation);
const afterSetup = structuredClone(game);
const token = game.ecazAmbassadors!.tokens.find(token => token.effect === (copied ? 'beneGesserit' : effect))!;
assert.equal(token.zone, 'supply');
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.effect === effect)!.zone, copied ? 'pool' : 'supply');
assert.deepEqual(game.ecazAmbassadors!.cohort.map(id =>
game.ecazAmbassadors!.tokens.find(token => token.id === id)!.effect), packet.supply);
assert.deepEqual(basicSemutaAmbassadorPlayer(game).hand.map(card => card.id), [deal[0]]);
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);
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 beforeCacheOffer = structuredClone(game), acquisitionStart = actions.length;
const cacheOffer: BasicSemutaAmbassadorStep = { actor: 'richese', action: { type: 'decision',
event: game.richeseBidding!.event, card: SEMUTA_DRUG_ID, method: 'onceAround', direction: 'counterclockwise' } };
act(cacheOffer.actor, cacheOffer.action);
reach(state => basicSemutaAmbassadorClean(state) && !!state.richeseAuction && !state.richeseAuction.outcome);
const cacheAuction = structuredClone(game);
const cacheBidSteps: BasicSemutaAmbassadorStep[] = [];
let semutaBid: BasicSemutaAmbassadorTransition | null = null;
for (let count = 0; !basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) {
const lot = game.richeseAuction; assert.ok(lot && !lot.outcome && lot.active);
const bid: BasicSemutaAmbassadorStep = { actor: lot.active, action: { type: 'richeseBid', event: lot.event,
amount: lot.active === holder ? 1 : null, allyPayment: 0 } };
cacheBidSteps.push(structuredClone(bid));
const result = transition(bid, state => basicSemutaAmbassadorClean(state));
if (bid.actor === holder) semutaBid = result;
}
assert.ok(semutaBid); assert.ok(basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.equal(game.richeseCache!.length, 9);
assert.equal(basicSemutaAmbassadorPlayer(game, holder).spice,
basicSemutaAmbassadorPlayer(beforeCacheOffer, holder).spice - 1);
const acquisition = { before: beforeCacheOffer, steps: structuredClone(actions.slice(acquisitionStart)), after: structuredClone(game) };
reach(state => state.phase === 3 && basicSemutaAmbassadorClean(state) && state.auction?.active === owner);
const normalPurchase = transition({ actor: owner, action: { type: 'bid', amount: 1, allyPayment: 0 } },
state => basicSemutaAmbassadorPlayer(state, owner).hand.length === 2 && basicSemutaAmbassadorClean(state));
assert.equal(basicSemutaAmbassadorPlayer(game).spice, basicSemutaAmbassadorPlayer(normalPurchase.before).spice - 1);
assert.deepEqual(basicSemutaAmbassadorPlayer(game).hand.map(card => card.id), [deal[0], deal[4]]);
reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
const territory = 'habbanya_ridge_sietch', sector = 17;
const placement = transition({ actor: owner, action: { type: 'decision', token: token.id, territory } },
state => !state.response && state.decision?.kind === 'ecazPlacement');
assert.equal(basicSemutaAmbassadorPlayer(game).spice, basicSemutaAmbassadorPlayer(placement.before).spice - 1);
act(owner, { type: 'decision', decline: true });
reach(state => state.phase === 5 && state.active === entrant && basicSemutaAmbassadorClean(state));
const beforeArrival = structuredClone(game);
assert.equal(basicSemutaAmbassadorPlayer(game, entrant).shipped, false);
assert.equal(basicSemutaAmbassadorPlayer(game, entrant).ally, null);
assert.ok(game.players.every(player => !Object.keys(player.forces).some(location => location.startsWith(`${territory}:`))));
const shipmentTerritory = arrivalKind === 'movement' ? 'habbanya_ridge_flat' : territory;
const shipment = transition({ actor: entrant, action: { type: 'ship', amount: 1, territory: shipmentTerritory, sector, elite: 0, allyPayment: 0 } },
state => arrivalKind === 'shipment'
? !state.response && state.pendingAmbassador?.stage === 'offer'
: basicSemutaAmbassadorClean(state));
const arrival = arrivalKind === 'shipment' ? shipment : transition({ actor: entrant, action: {
type: 'move', from: `${shipmentTerritory}:${sector}`, amount: 1, territory, sector,
} }, state => !state.response && state.pendingAmbassador?.stage === 'offer');
assert.equal(basicSemutaAmbassadorPlayer(game, entrant).spice,
basicSemutaAmbassadorPlayer(beforeArrival, entrant).spice - 1);
const offer = structuredClone(game), event = game.pendingAmbassador!.event;
assert.equal(game.pendingAmbassador!.token, token.id);
const triggerStep: BasicSemutaAmbassadorStep = { actor: owner,
action: { type: 'decision', event, trigger: true, beneficiary, choice: 'effect' } };
act(triggerStep.actor, triggerStep.action);
let beforeCopy: Game | null = null, copyStep: BasicSemutaAmbassadorStep | null = null;
if (copied) {
assert.equal(game.pendingAmbassador?.stage, 'copy');
assert.ok(game.pendingAmbassador!.copyChoices.includes(effect)); beforeCopy = structuredClone(game);
copyStep = { actor: beneficiary, action: { type: 'decision', event, effect } };
act(copyStep.actor, copyStep.action);
}
assert.equal(game.pendingAmbassador?.stage, 'cards'); assert.equal(game.pendingAmbassador!.effect, effect);
assert.equal(game.pendingAmbassador!.beneficiary, beneficiary); assert.equal(game.pendingAmbassador!.entrant, entrant);
assert.equal(game.decision?.player, beneficiary); assert.equal(game.active, entrant);
assert.equal(game.turn, 1); assert.equal(game.phase, 5); assert.ok(!game.pendingTreacheryDiscard);
assert.ok(basicSemutaAmbassadorPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.deepEqual(basicSemutaAmbassadorInventory(game), originalTreachery);
const currentGrant = structuredClone(game), eligibleChosenIds = basicSemutaAmbassadorPlayer(game, beneficiary).hand
.slice(0, effect === 'choam' ? 2 : 1).map(card => card.id);
assert.equal(eligibleChosenIds.length, effect === 'choam' ? 2 : 1);
const preReplacementDeckTop = structuredClone(game.deck[0]); assert.ok(preReplacementDeckTop);
const discardStep: BasicSemutaAmbassadorStep = { actor: beneficiary, action: { type: 'decision', event, cards: eligibleChosenIds } };
return { game, lobby, setup, beforeStartingDeal, afterSetup, packetCreation, packet, deal, dealOrder,
spiceOrder, originalMainDeck, originalCache, originalTreachery, beforeCacheOffer, cacheOffer,
cacheAuction, cacheBidSteps, semutaBid, acquisition, normalPurchase, placement, beforeArrival, shipment, arrival, arrivalKind, offer,
triggerStep, beforeCopy, copyStep, currentGrant, ownerView: viewGame(game, owner), holderView: viewGame(game, holder),
entrantView: viewGame(game, entrant), effect, copied, advanced: false as const,
owner, beneficiary, holder, entrant, token: token.id, semuta: SEMUTA_DRUG_ID, event, territory, sector,
location: `${territory}:${sector}`, eligibleChosenIds, chosenCards: eligibleChosenIds, discardStep,
preReplacementDeckTop, balances: basicSemutaAmbassadorBalances(game),
entrantContinuation: { actor: entrant, action: { type: 'endMovement' } } as BasicSemutaAmbassadorStep,
actions, sourceActionCount: actions.length,
staging: ['Only five unopened original main-deck positions (four starts and the first normal purchase), two unopened ordinary land Spice positions and original scalar lotteries are selected. Nine explicitly recorded draws select the original Ambassador packet on the final original traitor choice. Other scalar draws retain Fisher–Yates order; UUID/byte entropy remains native. Public Basic Ecaz/CHOAM/Richese/Guild setup retains the selected Ecaz+CHOAM decks (35 main plus 10 genuine native cache cards) and no optional modules. Guild buys the physical Semuta Drug in the real Once Around cache auction for 1; Ecaz pays 1 for the original first ordinary card and 1 for its actual placement. Guild then pays 1 for the non-allied original reserve entry. Native/BG-copy choices stop at the original cards grant. No dealt hands, resources, armies, clocks, alliances, deaths, token custody, pending reactions or receipts are installed. No discard, CHOAM payout, Ixian replacement or Semuta commitment has happened in this source snapshot.'] };
}
export type BasicSemutaAmbassadorFixture = ReturnType<typeof createBasicSemutaAmbassadorFixture>;
+18 -2
View File
@@ -12,6 +12,17 @@ import { newRevivalRules } from '../game/revival';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
/** These discard tests begin after the participants acknowledge the actual
* revealed result; that native window precedes fresh cleanup reactions. */
function finishBattleOutcome(game: Game): Game {
while (game.battle?.outcomeWindow) {
const window = game.battle.outcomeWindow;
const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id));
assert.ok(actor); game = applyAction(game, actor, { type: 'battleOutcomeReady', event: window.event });
}
return game;
}
function take(g: Game, id: string) {
const zones = [g.deck, g.discard, g.richeseCache!, ...g.players.map(p => p.hand)];
for (const zone of zones) {
@@ -88,6 +99,7 @@ for (const holder of ['a', 'r'] as const) void test(
state = applyAction(state, 'e', { type: 'battlePlan', dial: 0, support: 0,
leader: player(state, 'e').leaders.find(isAuditorLeader)!.id, defense: defense.id });
for (const id of ['a', 'e']) state = applyAction(state, id, { type: 'traitorCall', call: false });
state = finishBattleOutcome(state);
assert.equal(state.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const pass = (game: Game) => {
const event = game.pendingTreacheryDiscard!.batch.event;
@@ -165,6 +177,7 @@ void test('Semuta preserves quoted CHOAM battle income through original allowanc
state = applyAction(state, 'e', { type: 'battlePlan', dial: 0, support: 0,
leader: player(state, 'e').leaders.find(l => !isAuditorLeader(l))!.id, defense: defense.id });
for (const id of ['a', 'e']) state = applyAction(state, id, { type: 'traitorCall', call: false });
state = finishBattleOutcome(state);
assert.equal(state.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(state.pendingChoamBattleIncome, { owner: 'e', amount: 1 });
assert.equal(player(state, 'e').spice, beforeIncome);
@@ -262,6 +275,7 @@ void test('Semuta settles mandatory and winner discards before one native Face D
state = applyAction(state, 'e', { type: 'battlePlan', dial: 0, support: 0,
leader: player(state, 'e').leaders[0].id, defense: defense.id });
for (const id of ['a', 'e']) state = applyAction(state, id, { type: 'traitorCall', call: false });
state = finishBattleOutcome(state);
assert.equal(state.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(state.pendingFaceDance?.winner, 'a');
const pass = (game: Game) => {
@@ -1659,6 +1673,7 @@ void test('clean winner-selected battle cards allow one committed Semuta choice
leader: player(battle, 'e').leaders[0].id, defense: defense.id });
battle = applyAction(battle, 'a', { type: 'traitorCall', call: false });
battle = applyAction(battle, 'e', { type: 'traitorCall', call: false });
battle = finishBattleOutcome(battle);
assert.equal(battle.pendingTreacheryDiscard?.continuation.kind, 'battleResolved');
assert.equal(battle.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(battle.pendingTreacheryDiscard?.batch.entries.map(entry => [
@@ -1770,6 +1785,7 @@ void test('Semuta settles mandatory and winner discards before the original Hark
state = applyAction(state, 'a', { type: 'battlePlan', dial: 0, support: 0,
leader: player(state, 'a').leaders[0].id, defense: defense.id });
for (const id of ['e', 'a']) state = applyAction(state, id, { type: 'traitorCall', call: false });
state = finishBattleOutcome(state);
assert.equal(state.pendingTreacheryDiscard?.continuation.kind, 'battleResolved');
assert.equal(state.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(state.pendingCapture, { player: 'e', loser: 'a', territory: 'arrakeen' });
@@ -1857,7 +1873,7 @@ void test('a mandatory winner Hero is reserved through the loser offer and claim
battle = applyAction(battle, 'e', { type: 'battlePlan', dial: 0, support: 0,
leader: player(battle, 'e').leaders[0].id, defense: defense.id });
battle = applyAction(battle, 'a', { type: 'traitorCall', call: false });
let pending = applyAction(battle, 'e', { type: 'traitorCall', call: false });
let pending = finishBattleOutcome(applyAction(battle, 'e', { type: 'traitorCall', call: false }));
assert.equal(player(pending, 'a').hand.some(card => card.id === hero.id), true);
assert.equal(pending.discard.some(card => card.id === hero.id), false);
const loserEvent = pending.pendingTreacheryDiscard!.batch.event;
@@ -1929,7 +1945,7 @@ void test('a mutual-traitor battle offers one mixed-owner mandatory batch with o
battle = applyAction(battle, 'e', { type: 'battlePlan', dial: 0, support: 0,
leader: player(battle, 'e').leaders[0].id, defense: snooper.id });
battle = applyAction(battle, 'a', { type: 'traitorCall', call: true });
const pending = applyAction(battle, 'e', { type: 'traitorCall', call: true });
const pending = finishBattleOutcome(applyAction(battle, 'e', { type: 'traitorCall', call: true }));
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'battleResolved');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(pending.pendingTreacheryDiscard?.batch.entries.map(entry => [