Preserve Guild advisor arrivals and support matching Semuta costs

This commit is contained in:
Quintine committed 2026-10-26 05:23:31 +11:00
1 parent 81fbbca309
commit 617a6593a9
14 files changed
+1649 -14

No files matched your search

@@ -0,0 +1,237 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, 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 { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import {
actSemutaCombatCapabilities, nextSemutaCombatCapabilities,
semutaCombatCapabilitiesPlayer, semutaCombatCapabilitiesInventory, semutaCombatCapabilitiesClean,
passSemutaCombatCapabilities, claimSemutaCombatCapabilities,
} from './fixture-semuta-combat-capabilities';
export type SemutaAdvisorAmbassadorMatchingDirection = 'fightersToAdvisors' | 'advisorsToFighters';
export type SemutaAdvisorAmbassadorMatchingStep = { actor: string; action: Action };
export const actSemutaAdvisorAmbassadorMatching = actSemutaCombatCapabilities;
export const semutaAdvisorAmbassadorMatchingPlayer = semutaCombatCapabilitiesPlayer;
export const semutaAdvisorAmbassadorMatchingInventory = semutaCombatCapabilitiesInventory;
export const semutaAdvisorAmbassadorMatchingClean = semutaCombatCapabilitiesClean;
export const passSemutaAdvisorAmbassadorMatching = passSemutaCombatCapabilities;
export const claimSemutaAdvisorAmbassadorMatching = claimSemutaCombatCapabilities;
export const semutaAdvisorAmbassadorMatchingPhysical = (game: Game) => game.players.map(p => ({
id: p.id, spice: p.spice, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites,
moved: p.moved, shipped: p.shipped, ally: p.ally, allySinceTurn: p.allySinceTurn,
}));
/** Native setup/quiet controls only; placements, Ambassador selections and
* matching remain source/caller owned. Spiritual accompaniment is declined. */
export function nextSemutaAdvisorAmbassadorMatching(game: Game): SemutaAdvisorAmbassadorMatchingStep | null {
if (game.response?.kind === 'advisorFlip') return null;
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
if (game.status === 'setup' && game.setupStage === 'prediction')
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'atreides', turn: 10 } };
const ecaz = game.players.find(p => p.faction === 'ecaz');
if (game.status === 'setup' && game.setupStage === 'forces' && ecaz?.reserves === 20)
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
const bg = game.players.find(p => p.faction === 'beneGesserit');
if (game.status === 'setup' && game.setupStage === 'forces' && bg && !bg.advisorSetup)
return { actor: bg.id, action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
const d = game.decision;
if (d?.kind === 'ecazPlacement' || d?.kind === 'ecazAmbassador') return null;
if (d?.kind === 'advisorBattle' || d?.kind === 'advisor' || d?.kind === 'intrusion')
return { actor: d.player, action: { type: 'decision', accept: false } };
}
return nextSemutaCombatCapabilities(game);
}
export const nextSemutaAdvisorAmbassadorMatchingStep = nextSemutaAdvisorAmbassadorMatching;
export function advanceSemutaAdvisorAmbassadorMatching(game: Game, until: (state: Game) => boolean,
actions: SemutaAdvisorAmbassadorMatchingStep[] = []): Game {
for (let count = 0; count < 3200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextSemutaAdvisorAmbassadorMatching(game);
assert.ok(next, `Supply the actual Fremen Ambassador choice at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actSemutaAdvisorAmbassadorMatching(game, next.actor, next.action, actions);
}
throw Error('The native Semuta/Ambassador matching source did not reach its finite boundary');
}
export function allowSemutaAdvisorAmbassadorMatching(game: Game, actions: SemutaAdvisorAmbassadorMatchingStep[] = []): Game {
const event = game.response?.advisorAmbassadorMatchingSource?.event; assert.ok(event);
for (let count = 0; game.response?.advisorAmbassadorMatchingSource?.event === event && count < game.players.length; count++) {
const p = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(p);
game = actSemutaAdvisorAmbassadorMatching(game, p.id, { type: 'passResponse' }, actions);
}
assert.notEqual(game.response?.advisorAmbassadorMatchingSource?.event, event); return game;
}
/** Finish only this consumed Ambassador grant. A prevented caller must first
* choose a real legal alternate; no new decline payload is introduced here. */
export function finishSemutaAdvisorAmbassadorMatching(game: Game, actions: SemutaAdvisorAmbassadorMatchingStep[] = []): Game {
assert.ok(!game.pendingTreacheryDiscard);
return advanceSemutaAdvisorAmbassadorMatching(game, state => !state.pendingAmbassador &&
!state.pendingAdvisorAmbassadorMatching && semutaAdvisorAmbassadorMatchingClean(state), actions);
}
function originalFremenPacket() {
const original = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = ['fremen', ...original.filter(effect => effect !== 'fremen').slice(0, 4)];
const order = [...supply, ...original.filter(effect => !supply.includes(effect))], current = [...original], scalars: number[] = [];
for (let index = current.length - 1; index > 0; index--) {
const from = current.indexOf(order[index]); assert.ok(from >= 0 && from <= index);
scalars.push(Math.floor((from + 0.5) * 4294967296 / (index + 1)));
[current[index], current[from]] = [current[from], current[index]];
}
assert.deepEqual(current, order); return { supply, order, scalars };
}
function createOriginalPacket(game: Game, step: SemutaAdvisorAmbassadorMatchingStep,
actions: SemutaAdvisorAmbassadorMatchingStep[], scalars: number[]) {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = 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 < scalars.length ? scalars[cursor++] : 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try {
const next = applyAction(game, step.actor, step.action); assert.equal(cursor, scalars.length);
actions.push(structuredClone(step)); return next;
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Real native Fremen packet, paid token/fighter group and Nexus alliance.
* The matching response precedes any independent relocation. */
export function createSemutaAdvisorAmbassadorMatchingFixture(options: { direction?: SemutaAdvisorAmbassadorMatchingDirection } = {}) {
const direction = options.direction ?? 'fightersToAdvisors';
const richese = 'richese', holder = 'choam', ecaz = 'ecaz', owner = 'beneGesserit', entrant = 'atreides', spender = 'emperor';
let game = createGame('SEMUTAADVISORAMBASSADORMATCHING', newPlayer(richese, 'Richese', 'richese'), true, ['choam', 'ecaz']);
for (const faction of ['choam', 'ecaz', 'beneGesserit', 'atreides', 'emperor'] as const)
joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: SemutaAdvisorAmbassadorMatchingStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaAdvisorAmbassadorMatching(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorAmbassadorMatching(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), at = actions.length; run();
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
};
for (const p of game.players) act(p.id, { type: 'ready' });
const lobby = structuredClone(game);
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.equal(game.semutaPreview, true);
assert.ok(game.players.every(p => p.hand.length === 0));
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
const originalInventory = semutaAdvisorAmbassadorMatchingInventory(game); assert.equal(originalInventory.length, 45);
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
const ordinary = game.deck.filter(card => card.kind === 'projectile' || card.kind === 'poison').slice(0, 5);
assert.equal(ordinary.length, 5); const selected = [...ordinary, printed];
const dealOrder = selected.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { card: card.id, position, from };
});
const beforeStartingDeal = structuredClone(game), cost = printed.id, packet = originalFremenPacket();
let packetCreation: { before: Game; steps: SemutaAdvisorAmbassadorMatchingStep[]; after: Game } | null = null;
let advisorSetup: { before: Game; steps: SemutaAdvisorAmbassadorMatchingStep[]; after: Game } | null = null;
for (let count = 0; game.status === 'setup' && count < 180; count++) {
const next = nextSemutaAdvisorAmbassadorMatching(game); assert.ok(next);
const before = structuredClone(game), at = actions.length;
const creates = game.setupStage === 'traitors' && next.action.type === 'traitor' &&
game.players.filter(p => p.traitorChoices.length).length === 1;
if (creates) game = createOriginalPacket(game, next, actions, packet.scalars);
else act(next.actor, next.action);
const record = { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
if (creates) packetCreation = record;
if (next.action.type === 'advisorSetup') advisorSetup = record;
}
assert.equal(game.status, 'playing'); assert.ok(packetCreation && advisorSetup);
const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
assert.deepEqual(game.ecazAmbassadors!.cohort.map(id => game.ecazAmbassadors!.tokens.find(t => t.id === id)!.effect), packet.supply);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const orderLand = (positions: number[]) => positions.map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
!['imperial_basin', 'arrakeen', 'tueks_sietch', 'carthag'].includes(card.territory));
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
});
const spiceOrder = orderLand([0, 1]);
let beforeBid: Game | undefined, bid: SemutaAdvisorAmbassadorMatchingStep | undefined, bidAt = -1;
for (let count = 0; count < 1000 && !semutaAdvisorAmbassadorMatchingPlayer(game, holder).hand.some(c => c.id === SEMUTA_DRUG_ID); count++) {
const next = nextSemutaAdvisorAmbassadorMatching(game); assert.ok(next);
if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) {
beforeBid = structuredClone(game); bid = structuredClone(next); bidAt = actions.length;
}
act(next.actor, next.action);
}
assert.ok(beforeBid && bid && bidAt >= 0);
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
reach(state => state.turn === 1 && state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
const token = game.ecazAmbassadors!.tokens.find(t => t.effect === 'fremen' && t.zone === 'supply'); assert.ok(token);
const placement = transition(() => {
act(ecaz, { type: 'decision', token: token.id, territory: 'carthag' });
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
act(ecaz, { type: 'decision', decline: true });
});
assert.equal(semutaAdvisorAmbassadorMatchingPlayer(placement.before, ecaz).spice - semutaAdvisorAmbassadorMatchingPlayer(game, ecaz).spice, 1);
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && semutaAdvisorAmbassadorMatchingClean(state));
const fighterShipment = transition(() => {
act(owner, { type: 'ship', territory: 'tueks_sietch', sector: 5, amount: 2, elite: 0, allyPayment: 0 });
reach(semutaAdvisorAmbassadorMatchingClean);
});
assert.equal(semutaAdvisorAmbassadorMatchingPlayer(fighterShipment.before, owner).spice - semutaAdvisorAmbassadorMatchingPlayer(game, owner).spice, 2);
reach(state => state.turn === 2 && state.phase === 1 && semutaAdvisorAmbassadorMatchingClean(state) &&
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
const beforeWorm = structuredClone(game), beforeWormInventory = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const wormIndex = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.thumper && !card.suppressed);
assert.ok(wormIndex >= 0); const worm = game.spiceDeck.splice(wormIndex, 1)[0]; game.spiceDeck.unshift(worm);
const wormOrder = { position: 0, from: wormIndex, card: structuredClone(worm) }, wormFollowupOrder = orderLand([1, 2]);
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), beforeWormInventory);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && semutaAdvisorAmbassadorMatchingClean(state));
const alliance = transition(() => {
act(owner, { type: 'alliance', target: ecaz }); act(ecaz, { type: 'alliance', target: owner });
});
reach(state => state.turn === 2 && state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
act(ecaz, { type: 'decision', decline: true });
reach(state => state.turn === 2 && state.phase === 5 && state.active === entrant && semutaAdvisorAmbassadorMatchingClean(state));
const entrantShipment = transition(() => {
act(entrant, { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 });
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'offer');
});
const event = game.pendingAmbassador!.event;
const trigger = transition(() => {
act(ecaz, { type: 'decision', event, trigger: true, beneficiary: owner });
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'move');
});
const fightersToAdvisors: Action = { type: 'decision', event, forces: { 'tueks_sietch:5': 1 }, territory: 'arrakeen', sector: 10 };
const advisorsToFighters: Action = { type: 'decision', event, forces: { 'arrakeen:10': 1 }, territory: 'tueks_sietch', sector: 5 };
const alternate: Action = { type: 'decision', event, forces: { 'tueks_sietch:5': 1 }, territory: 'polar_sink', sector: 0 };
const relocationAction = direction === 'fightersToAdvisors' ? fightersToAdvisors : advisorsToFighters;
const from = direction === 'fightersToAdvisors' ? 'tueks_sietch:5' : 'arrakeen:10';
const to = direction === 'fightersToAdvisors' ? 'arrakeen:10' : 'tueks_sietch:5';
const declaration = transition(() => { act(owner, relocationAction); });
assert.ok(game.response?.kind === 'advisorFlip' && game.response.advisorAmbassadorMatchingSource);
assert.ok(game.pendingAdvisorAmbassadorMatching && game.pendingAmbassador);
assert.deepEqual(semutaAdvisorAmbassadorMatchingInventory(game), originalInventory);
return { game, kind: 'advisorFlip' as const, advanced: true as const, direction, richese, holder, owner, ecaz, entrant, spender,
beneficiary: owner, cost, karama: cost, semuta: SEMUTA_DRUG_ID,
action: { type: 'card', card: cost, mode: 'cancel' } as Action,
originalResponse: structuredClone(game.response), originalSource: structuredClone(game.response.advisorAmbassadorMatchingSource),
originalParent: structuredClone(game.pendingAdvisorAmbassadorMatching), originalAmbassador: structuredClone(game.pendingAmbassador),
originalPhysical: structuredClone(semutaAdvisorAmbassadorMatchingPhysical(game)),
originalStance: structuredClone(semutaAdvisorAmbassadorMatchingPlayer(game, owner).advisors!),
originalTokens: structuredClone(game.ecazAmbassadors!), token: token.id, event, from, to, relocationAction, alternate,
fightersToAdvisors, advisorsToFighters, initial, lobby, setup, beforeStartingDeal, afterSetup, advisorSetup,
packet, packetCreation, dealOrder, deal: selected.map(c => c.id), originalInventory, originalSpice, spiceOrder,
acquisition, acquired, cache: acquisition, placement, fighterShipment, beforeWorm, wormOrder, wormFollowupOrder,
alliance, entrantShipment, trigger, declaration, actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Original narrow Advanced Ecaz Semuta initializer on ready R/C/Ecaz/BG/A/E, exact CHOAM+Ecaz and no modules. Native Ecaz Basin6/BG Arrakeen1 beside A10 setup execute. Only unopened main/Spice positions, scalar lotteries and original nine-draw Fremen packet are selected; C buys cache1 and E receives printed Karama.',
'Ecaz really pays1 to place its original Fremen token in Carthag at first Revival. BG actually pays2 to ship two reserves into empty Tuek’s5 as fighters, retaining its original peaceful Arrakeen advisor. Actual next Storm, original worm/Nexus and reciprocal BG/Ecaz proposals earn the alliance.',
'Atreides actually pays1 into the placed Carthag token; its earlier Spiritual opportunity is declined through native controls. Ecaz actually triggers Fremen for its BG ally and consumes the original token. BG selects a real one-counter opposite-type relocation, which stops at precommit matching without transferring the packet.',
'The source exposes both actual stance directions and a native legal alternate relocating one Tuek’s fighter to empty Polar Sink. No new decline is invented. No played hand, wallet, token, ally, army, stance, clock, parent or Semuta window is installed.',
] };
}
export type SemutaAdvisorAmbassadorMatchingFixture = ReturnType<typeof createSemutaAdvisorAmbassadorMatchingFixture>;
+266
View File
@@ -0,0 +1,266 @@
import assert from 'node:assert/strict';
import {
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { distance } from '../game/board';
import { isAdvisor } from '../game/advisors';
import { advisorMatchingKey } from '../game/advisor-matching';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { planetologistLeader } from '../game/planetologist-movement';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import {
actSemutaRicheseCacheKarama, nextSemutaRicheseCacheKarama,
semutaRicheseCacheKaramaClean, semutaRicheseCacheKaramaInventory, semutaRicheseCacheKaramaPlayer,
} from './fixture-semuta-richese-cache-karama';
import {
passSemutaFremenGuildMovement, claimSemutaFremenGuildMovement,
} from './fixture-semuta-fremen-guild-movement';
export type SemutaAdvisorGatherPosition = 'first' | 'second';
export type SemutaAdvisorGatherStep = { actor: string; action: Action };
export const actSemutaAdvisorGather = actSemutaRicheseCacheKarama;
export const passSemutaAdvisorGather = passSemutaFremenGuildMovement;
export const claimSemutaAdvisorGather = claimSemutaFremenGuildMovement;
export const semutaAdvisorGatherPlayer = semutaRicheseCacheKaramaPlayer;
export const semutaAdvisorGatherInventory = semutaRicheseCacheKaramaInventory;
export const semutaAdvisorGatherClean = semutaRicheseCacheKaramaClean;
export const semutaAdvisorGatherPhysical = (game: Game) => ({
turn: game.turn, phase: game.phase, storm: game.storm, order: game.order, active: game.active,
ready: game.ready, movementRemaining: game.movementRemaining,
spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard, skills: game.leaderSkills,
players: game.players.map(player => ({ id: player.id, spice: player.spice, forces: player.forces,
advisors: player.advisors, reserves: player.reserves, tanks: player.tanks, elites: player.elites,
leaders: player.leaders, shipped: player.shipped, moved: player.moved, ally: player.ally })),
});
export function semutaAdvisorGatherSkillInventory(game: Game) {
const state = game.leaderSkills!;
const ids = [...state.deck, ...Object.values(state.offers).flatMap(offer => offer.cards),
...state.assignments.map(assignment => assignment.skill)].sort();
assert.equal(new Set(ids).size, ids.length, 'Every original all14 Skill retains one physical slot'); return ids;
}
const training: Record<string, LeaderSkillId> = {
richese: 'killer-medic', choam: 'swordmaster-of-ginaz', beneGesserit: 'planetologist',
atreides: 'prana-bindu-adept', emperor: 'master-of-assassins',
};
/** Original two-card offers are really selected and assigned. Fresh matching
* declarations and their costs remain caller-owned, while optional preparation
* and accompaniment are declined through their actual original controls. */
export function nextSemutaAdvisorGather(game: Game): SemutaAdvisorGatherStep | null {
if (game.pendingTreacheryDiscard || game.truthtrance || game.response?.kind === 'advisorFlip') return null;
if (!game.phaseOpening && !game.response) {
if (game.status === 'setup' && game.setupStage === 'prediction')
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'atreides', turn: 10 } };
if (game.status === 'setup' && game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
const offer = own.offer, skill = training[actor];
assert.ok(offer && offer.cards.includes(skill) && !own.unavailableSkills?.[skill]);
const player = semutaAdvisorGatherPlayer(game, actor);
const leader = own.eligibleLeaders.sort((a, b) =>
player.leaders.find(disc => disc.id === b.id)!.strength - player.leaders.find(disc => disc.id === a.id)!.strength ||
a.id.localeCompare(b.id))[0]; assert.ok(leader);
return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
}
if (game.status === 'setup' && game.setupStage === 'forces' &&
!semutaAdvisorGatherPlayer(game, 'beneGesserit').advisorSetup)
return { actor: 'beneGesserit', action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
if (game.decision?.kind === 'advisorBattle' || game.decision?.kind === 'advisor')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
if (game.decision?.kind === 'intrusion') return null;
}
return nextSemutaRicheseCacheKarama(game);
}
export function advanceSemutaAdvisorGather(game: Game, until: (state: Game) => boolean,
actions: SemutaAdvisorGatherStep[] = []): Game {
for (let count = 0; count < 2000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextSemutaAdvisorGather(game);
assert.ok(step, 'Choose the original matching counter or fresh cost before continuing');
game = actSemutaAdvisorGather(game, step.actor, step.action, actions);
}
throw Error('The original Semuta gather programme did not reach its finite boundary');
}
function allowOneGatherSource(game: Game, actions: SemutaAdvisorGatherStep[] = []): Game {
const source = game.response?.advisorGatherMatchingSource; assert.ok(source);
for (let count = 0; game.response?.advisorGatherMatchingSource?.event === source.event && count < game.players.length; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actSemutaAdvisorGather(game, actor.id, { type: 'passResponse' }, actions);
}
assert.notEqual(game.response?.advisorGatherMatchingSource?.event, source.event); return game;
}
/** Allow the actual remaining origin counters, then only their original
* movement suffix. The first approval alone never flips or transfers a force. */
export function allowSemutaAdvisorGather(game: Game, actions: SemutaAdvisorGatherStep[] = []): Game {
assert.ok(game.response?.advisorGatherMatchingSource);
for (let count = 0; game.response?.advisorGatherMatchingSource && count < 2; count++)
game = allowOneGatherSource(game, actions);
assert.equal(game.pendingAdvisorGatherMatching, undefined);
return finishSemutaAdvisorGather(game, actions);
}
export function finishSemutaAdvisorGather(game: Game, actions: SemutaAdvisorGatherStep[] = []): Game {
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
assert.equal(game.pendingAdvisorGatherMatching, undefined);
return advanceSemutaAdvisorGather(game, semutaAdvisorGatherClean, actions);
}
/** Fresh original Advanced Semuta+all14 Skills development entry. BG's real
* selected Planetologist and two paid fighter shipments supply the packet;
* only untouched original Skill/Treachery/Spice orders are selected. */
export function createSemutaAdvisorGatherFixture(options: { position?: SemutaAdvisorGatherPosition } = {}) {
const position = options.position ?? 'first', richese = 'richese', holder = 'choam', owner = 'beneGesserit',
spender = 'atreides', emperor = 'emperor';
let game = createGame('SEMUTAADVISORGATHER', newPlayer(richese, 'Richese', 'richese'), true, ['choam']);
for (const faction of [holder, owner, spender, emperor] as const)
joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: SemutaAdvisorGatherStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaAdvisorGather(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorGather(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
// Existing explicit Skills entry requires an undealt lobby with no already
// allocated Skill state; it creates the original deck instead of redealing.
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game, true));
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
entry: 'original-undealt-semuta-all14-skills', skills: true };
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
assert.ok(game.leaderSkills); assert.deepEqual(game.expansions, ['choam']);
assert.ok(game.players.every(player => player.hand.length === 0));
assert.ok(!game.homeworlds && !game.nexusCards && !game.techTokens && !game.strongholdCards &&
!game.discoveryEnabled && !game.ecazTreachery);
assert.ok(!Object.keys(game.leaderSkills.offers).length && !game.leaderSkills.assignments.length);
const originalInventory = semutaAdvisorGatherInventory(game), originalSkillInventory = semutaAdvisorGatherSkillInventory(game);
assert.equal(originalInventory.length, 45); assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10);
assert.deepEqual(originalSkillInventory, LEADER_SKILL_CARDS.map(card => card.id).sort());
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 4); assert.equal(ordinary.length, 4);
const selected = [ordinary[0], ordinary[1], ordinary[2], printed, ordinary[3]], cost = printed.id;
const dealOrder = selected.map((card, index) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= index);
[game.deck[index], game.deck[from]] = [game.deck[from], game.deck[index]];
return { position: index, from, card: card.id };
});
const beforeSkillOrder = structuredClone(game), originalSkillOrder = [...game.leaderSkills.deck];
const wanted = game.players.map(player => training[player.id]), rest = game.leaderSkills.deck.filter(skill => !wanted.includes(skill));
const skillOrder = wanted.flatMap(skill => [skill, rest.shift()!]).concat(rest);
game.leaderSkills.deck.splice(0, game.leaderSkills.deck.length, ...skillOrder);
assert.deepEqual(semutaAdvisorGatherSkillInventory(game), originalSkillInventory);
const beforeStartingDeal = structuredClone(game);
let advisorSetup: ReturnType<typeof transition> | null = null, skillOffers: Game | null = null;
const skillChoices: ReturnType<typeof transition>[] = [];
for (let count = 0; game.status === 'setup' && count < 120; count++) {
const step = nextSemutaAdvisorGather(game); assert.ok(step);
const before = structuredClone(game), start = actions.length;
if (before.setupStage === 'leaderSkills' && !skillOffers) skillOffers = structuredClone(game);
act(step.actor, step.action);
const record = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
if (step.action.type === 'leaderSkill') skillChoices.push(record);
if (step.action.type === 'advisorSetup') advisorSetup = record;
}
assert.equal(game.status, 'playing'); assert.ok(advisorSetup && skillOffers); assert.equal(skillChoices.length, 5);
const afterSetup = structuredClone(game), trainer = planetologistLeader(game, owner); assert.ok(trainer);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
assert.equal(semutaAdvisorGatherPlayer(game, owner).forces['arrakeen:10'], 1);
assert.equal(isAdvisor(semutaAdvisorGatherPlayer(game, owner), 'arrakeen'), true);
assert.equal(semutaAdvisorGatherPlayer(game, spender).forces['arrakeen:10'], 10);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1, 2, 3].map(index => {
const from = game.spiceDeck.findIndex((card, cardIndex) => cardIndex >= index && 'territory' in card && !card.discovery &&
!['imperial_basin', 'oh_gap', 'arrakeen'].includes(card.territory));
assert.ok(from >= index); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(index, 0, card); return { position: index, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
let beforeBid: Game | undefined, bid: SemutaAdvisorGatherStep | undefined, bidAt = -1;
for (let count = 0; count < 900 && !semutaAdvisorGatherPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
const step = nextSemutaAdvisorGather(game); assert.ok(step);
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
}
act(step.actor, step.action);
}
assert.ok(beforeBid && bid && bidAt >= 0, 'CHOAM genuinely buys the original cache Semuta for one spice');
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
assert.equal(semutaAdvisorGatherPlayer(beforeBid, holder).spice - semutaAdvisorGatherPlayer(game, holder).spice, 1);
assert.equal(semutaAdvisorGatherPlayer(game, richese).spice - semutaAdvisorGatherPlayer(beforeBid, richese).spice, 1);
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && semutaAdvisorGatherClean(state));
const fundingAction: Action = { type: 'bribe', target: owner, amount: 1 };
const funding = transition(() => { act(emperor, fundingAction); });
const firstShipment = transition(() => {
act(owner, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 2, elite: 0, allyPayment: 0 });
reach(semutaAdvisorGatherClean);
});
assert.equal(semutaAdvisorGatherPlayer(firstShipment.before, owner).spice - semutaAdvisorGatherPlayer(game, owner).spice, 4);
reach(state => state.turn === 2 && state.phase === 5 && state.active === owner && semutaAdvisorGatherClean(state));
const beforeSecondShipment = structuredClone(game);
const secondShipment = transition(() => {
act(owner, { type: 'ship', territory: 'oh_gap', sector: 9, amount: 1, elite: 0, allyPayment: 0 });
reach(semutaAdvisorGatherClean);
});
assert.equal(semutaAdvisorGatherPlayer(secondShipment.before, owner).spice - semutaAdvisorGatherPlayer(game, owner).spice, 2);
const territory = 'arrakeen', location = 'arrakeen:10', sourceLocations = ['imperial_basin:9', 'oh_gap:9'];
const player = semutaAdvisorGatherPlayer(game, owner);
assert.equal(player.forces[sourceLocations[0]], 2); assert.equal(player.forces[sourceLocations[1]], 1);
assert.equal(isAdvisor(player, 'imperial_basin'), false); assert.equal(isAdvisor(player, 'oh_gap'), false);
assert.equal(player.forces[location], 1); assert.equal(isAdvisor(player, territory), true);
assert.equal(player.shipped, true); assert.equal(player.moved, 0); assert.equal(planetologistLeader(game, owner), trainer);
assert.deepEqual(sourceLocations.map(source => distance(source, location)), [1, 1]);
const forces = { 'imperial_basin:9': 1, 'oh_gap:9': 1 };
const nativeAction: Action = { type: 'move', planetologist: 'gather', forces, territory, sector: 10 };
const largerPacket: Action = { ...nativeAction, forces: { 'imperial_basin:9': 2, 'oh_gap:9': 1 } };
const replacement: Action = { type: 'move', from: 'imperial_basin:9', territory: 'oh_gap', sector: 9, amount: 1 };
const beforeDeclaration = structuredClone(game), beforePhysical = structuredClone(semutaAdvisorGatherPhysical(game));
const declaration = transition(() => { act(owner, nativeAction); });
const firstSource = structuredClone(game.response?.advisorGatherMatchingSource); assert.ok(firstSource);
assert.equal(firstSource.origin, 'imperial_basin'); assert.equal(game.pendingAdvisorGatherMatching?.receipts.length, 2);
assert.deepEqual(semutaAdvisorGatherPhysical(game), beforePhysical);
let firstAllowance: ReturnType<typeof transition> | null = null;
if (position === 'second') firstAllowance = transition(() => {
game = allowOneGatherSource(game, actions);
assert.deepEqual(semutaAdvisorGatherPhysical(game), beforePhysical);
});
const parent = game.pendingAdvisorGatherMatching, matching = game.response?.advisorGatherMatchingSource;
assert.ok(parent && matching); assert.equal(parent.index, position === 'first' ? 0 : 1);
assert.equal(parent.approved.length, parent.index);
assert.equal(matching.origin, position === 'first' ? 'imperial_basin' : 'oh_gap');
assert.equal(game.response?.kind, 'advisorFlip'); assert.equal(game.response?.owner, owner);
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
assert.equal(game.phaseOpening ?? null, null); assert.equal(game.decision ?? null, null);
assert.ok(semutaAdvisorGatherPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.ok(semutaAdvisorGatherPlayer(game, spender).hand.some(card => card.id === cost));
assert.deepEqual(semutaAdvisorGatherInventory(game), originalInventory);
assert.deepEqual(semutaAdvisorGatherSkillInventory(game), originalSkillInventory);
const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
return { game, source, beforeCost: source, position, kind: 'advisorGatherMatching' as const, advanced: true as const,
richese, holder, owner, bg: owner, spender, atreides: spender, emperor, cost, semuta: SEMUTA_DRUG_ID, action, nativeAction,
territory, resident: territory, location, destination: territory, destinationLocation: location, forces,
sourceLocations, sourceCounts: { 'imperial_basin:9': 2, 'oh_gap:9': 1 }, trainer, skill: 'planetologist' as const,
replacement, sameTypeReplacement: replacement, largerPacket,
originalResponse: structuredClone(game.response!), originalParent: structuredClone(parent),
originalSource: structuredClone(matching), originalGatherSource: structuredClone(matching),
originalPhysical: structuredClone(semutaAdvisorGatherPhysical(game)), originalHistory: structuredClone(game.log),
originalDenials: structuredClone(game.advisorMatchingDenied ?? []), expectedDenial: advisorMatchingKey(matching),
initial, lobby, setup, initialization, beforeStartingDeal, beforeSkillOrder, originalSkillOrder, skillOrder,
skillOffers, skillChoices, advisorSetup, afterSetup, dealOrder, spiceOrder, originalSpice,
acquired, acquisition, funding, fundingAction, firstShipment, beforeSecondShipment, secondShipment,
beforeDeclaration, declaration, firstSource, firstAllowance, originalInventory, originalSkillInventory,
actions, sourceActions: actions, sourceActionCount: actions.length,
staging: [
'Original explicit Advanced Semuta all14 Skills initializer acts on the ready undealt R/C/BG/A/E exact-CHOAM lobby. It creates the original Skill deck once; no preexisting Skill state is redealt and no Basic game is converted.',
'Reorder only the untouched original all14 Skill deck and unopened starting Treachery positions. Each of five actual two-card offers is really selected on a living eligible native disc; BG earns Planetologist and A is genuinely dealt printed Karama.',
'Actual advisorSetup places BG beside original A10 Arrakeen. Four safe original ordinary Spice positions are selected; actual first/second Storm, dual Spice, Charity, native auctions and Revival execute. CHOAM really buys original cache Semuta for one spice.',
'E actually bribes one spice. BG pays four for Basin2 in turn1 and two for OhGap1 in turn2; both groups remain fighters and are graph-distance1 from its original resident Arrakeen advisor. No played wallet, force, stance, trainer, clock or response is installed.',
position === 'second'
? 'Submit the real two-origin gather, then actually allow the first Basin matching counter. All physical counters, stances, payments and movement use remain unchanged; stop at the original second OhGap counter with one approved event.'
: 'Submit the real two-origin gather and stop at the original first Basin matching counter, before any physical transfer or movement use.',
'Fresh cost targets A original printed Karama. Denial keeps the whole move unused and permits the real same-type Basin→OhGap alternative; native allowance completes all remaining origin controls before one actual gather commit.',
],
};
}
export type SemutaAdvisorGatherFixture = ReturnType<typeof createSemutaAdvisorGatherFixture>;
@@ -0,0 +1,236 @@
import assert from 'node:assert/strict';
import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { isAdvisor } from '../game/advisors';
import { splitLocation, territory } from '../game/board';
import { guildShipmentCost } from '../game/shipment-price';
import { guildTransportQuote } from '../game/transport-quote';
import { junctionTransportChoice } from '../game/junction-transport-options';
import { advisorTransportMatchingKey } from '../game/advisor-transport-matching';
import type { HomeworldShipmentSources } from '../game/homeworld-shipment-options';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import {
actSemutaAdvisorMatching, nextSemutaAdvisorMatching, passSemutaAdvisorMatching, claimSemutaAdvisorMatching,
semutaAdvisorMatchingPlayer, semutaAdvisorMatchingInventory,
} from './fixture-semuta-advisor-matching';
export type SemutaAdvisorTransportMatchingKind = 'guild' | 'junction';
export type SemutaAdvisorTransportMatchingDirection = 'fightersToAdvisors' | 'advisorsToFighters';
export type SemutaAdvisorTransportMatchingStep = { actor: string; action: Action };
export const actSemutaAdvisorTransportMatching = actSemutaAdvisorMatching;
export const passSemutaAdvisorTransportMatching = passSemutaAdvisorMatching;
export const claimSemutaAdvisorTransportMatching = claimSemutaAdvisorMatching;
export const semutaAdvisorTransportMatchingPlayer = semutaAdvisorMatchingPlayer;
export const semutaAdvisorTransportMatchingInventory = semutaAdvisorMatchingInventory;
export const semutaAdvisorTransportMatchingClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingAdvisorTransportMatching &&
!game.pendingGuildTransport && !game.pendingHomeworldShipment && !game.pendingAutomaticAdvisorFlips?.length;
export const semutaAdvisorTransportMatchingPhysical = (game: Game) => ({ homeworlds: game.homeworlds,
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, elites: p.elites,
advisors: p.advisors, reserves: p.reserves, tanks: p.tanks, shipped: p.shipped, moved: p.moved, ally: p.ally })) });
const ownedResponse = (game: Game) => !!game.response && (game.response.kind === 'advisorFlip' ||
game.response.kind === 'guildTransportPermission' || game.response.kind === 'guildRate');
/** Keep matching, native permission/rate and every fresh group caller-owned.
* Guild actually waits; typed Homeworld interception uses its native reply. */
export function nextSemutaAdvisorTransportMatching(game: Game): SemutaAdvisorTransportMatchingStep | null {
if (game.pendingTreacheryDiscard || game.truthtrance || ownedResponse(game)) return null;
if (!game.response && !game.phaseOpening) {
if (game.decision?.kind === 'guildTiming')
return { actor: game.decision.player, action: { type: 'decision', take: false } };
if (game.decision?.kind === 'homeworldShipmentGuild')
return { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, allow: true } };
}
return nextSemutaAdvisorMatching(game);
}
export const nextSemutaAdvisorTransportMatchingStep = nextSemutaAdvisorTransportMatching;
export function advanceSemutaAdvisorTransportMatching(game: Game, until: (state: Game) => boolean,
actions: SemutaAdvisorTransportMatchingStep[] = []): Game {
for (let count = 0; count < 2400; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextSemutaAdvisorTransportMatching(game);
assert.ok(step, `Choose the original matching/permission/rate/fresh control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actSemutaAdvisorTransportMatching(game, step.actor, step.action, actions);
}
throw Error('The original Semuta/transport matching chronology did not reach its finite boundary');
}
function layer(game: Game) {
const response = game.response;
return response ? JSON.stringify([response.kind, response.advisorTransportMatchingSource?.event,
response.guildTransportEvent, response.guildRateEvent, response.automaticAdvisorFlip?.event]) : null;
}
/** Explicitly allow one original response only. Matching allowance can open
* the independently owned native permission/rate or payment continuation. */
export function allowSemutaAdvisorTransportMatching(game: Game, actions: SemutaAdvisorTransportMatchingStep[] = []): Game {
const original = layer(game); assert.ok(original);
for (let count = 0; layer(game) === original && count < game.players.length; count++) {
const response: NonNullable<Game['response']> = game.response!;
const actor = game.players.find(p => !response.passed.includes(p.id)); assert.ok(actor);
game = actSemutaAdvisorTransportMatching(game, actor.id, { type: 'passResponse' }, actions);
}
assert.notEqual(layer(game), original); return game;
}
/** Finish only original ordinary income/arrival controls. Matching and native
* permission/rate remain explicit; the same BG movement turn stays active. */
export function finishSemutaAdvisorTransportMatching(game: Game, actions: SemutaAdvisorTransportMatchingStep[] = []): Game {
assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the original fresh cost first');
return advanceSemutaAdvisorTransportMatching(game, semutaAdvisorTransportMatchingClean, actions);
}
/** Original Advanced R/C/BG/A/G with actual paid Semuta, original printed
* Karama and two physically earned BG groups. No played state is installed. */
export function createSemutaAdvisorTransportMatchingFixture(options: {
kind?: SemutaAdvisorTransportMatchingKind; direction?: SemutaAdvisorTransportMatchingDirection;
} = {}) {
const kind = options.kind ?? 'guild', direction = options.direction ?? 'fightersToAdvisors';
const seller = 'richese', holder = 'choam', owner = 'beneGesserit', spender = 'atreides', guild = 'guild';
const homeworlds = kind === 'junction';
let game = createGame('SEMUTAADVISORTRANSPORTMATCHING', newPlayer(seller, 'Richese', 'richese'), true, ['choam']);
for (const faction of [holder, owner, spender, guild] as const) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: SemutaAdvisorTransportMatchingStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaAdvisorTransportMatching(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorTransportMatching(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), at = actions.length; run();
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
};
const enableHomeworlds = homeworlds ? transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); }) : null;
for (const p of game.players) act(p.id, { type: 'ready' });
const lobby = structuredClone(game);
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
entry: homeworlds ? 'original-undealt-semuta-homeworld-preview' : 'original-undealt-advanced-semuta-preview' };
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
assert.equal(!!game.homeworlds, homeworlds); assert.ok(game.players.every(p => !p.hand.length));
assert.ok(!game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
!game.discoveryEnabled && !game.ecazTreachery);
const originalInventory = semutaAdvisorTransportMatchingInventory(game); assert.equal(originalInventory.length, 45);
const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed);
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 4);
assert.equal(ordinary.length, 4);
const selected = [ordinary[0], ordinary[1], ordinary[2], printed, ordinary[3]], cost = printed.id;
const dealOrder = selected.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
const beforeStartingDeal = structuredClone(game);
let advisorSetup: { before: Game; steps: SemutaAdvisorTransportMatchingStep[]; after: Game } | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const step = nextSemutaAdvisorTransportMatching(game); assert.ok(step);
const before = step.action.type === 'advisorSetup' ? structuredClone(game) : null;
act(step.actor, step.action);
if (before) advisorSetup = { before, steps: [structuredClone(step)], after: structuredClone(game) };
}
assert.equal(game.status, 'playing'); assert.ok(advisorSetup);
const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
assert.equal(semutaAdvisorTransportMatchingPlayer(game, owner).forces['arrakeen:10'], 1);
assert.equal(isAdvisor(semutaAdvisorTransportMatchingPlayer(game, owner), 'arrakeen'), true);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder: { turn: number; position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
const orderLand = (position: number) => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
!['imperial_basin', 'arrakeen', 'tueks_sietch'].includes(card.territory));
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
spiceOrder.push({ turn: game.turn, position, from, card: structuredClone(card) });
};
for (let position = 0; position < 4; position++) orderLand(position);
let beforeBid: Game | undefined, bid: SemutaAdvisorTransportMatchingStep | undefined, bidAt = -1;
for (let count = 0; count < 900 && !semutaAdvisorTransportMatchingPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
const step = nextSemutaAdvisorTransportMatching(game); assert.ok(step);
if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) {
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
}
act(step.actor, step.action);
}
assert.ok(beforeBid && bid && bidAt >= 0);
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
assert.equal(semutaAdvisorTransportMatchingPlayer(beforeBid, holder).spice - semutaAdvisorTransportMatchingPlayer(game, holder).spice, 1);
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && semutaAdvisorTransportMatchingClean(state));
const fighterShipment = transition(() => {
act(owner, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 2, elite: 0, allyPayment: 0 });
reach(semutaAdvisorTransportMatchingClean);
});
assert.equal(semutaAdvisorTransportMatchingPlayer(fighterShipment.before, owner).spice - semutaAdvisorTransportMatchingPlayer(game, owner).spice, 4);
assert.equal(semutaAdvisorTransportMatchingPlayer(game, owner).forces['imperial_basin:9'], 2);
let beforeWorm: Game | null = null, nexus: Game | null = null;
let wormOrder: { position: number; from: number; card: Game['spiceDeck'][number] } | null = null;
let alliance: ReturnType<typeof transition> | null = null;
if (kind === 'guild') {
reach(state => state.turn === 2 && state.phase === 1 && semutaAdvisorTransportMatchingClean(state) &&
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
beforeWorm = structuredClone(game);
const from = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
assert.ok(from >= 0); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(card);
wormOrder = { position: 0, from, card: structuredClone(card) }; orderLand(1); orderLand(2);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && semutaAdvisorTransportMatchingClean(state));
nexus = structuredClone(game);
alliance = transition(() => { act(owner, { type: 'alliance', target: guild }); act(guild, { type: 'alliance', target: owner }); });
assert.equal(semutaAdvisorTransportMatchingPlayer(game, owner).ally, guild);
assert.equal(semutaAdvisorTransportMatchingPlayer(game, guild).ally, owner);
}
reach(state => state.turn === 2 && state.phase === 5 && state.active === owner && semutaAdvisorTransportMatchingClean(state));
let junctionOffer: ReturnType<typeof transition> | null = null, offerStep: SemutaAdvisorTransportMatchingStep | null = null;
if (kind === 'junction') {
const sponsor = viewGame(game, guild).junctionTransport; assert.ok(sponsor?.canOffer); assert.equal(sponsor.recipient, owner);
offerStep = { actor: guild, action: { type: 'offerJunctionTransport', event: sponsor.offerEvent, rate: 'half' } };
junctionOffer = transition(() => { act(offerStep!.actor, offerStep!.action); });
}
const sourceLocation = direction === 'fightersToAdvisors' ? 'imperial_basin:9' : 'arrakeen:10';
const destinationLocation = direction === 'fightersToAdvisors' ? 'arrakeen:10' : 'imperial_basin:9';
const replacementLocation = direction === 'fightersToAdvisors' ? 'polar_sink:0' : 'tueks_sietch:5';
const declarationFor = (to: string, amount: number) => {
const sources: HomeworldShipmentSources = { [sourceLocation]: { normal: amount, elite: 0 } };
const point = splitLocation(to);
if (kind === 'guild') {
const action: Action = { type: 'guildShip', from: sourceLocation, territory: point.territory,
sector: point.sector, amount, elite: 0, allyPayment: 0 };
const quote = guildTransportQuote(viewGame(game, owner), action); assert.deepEqual(quote.unavailableReasons, []);
return { action, sources, price: guildShipmentCost(territory(point.territory).type, amount, true) };
}
const quote = junctionTransportChoice(viewGame(game, owner), to, sources, 0); assert.equal(quote.blocked, null); assert.ok(quote.action);
return { action: quote.action, sources, price: quote.cost };
};
const transportQuote = declarationFor(destinationLocation, 1), replacementQuote = declarationFor(replacementLocation, 1);
const largerQuote = direction === 'fightersToAdvisors' ? declarationFor(destinationLocation, 2) : null;
const beforeDeclaration = structuredClone(game), nativeAction = transportQuote.action;
assert.equal(semutaAdvisorTransportMatchingPlayer(game, owner).shipped, false);
const declaration = transition(() => { act(owner, nativeAction); });
assert.ok(game.response?.advisorTransportMatchingSource && game.pendingAdvisorTransportMatching);
assert.equal(game.response.kind, 'advisorFlip'); assert.equal(game.pendingAdvisorTransportMatching.intent.kind, kind);
assert.equal(game.pendingGuildTransport ?? null, null); assert.equal(game.pendingHomeworldShipment ?? null, null);
assert.deepEqual(semutaAdvisorTransportMatchingPhysical(game), semutaAdvisorTransportMatchingPhysical(beforeDeclaration));
assert.ok(semutaAdvisorTransportMatchingPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.ok(semutaAdvisorTransportMatchingPlayer(game, spender).hand.some(card => card.id === cost));
const source = structuredClone(game), originalResponse = structuredClone(game.response);
const originalParent = structuredClone(game.pendingAdvisorTransportMatching), originalSource = structuredClone(game.response.advisorTransportMatchingSource);
const opportunity = originalParent.opportunity, denialKey = advisorTransportMatchingKey(originalSource, opportunity);
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
assert.deepEqual(semutaAdvisorTransportMatchingInventory(game), originalInventory);
return { game, source, beforeCost: source, advanced: true as const, kind, direction, owner, bg: owner, recipient: owner,
seller, richese: seller, holder, spender, atreides: spender, guild, actor: owner, shipper: owner,
cost, karama: cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, originalResponse, originalParent, originalPending: originalParent,
originalSource, receipt: originalSource, opportunity, denialKey, originalDenied: structuredClone(game.advisorTransportMatchingDenied ?? []),
originalPhysical: structuredClone(semutaAdvisorTransportMatchingPhysical(game)), originalOffer: structuredClone(game.junctionOffer ?? null),
sourceLocation, destinationLocation, origin: splitLocation(sourceLocation).territory, destination: splitLocation(destinationLocation).territory,
replacementLocation, replacementActor: owner, replacementAction: replacementQuote.action, replacementQuote,
noMatch: replacementQuote.action, largerPacket: largerQuote?.action ?? null, largerQuote,
amount: 1, price: transportQuote.price, transportCost: transportQuote.price, transportQuote,
initial, enableHomeworlds, lobby, setup, initialization, beforeStartingDeal, afterSetup, advisorSetup,
deal: selected.map(card => card.id), dealOrder, originalInventory, originalSpice, spiceOrder,
acquisition, acquired, fighterShipment, beforeWorm, wormOrder, nexus, alliance, junctionOffer, offerStep, beforeDeclaration, declaration,
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
staging: [
'Original Advanced R/C/BG/A/G exact-CHOAM Semuta initialization on a genuinely ready undealt lobby; only original unopened starting-card/Spice positions and scalar lotteries are selected. Atreides really receives printed Karama and CHOAM really buys cache1 Semuta. No played hand, wallet, force, stance, clock, alliance, grant or pending receipt is installed.',
'BG actually places its original advisor at Arrakeen10 beside A10, declines preparation and pays four to ship two original reserves to empty Basin9 on turn1. Actual native Guild timing/interception/income, Collection, Mentat, next Storm and phase controls execute. Both original groups remain for BG’s unused turn2 shipment.',
kind === 'guild'
? 'One original unopened turn2 Worm and safe original follow-up lands produce a real Nexus. BG actually proposes Guild alliance and Guild accepts; the original reciprocal alliance earns native board cross-shipment. No Nexus Cards module or Secret Ally is selected.'
: 'Homeworlds are enabled by their real original lobby action. Original typed custody stays native; high Guild really offers its current half-price Junction event to active BG. This source has no alliance and no other optional module.',
'Declare the original one-counter opposite-type transport and stop at its native matching response, before payment, withdrawal/transfer, permission or rate. Denial keeps the original opportunity unused; expose a real empty Polar destination for fighters, or Guild-occupied Tueks for original advisors. Board groups keep their source stance unless matching resident BG; the genuine larger two-fighter packet remains available separately.',
],
};
}
export type SemutaAdvisorTransportMatchingFixture = ReturnType<typeof createSemutaAdvisorTransportMatchingFixture>;
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { isAdvisor } from '../game/advisors';
import { guildTransportQuote } from '../game/transport-quote';
import {
createSemutaAdvisorTransportMatchingFixture as fixture,
actSemutaAdvisorTransportMatching as act, allowSemutaAdvisorTransportMatching as allow,
finishSemutaAdvisorTransportMatching as finish, semutaAdvisorTransportMatchingPlayer as player,
semutaAdvisorTransportMatchingInventory as inventory,
} from './fixture-semuta-advisor-transport-matching';
const advisorEntry: Action = {type:'guildShip',from:'arrakeen:10',territory:'tueks_sietch',
sector:5,amount:1,elite:0,allyPayment:0};
function complete(game: Game) {
for (let count=0;game.response && ['guildTransportPermission','guildRate'].includes(game.response.kind) && count<3;count++)
game=allow(game);
return finish(game);
}
function reject(game: Game, actor: string, action: Action) {
const before=structuredClone(game);assert.throws(()=>applyAction(game,actor,action));assert.deepEqual(game,before);
}
function source() {
const f=fixture({kind:'guild'}),game=structuredClone(f.beforeDeclaration);
assert.equal(game.pendingAdvisorTransportMatching,undefined);
assert.ok((player(game,f.guild).forces['tueks_sietch:5']??0)>0);
assert.equal(player(game,f.owner).ally,f.guild);assert.equal(player(game,f.guild).ally,f.owner);
assert.equal(isAdvisor(player(game,f.owner),'arrakeen'),true);
assert.equal(player(game,f.owner).forces['arrakeen:10'],1);
return {f,game};
}
void test('fresh Guild board transport quotes and delivers the actual advisor into its allied foreign group',()=>{
const {f,game:before}=source();
assert.deepEqual(guildTransportQuote(viewGame(before,f.owner),advisorEntry).unavailableReasons,[]);
let game=act(before,f.owner,advisorEntry);
assert.equal(game.response?.kind,'guildTransportPermission');assert.equal(game.pendingAdvisorTransportMatching,undefined);
assert.equal(game.pendingGuildTransport?.advisorArrivalSource,'arrakeen');assert.equal(game.pendingGuildTransport?.advisors,true);
assert.deepEqual(player(game,f.owner).forces,player(before,f.owner).forces);
game=complete(game);
assert.equal(player(game,f.owner).forces['arrakeen:10']??0,0);
assert.equal(player(game,f.owner).forces['tueks_sietch:5'],1);assert.equal(isAdvisor(player(game,f.owner),'tueks_sietch'),true);
assert.deepEqual(player(game,f.guild).forces,player(before,f.guild).forces);
assert.equal(player(game,f.owner).spice,player(before,f.owner).spice-1);
assert.equal(player(game,f.guild).spice,player(before,f.guild).spice+1);
assert.equal(player(game,f.owner).shipped,true);assert.equal(player(game,f.owner).moved,0);
assert.equal(game.pendingAutomaticAdvisorFlips,undefined);assert.deepEqual(inventory(game),f.originalInventory);
reject(game,f.owner,advisorEntry);
});
void test('the source-stance cutover blocks fighter cross-entry and preserves the direct reserve default',()=>{
const {f,game}=source();
const fighter:Action={...advisorEntry,from:'imperial_basin:9'};
assert.ok(guildTransportQuote(viewGame(game,f.owner),fighter).unavailableReasons.length>0);
reject(game,f.owner,fighter);
const reserve=act(game,f.owner,{type:'ship',territory:'tueks_sietch',sector:5,amount:1,elite:0,allyPayment:0});
assert.ok(reserve.pendingShipment);assert.equal(reserve.pendingShipment.advisors,false);
assert.equal('advisorArrivalSource' in reserve.pendingShipment,false);
});
void test('a fresh Guild advisor declaration rejects a changed source before payment or transfer',()=>{
const {f,game:before}=source(),game=act(before,f.owner,advisorEntry);
assert.ok(game.pendingGuildTransport);game.pendingGuildTransport.advisorArrivalSource='polar_sink';
reject(game,f.holder,{type:'passResponse'});
});
void test('controlled legacy Guild permission bytes retain the original already-approved matching settlement',()=>{
const f=fixture({kind:'guild'});let game=allow(structuredClone(f.game));
assert.equal(game.response?.kind,'guildTransportPermission');const intent=game.pendingGuildTransport;assert.ok(intent);
// Explicit format supplement to a genuine approved prefix. This is not a
// historical saved-game capture and changes no physical source or approval.
delete intent.advisorArrivalSource;
const fields:unknown[]=[intent.event,intent.turn,intent.player,intent.fromReserves,intent.group,
intent.eliteGroup,intent.origin,intent.to,intent.sector,intent.amount,intent.elite,
intent.cost,intent.allyPayment,intent.advisors];
if(intent.permission!==undefined)fields.push(intent.permission);
intent.signature=JSON.stringify(fields);
game=complete(game);
assert.equal(player(game,f.owner).forces['imperial_basin:9'],1);
assert.equal(player(game,f.owner).forces['arrakeen:10'],2);assert.equal(isAdvisor(player(game,f.owner),'arrakeen'),true);
assert.equal(player(game,f.owner).shipped,true);assert.equal(player(game,f.owner).moved,0);
assert.deepEqual(inventory(game),f.originalInventory);assert.equal(game.pendingGuildTransport??null,null);
});
+3 -2
View File
@@ -238,11 +238,12 @@ void test('source storm, destination geometry, ally occupancy and stronghold cap
unavailable(fixture(), cross({ territory: MOBILE_STRONGHOLD, sector: 0 }));
});
void test('new Bene Gesserit advisor source locks forbid fighter arrival but existing destination advisors preserve the lock', () => {
void test('fresh Guild transport retains advisor source locks at foreign arrivals and forbids matching into resident fighters', () => {
const g = fixture('beneGesserit');
g.players[0].advisors = { imperial_basin: { lockedTurn: g.turn } };
army(g, 2, { 'carthag:11': 1, 'imperial_basin:10': 1 });
unavailable(g, cross());
const foreign = enabled(g, cross(), 2);
assert.equal(foreign.players[0].advisors!.carthag.lockedTurn, g.turn);
army(g, 0, { ...g.players[0].forces, 'carthag:11': 1 });
g.players[0].advisors.carthag = {};
const after = enabled(g, cross(), 2);
@@ -0,0 +1,188 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { isAdvisor } from '../game/advisors';
import { advisorAmbassadorMatchingKey } from '../game/advisor-ambassador-matching';
import {
createSemutaAdvisorAmbassadorMatchingFixture as fixture, actSemutaAdvisorAmbassadorMatching as act,
passSemutaAdvisorAmbassadorMatching as pass, claimSemutaAdvisorAmbassadorMatching as claim,
allowSemutaAdvisorAmbassadorMatching as allow, finishSemutaAdvisorAmbassadorMatching as finish,
semutaAdvisorAmbassadorMatchingPlayer as player, semutaAdvisorAmbassadorMatchingInventory as inventory,
semutaAdvisorAmbassadorMatchingPhysical as physical, type SemutaAdvisorAmbassadorMatchingFixture,
} from './fixture-semuta-advisor-ambassador-matching';
const variants = [{ direction: 'fightersToAdvisors' as const }, { direction: 'advisorsToFighters' as const }];
const allowances = (game: Game) => game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves,
tanks: p.tanks, elites: p.elites, moved: p.moved, shipped: p.shipped, ally: p.ally, allySinceTurn: p.allySinceTurn }));
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function nativeSource(f: SemutaAdvisorAmbassadorMatchingFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
assert.deepEqual(f.setup.expansions, ['choam', 'ecaz']); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'ecaz', 'beneGesserit', 'atreides', 'emperor']);
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
assert.equal(f.packet.scalars.length, 9); assert.equal(f.packetCreation.steps.length, 1);
assert.equal(f.packetCreation.steps[0].action.type, 'traitor');
assert.equal(f.advisorSetup.steps[0].action.type, 'advisorSetup');
assert.equal(player(f.afterSetup, f.owner).forces['arrakeen:10'], 1);
assert.equal(player(f.afterSetup, f.entrant).forces['arrakeen:10'], 10);
assert.equal(isAdvisor(player(f.afterSetup, f.owner), 'arrakeen'), true);
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
assert.equal(player(f.placement.before, f.ecaz).spice - player(f.placement.after, f.ecaz).spice, 1);
assert.ok(f.placement.steps.some(step => step.actor === f.ecaz && step.action.type === 'decision' && step.action.token === f.token));
assert.equal(player(f.fighterShipment.before, f.owner).spice - player(f.fighterShipment.after, f.owner).spice, 2);
assert.equal(player(f.fighterShipment.after, f.owner).forces['tueks_sietch:5'], 2);
assert.equal(isAdvisor(player(f.fighterShipment.after, f.owner), 'tueks_sietch'), false);
assert.equal(f.alliance.before.turn, 2); assert.equal(f.alliance.before.phase, 1); assert.equal(f.alliance.before.nexus, true);
assert.equal(f.alliance.steps.length, 2); assert.ok(f.alliance.steps.every(step => step.action.type === 'alliance'));
assert.equal(player(f.game, f.owner).ally, f.ecaz); assert.equal(player(f.game, f.ecaz).ally, f.owner);
assert.ok('worm' in f.wormOrder.card);
assert.equal(player(f.entrantShipment.before, f.entrant).spice - player(f.entrantShipment.after, f.entrant).spice, 1);
assert.equal(player(f.entrantShipment.after, f.entrant).forces['carthag:11'], 1);
assert.equal(f.trigger.before.pendingAmbassador?.stage, 'offer'); assert.equal(f.trigger.after.pendingAmbassador?.stage, 'move');
assert.equal(f.originalAmbassador.event, f.event); assert.equal(f.originalAmbassador.beneficiary, f.owner);
assert.equal(f.originalAmbassador.effect, 'fremen'); assert.equal(f.originalAmbassador.entrant, f.entrant);
assert.equal(f.game.ecazAmbassadors!.tokens.find(t => t.id === f.token)?.zone, 'used');
assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.relocationAction }]);
assert.deepEqual(physical(f.declaration.before), f.originalPhysical, 'The selected packet has not relocated before matching');
assert.equal(f.game.active, f.entrant); assert.notEqual(f.game.active, f.owner);
assert.deepEqual(Object.keys(f.originalSource).sort(), ['advisors', 'destination', 'event', 'group', 'origin', 'owner', 'parent', 'phase', 'sector', 'turn']);
assert.equal(f.originalSource.owner, f.owner); assert.equal(f.originalSource.parent, f.event);
assert.equal(f.originalSource.advisors, f.direction === 'fightersToAdvisors');
assert.deepEqual(f.originalParent.receipt, f.originalSource);
assert.equal(f.originalParent.parentSignature, JSON.stringify(f.originalAmbassador));
assert.deepEqual(f.game.response, f.originalResponse); assert.equal(f.game.phase, 5); assert.equal(f.game.turn, 2);
assert.equal(f.game.pendingTreacheryDiscard ?? null, null); assert.equal(f.originalInventory.length, 45);
assert.deepEqual(inventory(f.game), f.originalInventory); assert.equal(f.sourceActionCount, f.actions.length);
}
function open(f: SemutaAdvisorAmbassadorMatchingFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard');
if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Use the original precommit Ambassador matching cost');
assert.equal(pending.batch.cause, 'karama:advisorFlip'); assert.equal(pending.reaction?.stage, 'offer');
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
[{ card: f.cost, by: f.spender, face: true }]);
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
assert.deepEqual(game.pendingAdvisorAmbassadorMatching, f.originalParent);
assert.deepEqual(game.pendingAmbassador, f.originalAmbassador); assert.deepEqual(game.ecazAmbassadors, f.originalTokens);
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(player(game, f.owner).advisors, f.originalStance);
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
assert.deepEqual(inventory(game), f.originalInventory); return game;
}
function canceled(game: Game, f: SemutaAdvisorAmbassadorMatchingFixture, claimed: boolean) {
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingAdvisorAmbassadorMatching, undefined);
assert.equal(game.response ?? null, null); assert.equal(game.decision?.kind, 'ecazAmbassador'); assert.equal(game.decision?.player, f.owner);
assert.deepEqual(game.pendingAmbassador, { ...f.originalAmbassador,
advisorMatchingDenied: [advisorAmbassadorMatchingKey(f.originalSource)] });
assert.deepEqual(game.ecazAmbassadors, f.originalTokens); assert.deepEqual(physical(game), f.originalPhysical);
assert.deepEqual(player(game, f.owner).advisors, f.originalStance);
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
reject(game, f.owner, f.relocationAction); reject(game, f.owner, { ...f.relocationAction, fighters: true });
reject(game, f.spender, f.action); assert.deepEqual(inventory(game), f.originalInventory); return game;
}
function alternate(game: Game, f: SemutaAdvisorAmbassadorMatchingFixture) {
const steps: { actor: string; action: Action }[] = [];
game = act(game, f.owner, f.alternate, steps); game = finish(game, steps);
assert.equal(game.pendingAmbassador ?? null, null); assert.equal(game.pendingAdvisorAmbassadorMatching, undefined);
assert.equal(player(game, f.owner).forces['tueks_sietch:5'], 1); assert.equal(player(game, f.owner).forces['polar_sink:0'], 1);
assert.equal(player(game, f.owner).forces['arrakeen:10'], 1); assert.equal(isAdvisor(player(game, f.owner), 'arrakeen'), true);
assert.equal(isAdvisor(player(game, f.owner), 'polar_sink'), false);
assert.deepEqual(allowances(game), allowances(f.game)); assert.deepEqual(game.ecazAmbassadors, f.originalTokens);
for (const row of physical(game).filter(p => p.id !== f.owner))
assert.deepEqual(row, f.originalPhysical.find(p => p.id === row.id));
assert.equal(game.log.filter(row => row.text.includes('through the Fremen Ambassador. Their ordinary')).length, 1);
assert.ok(steps.every(step => !['ship', 'move', 'card', 'endMovement'].includes(step.action.type)));
reject(game, f.owner, f.alternate); assert.deepEqual(inventory(game), f.originalInventory); return game;
}
for (const variant of variants) for (const claimed of [false, true]) void test(
`Advanced native Fremen Ambassador ${variant.direction} matching cost Semuta ${claimed ? 'claim' : 'pass'} keeps its consumed trigger and permits one real legal alternate`, () => {
const f = fixture(variant); nativeSource(f); const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
const game = canceled(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
alternate(game, f);
},
);
void test('native Fremen Ambassador matching allowance relocates only the selected incoming packet and preserves resident type and ordinary allowances', () => {
for (const variant of variants) {
const f = fixture(variant); nativeSource(f); const game = finish(allow(structuredClone(f.game)));
assert.equal(game.pendingAmbassador ?? null, null); assert.equal(game.pendingAdvisorAmbassadorMatching, undefined);
assert.equal(player(game, f.owner).forces[f.from] ?? 0, player(f.game, f.owner).forces[f.from] - 1);
assert.equal(player(game, f.owner).forces[f.to], player(f.game, f.owner).forces[f.to] + 1);
assert.equal(isAdvisor(player(game, f.owner), f.originalSource.destination), f.direction === 'fightersToAdvisors');
assert.deepEqual(allowances(game), allowances(f.game)); assert.deepEqual(game.ecazAmbassadors, f.originalTokens);
for (const row of physical(game).filter(p => p.id !== f.owner))
assert.deepEqual(row, f.originalPhysical.find(p => p.id === row.id));
assert.equal(game.log.filter(row => row.text.includes('through the Fremen Ambassador. Their ordinary')).length, 1);
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
assert.deepEqual(inventory(game), f.originalInventory); reject(game, f.owner, f.relocationAction);
}
});
void test('fresh Fremen Ambassador matching cost rejects stale and underlying controls before any physical relocation', () => {
for (const variant of variants) {
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.owner, f.relocationAction);
reject(game, f.owner, f.alternate); reject(game, f.entrant, { type: 'endMovement' });
assert.deepEqual(game.pendingAdvisorAmbassadorMatching, f.originalParent);
assert.deepEqual(game.pendingAmbassador, f.originalAmbassador); assert.deepEqual(physical(game), f.originalPhysical);
canceled(pass(game), f, false);
}
});
void test('controlled Fremen matching parent and source mismatches reject before cost and fresh Semuta appropriation', () => {
const f = fixture();
// Separate negative copies; the primary programme never installs a parent,
// receipt, army, stance or source event.
const changes: ((game: Game) => void)[] = [
game => { game.pendingAdvisorAmbassadorMatching!.signature += ':changed'; },
game => { game.pendingAdvisorAmbassadorMatching!.parentSignature += ':changed'; },
game => { game.pendingAdvisorAmbassadorMatching!.receipt.parent = `${f.event}:stale`; },
];
for (const change of changes) {
const beforeCost = structuredClone(f.game); change(beforeCost); reject(beforeCost, f.spender, f.action);
assert.ok(player(beforeCost, f.spender).hand.some(card => card.id === f.cost));
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; change(offered);
reject(offered, f.holder, { type: 'semutaCommit', event });
assert.ok(player(offered, f.holder).hand.some(card => card.id === f.semuta));
assert.equal(offered.discard.filter(card => card.id === f.cost).length, 1);
}
});
void test('all four minimal profiles legally answer both native Ambassador matching used costs and propose an actual legal alternate', () => {
for (const variant of variants) {
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
assert.deepEqual(own, before); assert.ok(action.type === 'semutaPass' || action.type === 'semutaCommit'); assert.equal(action.event, event);
let game = act(structuredClone(offered), f.holder, action); const claimed = action.type === 'semutaCommit';
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
const selected = botActions(selecting)[0]; assert.ok(selected);
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost); game = act(game, f.holder, selected);
} else if (game.pendingTreacheryDiscard) game = pass(game);
game = canceled(game, f, claimed);
const beneficiary = viewGame(game, f.owner); beneficiary.players.find(p => p.id === f.owner)!.bot = difficulty;
const proposed = botActions(beneficiary)[0]; assert.ok(proposed); assert.equal(proposed.type, 'decision');
assert.doesNotThrow(() => applyAction(game, f.owner, proposed));
}
}
});
+190
View File
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { isAdvisor } from '../game/advisors';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createSemutaAdvisorGatherFixture as fixture, actSemutaAdvisorGather as act,
passSemutaAdvisorGather as pass, claimSemutaAdvisorGather as claim,
allowSemutaAdvisorGather as allow, finishSemutaAdvisorGather as finish,
semutaAdvisorGatherPlayer as player, semutaAdvisorGatherInventory as inventory,
semutaAdvisorGatherPhysical as physical, semutaAdvisorGatherSkillInventory as skills,
type SemutaAdvisorGatherFixture,
} from './fixture-semuta-advisor-gather';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function nativeSource(f: SemutaAdvisorGatherFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
assert.deepEqual(f.setup.expansions, ['choam']); assert.ok(f.setup.leaderSkills);
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'beneGesserit', 'atreides', 'emperor']);
assert.equal(f.skillChoices.length, 5); assert.ok(f.skillOffers && f.advisorSetup);
assert.ok(f.skillChoices.some(choice => choice.steps.some(step => step.actor === f.owner && step.action.type === 'leaderSkill' &&
step.action.skill === f.skill && step.action.leader === f.trainer)));
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
assert.equal(player(f.afterSetup, f.owner).forces[f.location], 1);
assert.equal(player(f.afterSetup, f.spender).forces[f.location], 10);
assert.equal(isAdvisor(player(f.afterSetup, f.owner), f.territory), true);
assert.equal(f.acquisition.price, 1);
assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1);
assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1);
assert.deepEqual(f.funding.steps, [{ actor: f.emperor, action: f.fundingAction }]);
assert.equal(player(f.firstShipment.before, f.owner).spice - player(f.firstShipment.after, f.owner).spice, 4);
assert.equal(player(f.secondShipment.before, f.owner).spice - player(f.secondShipment.after, f.owner).spice, 2);
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.owner);
assert.equal(player(f.game, f.owner).shipped, true); assert.equal(player(f.game, f.owner).moved, 0);
assert.equal(player(f.game, f.owner).forces['imperial_basin:9'], 2);
assert.equal(player(f.game, f.owner).forces['oh_gap:9'], 1);
assert.equal(isAdvisor(player(f.game, f.owner), 'imperial_basin'), false);
assert.equal(isAdvisor(player(f.game, f.owner), 'oh_gap'), false);
assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.nativeAction }]);
assert.deepEqual(f.originalParent.receipts.map(source => source.origin), ['imperial_basin', 'oh_gap']);
assert.equal(f.originalParent.index, f.position === 'first' ? 0 : 1);
assert.deepEqual(f.originalParent.approved, f.position === 'first' ? [] : [f.firstSource.event]);
assert.equal(f.originalSource.origin, f.position === 'first' ? 'imperial_basin' : 'oh_gap');
assert.deepEqual(f.originalParent.receipts[f.originalParent.index], f.originalSource);
assert.deepEqual(f.game.response, f.originalResponse);
assert.equal(f.originalResponse.advisorGatherMatchingPosition?.current, f.position === 'first' ? 1 : 2);
assert.equal(f.originalResponse.advisorGatherMatchingPosition?.total, 2);
if (f.position === 'second') {
assert.ok(f.firstAllowance);
const response = f.firstAllowance.before.response; assert.ok(response);
const passers = f.firstAllowance.steps.map(step => step.actor);
assert.ok(passers.length > 0); assert.equal(new Set(passers).size, passers.length);
assert.ok(passers.every(actor => f.firstAllowance!.before.players.some(player => player.id === actor)));
assert.ok(f.firstAllowance.steps.every(step => step.action.type === 'passResponse'));
assert.deepEqual(physical(f.firstAllowance.before), physical(f.firstAllowance.after));
} else assert.equal(f.firstAllowance, null);
assert.deepEqual(physical(f.game), f.originalPhysical);
assert.deepEqual(physical(f.beforeDeclaration), f.originalPhysical);
assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.deepEqual(skills(f.game), f.originalSkillInventory);
}
function open(f: SemutaAdvisorGatherFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard');
assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, 'karama:advisorFlip');
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
[{ card: f.cost, by: f.spender, face: true }]);
if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Use the original native advisor-flip cost');
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
assert.deepEqual(game.pendingAdvisorGatherMatching, f.originalParent);
assert.deepEqual(physical(game), f.originalPhysical);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(skills(game), f.originalSkillInventory); return game;
}
function prevented(game: Game, f: SemutaAdvisorGatherFixture, claimed: boolean) {
game = finish(game);
assert.equal(game.pendingAdvisorGatherMatching, undefined);
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
assert.deepEqual(physical(game), f.originalPhysical);
assert.deepEqual(game.advisorMatchingDenied, [...f.originalDenials, f.expectedDenial]);
assert.equal(player(game, f.owner).advisorFlipMovementBlocked, undefined);
assert.equal(game.log.filter(row => row.automatic?.name === 'Gathered group matching prevented').length,
f.originalHistory.filter(row => row.automatic?.name === 'Gathered group matching prevented').length + 1);
reject(game, f.owner, f.nativeAction);
reject(game, f.owner, { ...f.nativeAction, forces: Object.fromEntries(Object.entries(f.forces).reverse()) });
reject(game, f.owner, { type: 'move', forces: f.originalSource.group, territory: f.territory, sector: 10 });
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(skills(game), f.originalSkillInventory); return game;
}
function replaceOriginalMove(game: Game, f: SemutaAdvisorGatherFixture) {
const before = structuredClone(physical(game)), wallet = player(game, f.owner).spice;
const result = finish(act(game, f.owner, f.replacement));
assert.equal(player(result, f.owner).moved, 1); assert.equal(player(result, f.owner).shipped, true);
assert.equal(player(result, f.owner).spice, wallet);
assert.equal(player(result, f.owner).forces['imperial_basin:9'], 1);
assert.equal(player(result, f.owner).forces['oh_gap:9'], 2);
assert.equal(player(result, f.owner).forces[f.location], 1);
assert.equal(isAdvisor(player(result, f.owner), f.territory), true);
assert.deepEqual(result.players.filter(player => player.id !== f.owner).map(player =>
({ id: player.id, spice: player.spice, forces: player.forces, reserves: player.reserves })),
before.players.filter(player => player.id !== f.owner).map(player =>
({ id: player.id, spice: player.spice, forces: player.forces, reserves: player.reserves })));
assert.deepEqual(inventory(result), f.originalInventory); assert.deepEqual(skills(result), f.originalSkillInventory); return result;
}
for (const position of ['first', 'second'] as const) for (const claimed of [false, true]) void test(
`Advanced native ${position} gather matching used cancellation cost ${claimed ? 'claim' : 'pass'} preserves both origins and permits one legal replacement`, () => {
const f = fixture({ position }); nativeSource(f);
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
const game = prevented(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event });
replaceOriginalMove(game, f);
});
for (const position of ['first', 'second'] as const) void test(
`Advanced native ${position} gather matching allowance commits the original two-origin packet once after every group approval`, () => {
const f = fixture({ position }); nativeSource(f);
const game = allow(structuredClone(f.game));
assert.equal(game.pendingAdvisorGatherMatching, undefined); assert.equal(player(game, f.owner).moved, 1);
assert.equal(player(game, f.owner).forces[f.location], 3);
assert.equal(player(game, f.owner).forces['imperial_basin:9'], 1);
assert.equal(player(game, f.owner).forces['oh_gap:9'] ?? 0, 0);
assert.equal(isAdvisor(player(game, f.owner), f.territory), true);
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice);
assert.equal(player(game, f.owner).shipped, true);
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(skills(game), f.originalSkillInventory);
const before = structuredClone(game); assert.equal(finish(game), game); assert.deepEqual(game, before);
});
for (const position of ['first', 'second'] as const) void test(
`fresh ${position} gather matching costs reject stale and underlying controls while original approvals stay parked`, () => {
const f = fixture({ position }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
reject(game, f.spender, { type: 'semutaCommit', event });
reject(game, f.spender, f.action); reject(game, f.owner, { type: 'passResponse' });
reject(game, f.owner, f.nativeAction); reject(game, f.owner, f.replacement);
reject(game, f.owner, { type: 'endMovement' });
assert.deepEqual(game.pendingAdvisorGatherMatching, f.originalParent);
assert.deepEqual(physical(game), f.originalPhysical); prevented(pass(game), f, false);
});
void test('unearned gather approval or changed original signature rejects before cost and before a fresh Semuta answer', () => {
const f = fixture({ position: 'second' });
for (const change of ['signature', 'approval'] as const) {
const game = structuredClone(f.game); assert.ok(game.pendingAdvisorGatherMatching);
if (change === 'signature') game.pendingAdvisorGatherMatching.signature += ':changed';
else game.pendingAdvisorGatherMatching.approved[0] = 'unearned';
reject(game, f.spender, f.action);
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
}
const offered = open(f); assert.ok(offered.pendingAdvisorGatherMatching);
offered.pendingAdvisorGatherMatching.approved[0] = 'unearned';
reject(offered, f.holder, { type: 'semutaCommit', event: offered.pendingTreacheryDiscard!.batch.event });
});
void test('all four minimal policies answer both actual native gather matching costs legally', () => {
for (const position of ['first', 'second'] as const) {
const f = fixture({ position }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const own = viewGame(offered, f.holder); own.players.find(player => player.id === f.holder)!.bot = difficulty;
const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty);
assert.deepEqual(own, before);
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.equal(action.event, event);
let game = act(structuredClone(offered), f.holder, action);
const claimed = action.type === 'semutaCommit';
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
const choosing = viewGame(game, f.holder); choosing.players.find(player => player.id === f.holder)!.bot = difficulty;
const selected = botActions(choosing)[0]; assert.ok(selected);
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost);
game = act(game, f.holder, selected);
} else if (game.pendingTreacheryDiscard) game = pass(game);
replaceOriginalMove(prevented(game, f, claimed), f);
}
}
});
@@ -0,0 +1,225 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { isAdvisor } from '../game/advisors';
import { advisorTransportMatchingKey } from '../game/advisor-transport-matching';
import { guildTransportQuote } from '../game/transport-quote';
import { junctionTransportChoice } from '../game/junction-transport-options';
import type { HomeworldShipmentSources } from '../game/homeworld-shipment-options';
import {
createSemutaAdvisorTransportMatchingFixture as fixture, actSemutaAdvisorTransportMatching as act,
passSemutaAdvisorTransportMatching as pass, claimSemutaAdvisorTransportMatching as claim,
allowSemutaAdvisorTransportMatching as allow, finishSemutaAdvisorTransportMatching as finish,
semutaAdvisorTransportMatchingPlayer as player, semutaAdvisorTransportMatchingInventory as inventory,
semutaAdvisorTransportMatchingPhysical as physical, type SemutaAdvisorTransportMatchingFixture,
} from './fixture-semuta-advisor-transport-matching';
const kinds = ['guild', 'junction'] as const, directions = ['fightersToAdvisors', 'advisorsToFighters'] as const;
const clock = (game: Game) => ({ turn: game.turn, phase: game.phase, active: game.active, ready: game.ready,
order: game.order, movementRemaining: game.movementRemaining, storm: game.storm,
spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
guildTimingGranted: game.guildTimingGranted, guildTimingLocked: game.guildTimingLocked });
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function nativeSource(f: SemutaAdvisorTransportMatchingFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
assert.equal(player(f.afterSetup, f.owner).forces['arrakeen:10'], 1);
assert.equal(player(f.afterSetup, f.spender).forces['arrakeen:10'], 10);
assert.equal(player(f.afterSetup, f.guild).forces['tueks_sietch:5'], 5);
assert.equal(isAdvisor(player(f.afterSetup, f.owner), 'arrakeen'), true);
assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1);
assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1);
assert.equal(player(f.acquired, f.seller).spice - player(f.acquisition.before, f.seller).spice, 1);
assert.equal(player(f.fighterShipment.before, f.owner).spice - player(f.fighterShipment.after, f.owner).spice, 4);
assert.deepEqual(f.fighterShipment.steps[0], { actor: f.owner, action: {
type: 'ship', territory: 'imperial_basin', sector: 9, amount: 2, elite: 0, allyPayment: 0,
} });
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.owner);
assert.equal(player(f.game, f.owner).forces['arrakeen:10'], 1);
assert.equal(player(f.game, f.owner).forces['imperial_basin:9'], 2);
assert.equal(isAdvisor(player(f.game, f.owner), 'arrakeen'), true);
assert.equal(isAdvisor(player(f.game, f.owner), 'imperial_basin'), false);
assert.equal(player(f.game, f.owner).shipped, false); assert.equal(player(f.game, f.owner).moved, 0);
if (f.kind === 'guild') {
assert.equal(f.enableHomeworlds, null); assert.equal(f.game.homeworlds ?? null, null);
assert.ok(f.nexus?.nexus && f.beforeWorm && f.wormOrder && f.alliance);
assert.deepEqual(f.alliance.steps, [{ actor: f.owner, action: { type: 'alliance', target: f.guild } },
{ actor: f.guild, action: { type: 'alliance', target: f.owner } }]);
assert.equal(player(f.game, f.owner).ally, f.guild); assert.equal(player(f.game, f.guild).ally, f.owner);
} else {
assert.ok(f.enableHomeworlds && f.junctionOffer && f.offerStep);
assert.deepEqual(f.enableHomeworlds.steps, [{ actor: f.game.host, action: { type: 'homeworlds', enabled: true } }]);
assert.equal(f.setup.homeworlds?.custody, null); assert.ok(f.afterSetup.homeworlds?.custody);
assert.equal(f.nexus, null); assert.equal(f.alliance, null); assert.equal(player(f.game, f.owner).ally ?? null, null);
assert.deepEqual(f.junctionOffer.steps, [f.offerStep]); assert.ok(f.originalOffer);
assert.equal(f.originalOffer.recipient, f.owner);
}
assert.equal(f.game.nexusCards ?? null, null);
assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.nativeAction }]);
assert.deepEqual(f.game.response, f.originalResponse); assert.equal(f.game.response?.kind, 'advisorFlip');
assert.deepEqual(f.game.pendingAdvisorTransportMatching, f.originalParent);
assert.equal(f.originalParent.intent.kind, f.kind); assert.equal(f.originalParent.signature, JSON.stringify(f.originalParent.intent));
assert.deepEqual(f.originalParent.receipt, f.originalSource); assert.equal(f.originalParent.opportunity, f.opportunity);
assert.equal(f.originalSource.owner, f.owner); assert.equal(f.originalSource.producer, f.kind);
assert.equal(f.originalSource.origin, f.origin); assert.equal(f.originalSource.destination, f.destination);
assert.deepEqual(f.originalSource.group, { [f.sourceLocation]: 1 });
assert.equal(f.originalSource.advisors, f.direction === 'fightersToAdvisors');
assert.equal(f.denialKey, advisorTransportMatchingKey(f.originalSource, f.opportunity));
assert.equal(f.game.pendingGuildTransport ?? null, null); assert.equal(f.game.pendingHomeworldShipment ?? null, null);
assert.deepEqual(physical(f.game), physical(f.beforeDeclaration)); assert.equal(f.price, 1);
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost));
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.equal(f.sourceActionCount, f.actions.length);
}
function open(f: SemutaAdvisorTransportMatchingFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard');
if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Expected the original native transport matching cost');
assert.equal(pending.batch.cause, 'karama:advisorFlip');
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })),
[{ card: f.cost, by: f.spender, face: true }]);
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
assert.deepEqual(pending.continuation.resume.response, f.originalResponse);
assert.deepEqual(game.pendingAdvisorTransportMatching, f.originalParent);
assert.deepEqual(game.advisorTransportMatchingDenied ?? [], f.originalDenied);
assert.deepEqual(game.junctionOffer ?? null, f.originalOffer);
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
assert.deepEqual(inventory(game), f.originalInventory); return game;
}
function complete(game: Game, actions: { actor: string; action: Action }[] = []) {
for (let count = 0; count < 6 && game.response &&
(game.response.advisorTransportMatchingSource || ['guildTransportPermission', 'guildRate'].includes(game.response.kind)); count++)
game = allow(game, actions);
return finish(game, actions);
}
function denied(state: Game, f: SemutaAdvisorTransportMatchingFixture, claimed: boolean) {
const game = finish(state); assert.equal(game.pendingAdvisorTransportMatching ?? null, null);
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game));
assert.deepEqual(game.junctionOffer ?? null, f.originalOffer);
assert.deepEqual(game.advisorTransportMatchingDenied, [...f.originalDenied, { owner: f.owner, turn: game.turn, key: f.denialKey }]);
assert.deepEqual(game.guildTransportBlocked ?? null, f.game.guildTransportBlocked ?? null);
assert.deepEqual(game.guildRateBlocked ?? null, f.game.guildRateBlocked ?? null);
assert.equal(player(game, f.owner).shipped, false); assert.equal(player(game, f.owner).moved, 0);
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
const view = viewGame(game, f.owner); assert.equal(view.advisorTransportMatchingDenied?.length, 1);
if (f.kind === 'guild') assert.ok(guildTransportQuote(view, f.nativeAction).unavailableReasons.some(reason => /prevented/.test(reason)));
else assert.match(junctionTransportChoice(view, f.destinationLocation, f.nativeAction.sources as HomeworldShipmentSources).blocked ?? '', /prevented/);
reject(game, f.owner, f.nativeAction); reject(game, f.spender, f.action);
assert.deepEqual(inventory(game), f.originalInventory); return game;
}
function transferred(game: Game, f: SemutaAdvisorTransportMatchingFixture, to = f.destinationLocation, amount = 1) {
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingAdvisorTransportMatching ?? null, null);
assert.equal(game.pendingGuildTransport ?? null, null); assert.equal(game.pendingHomeworldShipment ?? null, null);
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null); assert.deepEqual(clock(game), clock(f.game));
const owner = player(game, f.owner), original = player(f.game, f.owner);
assert.equal(owner.forces[f.sourceLocation] ?? 0, original.forces[f.sourceLocation] - amount);
assert.equal(owner.forces[to], (original.forces[to] ?? 0) + amount);
assert.equal(owner.spice, original.spice - f.price); assert.equal(owner.reserves, original.reserves); assert.equal(owner.tanks, original.tanks);
assert.equal(owner.shipped, true); assert.equal(owner.moved, 0);
assert.equal(isAdvisor(owner, to.split(':')[0]), to === f.destinationLocation ? f.originalSource.advisors : f.direction === 'advisorsToFighters');
assert.deepEqual(game.homeworlds ?? null, f.game.homeworlds ?? null);
for (const p of game.players.filter(p => p.id !== f.owner)) {
const prior = player(f.game, p.id); assert.deepEqual(p.forces, prior.forces);
assert.equal(p.reserves, prior.reserves); assert.equal(p.tanks, prior.tanks);
assert.equal(p.spice, prior.spice + Number(f.kind === 'guild' && p.id === f.guild) * f.price);
}
if (f.kind === 'junction') assert.equal(game.junctionOffer ?? null, null);
reject(game, f.owner, f.nativeAction); assert.deepEqual(inventory(game), f.originalInventory); return game;
}
for (const kind of kinds) for (const direction of directions) for (const claimed of [false, true]) void test(
`native ${kind} ${direction} matching Karama cost Semuta ${claimed ? 'claim' : 'pass'} keeps payment and packet uncommitted before a real replacement`, () => {
const f = fixture({ kind, direction }); nativeSource(f); const offered = open(f);
const game = denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
transferred(complete(act(game, f.replacementActor, f.replacementAction)), f, f.replacementLocation);
},
);
void test('native Guild and Junction matching allowance keeps original permission rate and payment in their real order', () => {
for (const kind of kinds) for (const direction of directions) {
const f = fixture({ kind, direction }); nativeSource(f);
let game = allow(structuredClone(f.game));
if (kind === 'guild') {
assert.equal(game.response?.kind, 'guildTransportPermission'); assert.deepEqual(physical(game), f.originalPhysical);
game = allow(game);
assert.ok(game.response?.kind === 'guildRate' || game.response?.kind === 'guildIncome');
if (game.response.kind === 'guildRate') assert.deepEqual(physical(game), f.originalPhysical);
else {
assert.equal(player(game, f.owner).shipped, true);
assert.equal(player(game, f.owner).forces[f.destinationLocation], player(f.game, f.owner).forces[f.destinationLocation] + 1);
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice - f.price);
}
}
const steps: { actor: string; action: Action }[] = [], done = transferred(complete(game, steps), f);
assert.ok(steps.every(step => step.action.type === 'passResponse' || step.action.type === 'decision'));
assert.ok(player(done, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(done, f.holder).hand.some(card => card.id === f.semuta));
}
});
void test('native transport denial permits the larger actual two-fighter matching packet in the same unused opportunity', () => {
for (const kind of kinds) {
const f = fixture({ kind }), game = denied(pass(open(f)), f, false); assert.ok(f.largerPacket);
const done = complete(act(game, f.owner, f.largerPacket));
assert.equal(player(done, f.owner).forces['arrakeen:10'], 3);
assert.equal(player(done, f.owner).forces['imperial_basin:9'] ?? 0, 0);
assert.equal(player(done, f.owner).spice, player(f.game, f.owner).spice - f.largerQuote!.price);
assert.equal(player(done, f.owner).shipped, true); assert.equal(player(done, f.owner).moved, 0);
assert.deepEqual(done.advisorTransportMatchingDenied, game.advisorTransportMatchingDenied);
assert.deepEqual(inventory(done), f.originalInventory);
}
});
void test('native transport matching cost rejects stale foreign and underlying declarations while its exact cost is offered', () => {
for (const kind of kinds) for (const direction of directions) {
const f = fixture({ kind, direction }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
reject(game, f.owner, f.nativeAction); reject(game, f.owner, f.replacementAction);
reject(game, f.owner, { type: 'passResponse' }); reject(game, f.owner, { type: 'endMovement' });
const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
}
});
void test('controlled changed private transport intent or opportunity rejects the original matching cost before spending', () => {
for (const kind of kinds) for (const field of ['signature', 'opportunity'] as const) {
const f = fixture({ kind }), game = structuredClone(f.game);
// Explicit malformed-receipt supplement only; the native primary source
// has no installed hand, force, wallet, stance, alliance or grant.
game.pendingAdvisorTransportMatching![field] += ':changed';
reject(game, f.spender, f.action); assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
}
});
void test('all four minimal profiles legally claim or pass real Guild and Junction matching costs in both type directions', () => {
for (const kind of kinds) for (const direction of directions) {
const f = fixture({ kind, direction }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
const proposed = botActions(own)[0]; assert.ok(proposed);
assert.ok(proposed.type === 'semutaCommit' || proposed.type === 'semutaPass'); assert.equal(proposed.event, event);
let game = act(structuredClone(offered), f.holder, proposed); const claimed = proposed.type === 'semutaCommit';
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty;
const selected = botActions(selecting)[0]; assert.ok(selected);
assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost); game = act(game, f.holder, selected);
} else if (game.pendingTreacheryDiscard) game = pass(game);
denied(game, f, claimed);
}
}
});