Offer Semuta on World, battle and Face Dance conversion costs
This commit is contained in:
1 parent
27959b4655
commit
92f2e8125b
11 files changed
+1561
-1
No files matched your search
@@ -0,0 +1,285 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import { advisorBattleFlipTraitors } from './fixture-advisor-battle-flip';
|
||||
import {
|
||||
actSemutaAdvisorMatching, nextSemutaAdvisorMatching, passSemutaAdvisorMatching, claimSemutaAdvisorMatching,
|
||||
semutaAdvisorMatchingPlayer, semutaAdvisorMatchingInventory,
|
||||
} from './fixture-semuta-advisor-matching';
|
||||
|
||||
export type SemutaAdvisorBattleStep = { actor: string; action: Action };
|
||||
export const actSemutaAdvisorBattle = actSemutaAdvisorMatching;
|
||||
export const passSemutaAdvisorBattle = passSemutaAdvisorMatching;
|
||||
export const claimSemutaAdvisorBattle = claimSemutaAdvisorMatching;
|
||||
export const semutaAdvisorBattlePlayer = semutaAdvisorMatchingPlayer;
|
||||
export const semutaAdvisorBattleInventory = semutaAdvisorMatchingInventory;
|
||||
export const semutaAdvisorBattleTraitors = advisorBattleFlipTraitors;
|
||||
export const semutaAdvisorBattleClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
/** Collection legitimately changes balances after original cleanup; preserve
|
||||
* committed physical loss/leader/usage facts separately from those balances. */
|
||||
export const semutaAdvisorBattlePhysical = (game: Game) => game.players.map(p => ({ id: p.id, forces: p.forces,
|
||||
elites: p.elites, reserves: p.reserves, tanks: p.tanks, battleLosses: p.battleLosses, leaders: p.leaders,
|
||||
shipped: p.shipped, moved: p.moved, revived: p.revived, freeForcesRevived: p.freeForcesRevived }));
|
||||
|
||||
/** Native preparation/readiness only. Private plans/votes, mandatory fresh
|
||||
* disposal and the new automatic response are explicit caller choices. */
|
||||
export function nextSemutaAdvisorBattle(game: Game): SemutaAdvisorBattleStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance || game.response?.automaticAdvisorFlip) return null;
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
if (game.decision?.kind === 'battleCards') return null;
|
||||
if (game.status === 'setup' && game.setupStage === 'traitors') {
|
||||
const player = game.players.find(p => p.traitorChoices.length); assert.ok(player);
|
||||
const leader = player.faction === 'atreides' && player.traitorChoices.includes('harkonnen-3')
|
||||
? 'harkonnen-3' : player.traitorChoices[0];
|
||||
return { actor: player.id, action: { type: 'traitor', leader } };
|
||||
}
|
||||
const outcome = game.battle?.outcomeWindow;
|
||||
if (!game.decision && outcome && !outcome.closed) {
|
||||
const actor = [outcome.attacker, outcome.defender].find(id => !outcome.ready.includes(id)); assert.ok(actor);
|
||||
return { actor, action: { type: 'battleOutcomeReady', event: outcome.event } };
|
||||
}
|
||||
if (game.battle?.revealed && viewGame(game, game.host).battle?.traitorVoters.some(id => game.battle!.traitorCalls[id] === undefined)) return null;
|
||||
}
|
||||
return nextSemutaAdvisorMatching(game);
|
||||
}
|
||||
export const nextSemutaAdvisorBattleStep = nextSemutaAdvisorBattle;
|
||||
export function advanceSemutaAdvisorBattle(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaAdvisorBattleStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaAdvisorBattle(game);
|
||||
assert.ok(step, `Choose the original battle/fresh control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaAdvisorBattle(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The native Semuta/advisor battle did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaAdvisorBattle(game: Game, actions: SemutaAdvisorBattleStep[] = []): Game {
|
||||
const source = game.response?.automaticAdvisorFlip; assert.ok(source?.producer === 'battleLosses');
|
||||
for (let count = 0; game.response?.automaticAdvisorFlip?.event === source.event && count < game.players.length; count++) {
|
||||
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game = actSemutaAdvisorBattle(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.automaticAdvisorFlip?.event, source.event); return game;
|
||||
}
|
||||
/** Keep the genuine optional winner Shield via its actual reply, then finish
|
||||
* original cleanup to Collection. No fresh group or conversion is bypassed. */
|
||||
export function finishSemutaAdvisorBattle(game: Game, actions: SemutaAdvisorBattleStep[] = []): Game {
|
||||
for (let count = 0; count < 250; count++) {
|
||||
if (!game.battle && game.phase === 7 && semutaAdvisorBattleClean(game)) return game;
|
||||
assert.ok(!game.pendingTreacheryDiscard && !game.response?.automaticAdvisorFlip,
|
||||
'Resolve the original fresh/conversion choice before native battle cleanup');
|
||||
const step = game.decision?.kind === 'battleCards' && !game.response && !game.phaseOpening
|
||||
? { actor: game.decision.player, action: { type: 'decision', discard: [] } satisfies Action }
|
||||
: nextSemutaAdvisorBattle(game);
|
||||
assert.ok(step); game = actSemutaAdvisorBattle(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original battle cleanup did not reach Collection');
|
||||
}
|
||||
|
||||
/** Only original undealt Fisher-Yates draws, consumed by actual prediction. */
|
||||
function originalTraitorScalars(original: string[], desired: string[]) {
|
||||
assert.deepEqual([...original].sort(), [...desired].sort());
|
||||
const current = [...original], scalars: number[] = [];
|
||||
for (let i = current.length - 1; i > 0; i--) {
|
||||
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
|
||||
scalars.push(Math.floor((j + 0.5) * 4294967296 / (i + 1)));
|
||||
[current[i], current[j]] = [current[j], current[i]];
|
||||
}
|
||||
assert.deepEqual(current, desired); return scalars;
|
||||
}
|
||||
function predictOriginalTraitors(game: Game, actor: string, action: Action, scalars: number[], actions: SemutaAdvisorBattleStep[]) {
|
||||
assert.equal(game.setupStage, 'prediction'); assert.ok(game.players.every(p => !p.traitors.length && !p.traitorChoices.length));
|
||||
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) {
|
||||
assert.ok(cursor < scalars.length); array[0] = scalars[cursor++];
|
||||
} else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try {
|
||||
const next = applyAction(game, actor, action); assert.equal(cursor, scalars.length);
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Genuine native Advanced losses before the new cancellation. No played
|
||||
* force, leader, hand, traitor, battle, stance, clock or pending frame is set. */
|
||||
export function createSemutaAdvisorBattleFixture(options: { mutualTraitors?: boolean } = {}) {
|
||||
const mutualTraitors = options.mutualTraitors ?? false, seller = 'richese', holder = 'choam', owner = 'beneGesserit';
|
||||
const atreides = 'atreides', harkonnen = 'harkonnen', spender = seller, territory = 'arrakeen', location = 'arrakeen:10';
|
||||
let game = createGame('SEMUTAADVISORBATTLE', newPlayer(seller, 'Richese', 'richese'), true, ['choam']);
|
||||
for (const faction of ['choam', 'beneGesserit', 'atreides', 'harkonnen'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaAdvisorBattleStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaAdvisorBattle(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorBattle(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), initialization = { before: lobby, after: setup,
|
||||
entry: 'original-undealt-advanced-semuta-preview' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.setupStage, 'prediction'); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(p => !p.hand.length && !p.traitorChoices.length && !p.traitors.length));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaAdvisorBattleInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const printed = game.deck.find(card => card.effect === 'karama'), shieldCard = game.deck.find(card => card.kind === 'shield');
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3), extra = game.deck.find(card => card.kind === 'poison');
|
||||
assert.ok(printed && shieldCard && extra); assert.equal(ordinary.length, 3);
|
||||
const selected = [printed, ordinary[0], ordinary[1], shieldCard, ordinary[2], extra];
|
||||
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 originalTraitorOrder = traitorDeck(game.players, game.expansions.includes('ix'));
|
||||
const originalTraitors = [...originalTraitorOrder].sort(), traitorOrder = [...originalTraitorOrder];
|
||||
if (mutualTraitors) {
|
||||
for (const [target, identity] of [[game.players.findIndex(p => p.id === atreides) * 4, 'harkonnen-3'],
|
||||
[game.players.findIndex(p => p.id === harkonnen) * 4, 'atreides-0']] as const) {
|
||||
const from = traitorOrder.indexOf(identity); assert.ok(from >= 0);
|
||||
[traitorOrder[target], traitorOrder[from]] = [traitorOrder[from], traitorOrder[target]];
|
||||
}
|
||||
}
|
||||
const traitorScalars = originalTraitorScalars(originalTraitorOrder, traitorOrder), beforeTraitorDeal = structuredClone(game);
|
||||
const traitorDeal = transition(() => { game = predictOriginalTraitors(game, owner,
|
||||
{ type: 'predict', faction: atreides, turn: 10 }, traitorScalars, actions); });
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
let advisorSetup: { before: Game; steps: SemutaAdvisorBattleStep[]; after: Game } | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const step = nextSemutaAdvisorBattle(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), cost = printed.id, shield = shieldCard.id;
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(semutaAdvisorBattlePlayer(game, owner).forces[location], 1);
|
||||
assert.equal(isAdvisor(semutaAdvisorBattlePlayer(game, owner), territory), true);
|
||||
if (mutualTraitors) {
|
||||
assert.ok(semutaAdvisorBattlePlayer(game, atreides).traitors.includes('harkonnen-3'));
|
||||
assert.ok(semutaAdvisorBattlePlayer(game, harkonnen).traitors.includes('atreides-0'));
|
||||
}
|
||||
assert.deepEqual(semutaAdvisorBattleTraitors(game), originalTraitors);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (const [position, face] of ['funeral_plain', 'hagga_basin'].entries()) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && card.territory === face && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
spiceOrder.push({ position, from, card: structuredClone(card) });
|
||||
}
|
||||
let beforeBid: Game | undefined, bid: SemutaAdvisorBattleStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaAdvisorBattlePlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaAdvisorBattle(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(semutaAdvisorBattlePlayer(beforeBid, holder).spice - semutaAdvisorBattlePlayer(game, holder).spice, 1);
|
||||
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'advisorBattle');
|
||||
const declinedPreparation = transition(() => {
|
||||
act(owner, { type: 'decision', accept: false });
|
||||
reach(state => state.phase === 5 && state.active === owner && semutaAdvisorBattleClean(state));
|
||||
act(owner, { type: 'endMovement' });
|
||||
});
|
||||
reach(state => state.phase === 5 && state.active === atreides && semutaAdvisorBattleClean(state));
|
||||
const atreidesDeparture = transition(() => {
|
||||
act(atreides, { type: 'move', from: location, territory: 'imperial_basin', sector: 10, amount: 9, elite: 0 });
|
||||
reach(semutaAdvisorBattleClean); act(atreides, { type: 'endMovement' });
|
||||
});
|
||||
reach(state => state.phase === 5 && state.active === harkonnen && semutaAdvisorBattleClean(state));
|
||||
const harkonnenArrival = transition(() => {
|
||||
act(harkonnen, { type: 'move', from: 'carthag:11', territory, sector: 10, amount: 1, elite: 0 });
|
||||
reach(semutaAdvisorBattleClean); act(harkonnen, { type: 'endMovement' });
|
||||
});
|
||||
reach(state => state.phase === 6 && semutaAdvisorBattleClean(state) && !!state.active);
|
||||
const beforeBattle = structuredClone(game), choice = game.players.flatMap(p => viewGame(game, p.id).battleChoices)
|
||||
.find(choice => choice.territory === territory); assert.ok(choice);
|
||||
const battleAction: Action = { type: 'chooseBattle', territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker };
|
||||
act(choice.chooser, battleAction);
|
||||
reach(state => !!state.battle && !state.battle.revealed && semutaAdvisorBattleClean(state) &&
|
||||
!state.battle.preparation && state.battle.preLeader?.closed !== false);
|
||||
const beforePlans = structuredClone(game), planSteps: SemutaAdvisorBattleStep[] = [
|
||||
{ actor: atreides, action: { type: 'battlePlan', leader: 'atreides-0', dial: 0.5, support: 0, weapon: null, defense: shield } },
|
||||
{ actor: harkonnen, action: { type: 'battlePlan', leader: 'harkonnen-3', dial: 0, support: 0, weapon: null, defense: null } },
|
||||
];
|
||||
for (const step of planSteps) act(step.actor, step.action);
|
||||
const reveal = transition(() => { reach(state => state.battle?.revealed === true); }), revealed = structuredClone(game);
|
||||
const lossAt = actions.length, voteHistory: { before: Game; step: SemutaAdvisorBattleStep; after: Game }[] = [];
|
||||
const disposals: { before: Game; steps: SemutaAdvisorBattleStep[]; after: Game; event: string }[] = [];
|
||||
for (let count = 0; game.response?.automaticAdvisorFlip?.producer !== 'battleLosses' && count < 150; count++) {
|
||||
if (game.pendingTreacheryDiscard) {
|
||||
const pending: NonNullable<Game['pendingTreacheryDiscard']> = game.pendingTreacheryDiscard;
|
||||
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy })), [{ card: shield, by: atreides }]);
|
||||
disposals.push({ ...transition(() => { game = passSemutaAdvisorBattle(game, actions); }), event: pending.batch.event });
|
||||
} else if (game.battle?.revealed) {
|
||||
const actor = viewGame(game, game.host).battle!.traitorVoters.find(id => game.battle!.traitorCalls[id] === undefined);
|
||||
if (actor) {
|
||||
const before = structuredClone(game), step: SemutaAdvisorBattleStep = { actor, action: { type: 'traitorCall', call: mutualTraitors } };
|
||||
act(step.actor, step.action); voteHistory.push({ before, step, after: structuredClone(game) });
|
||||
} else {
|
||||
const step = nextSemutaAdvisorBattle(game); assert.ok(step); act(step.actor, step.action);
|
||||
}
|
||||
} else {
|
||||
const step = nextSemutaAdvisorBattle(game); assert.ok(step); act(step.actor, step.action);
|
||||
}
|
||||
}
|
||||
const automatic = game.response?.automaticAdvisorFlip; assert.ok(automatic?.producer === 'battleLosses');
|
||||
assert.ok(game.pendingAutomaticAdvisorBattle?.resume && game.pendingAutomaticAdvisorFlips?.length && game.lastBattleContext);
|
||||
assert.equal(game.battle, null); assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(semutaAdvisorBattlePlayer(game, atreides).forces[location] ?? 0, 0);
|
||||
assert.equal(semutaAdvisorBattlePlayer(game, harkonnen).forces[location] ?? 0, 0);
|
||||
assert.equal(semutaAdvisorBattlePlayer(game, owner).forces[location], 1);
|
||||
assert.equal(semutaAdvisorBattlePlayer(game, atreides).tanks, 1); assert.equal(semutaAdvisorBattlePlayer(game, harkonnen).tanks, 1);
|
||||
assert.equal(game.discard.filter(card => card.id === shield).length, Number(mutualTraitors));
|
||||
assert.ok(semutaAdvisorBattlePlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaAdvisorBattlePlayer(game, spender).hand.some(card => card.id === cost));
|
||||
const source = structuredClone(game), originalResponse = structuredClone(game.response!);
|
||||
const originalAutomaticSource = structuredClone(automatic), originalParent = structuredClone(game.pendingAutomaticAdvisorBattle);
|
||||
const originalQueue = structuredClone(game.pendingAutomaticAdvisorFlips), originalBattle = structuredClone(game.lastBattleContext);
|
||||
const originalStance = structuredClone(semutaAdvisorBattlePlayer(game, owner).advisors![territory]);
|
||||
assert.equal(originalStance.aloneFlipHold, automatic.event);
|
||||
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
assert.deepEqual(semutaAdvisorBattleInventory(game), originalInventory); assert.deepEqual(semutaAdvisorBattleTraitors(game), originalTraitors);
|
||||
return { game, source, beforeCost: source, advanced: true as const, mutualTraitors, owner, bg: owner, holder, spender,
|
||||
seller, richese: seller, atreides, harkonnen, winner: mutualTraitors ? null : atreides, recipient: owner,
|
||||
cost, karama: cost, semuta: SEMUTA_DRUG_ID, shield, action, originalResponse,
|
||||
originalAutomaticSource, originalParent, originalQueue, originalStance, originalBattle,
|
||||
originalCleanup: structuredClone(originalParent.resume), originalPhysical: structuredClone(semutaAdvisorBattlePhysical(game)),
|
||||
originalBalances: Object.fromEntries(game.players.map(p => [p.id, p.spice])),
|
||||
originalPlans: structuredClone(revealed.battle!.plans), voteSteps: voteHistory.map(vote => vote.step),
|
||||
territory, location, event: game.lastBattleContext.event, initial, lobby, setup, initialization,
|
||||
beforeTraitorDeal, traitorDeal, traitorOrder, traitorScalars, beforeStartingDeal, afterSetup, advisorSetup,
|
||||
deal: selected.map(card => card.id), dealOrder, originalInventory, originalTraitors, originalSpice, spiceOrder,
|
||||
acquisition, acquired, declinedPreparation, atreidesDeparture, harkonnenArrival, beforeBattle, choice, battleAction,
|
||||
beforePlans, planSteps, plans: planSteps, reveal, revealed, voteHistory, disposals,
|
||||
lossHistory: { before: revealed, steps: structuredClone(actions.slice(lossAt)), after: source },
|
||||
actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original Advanced Semuta initializer on the genuinely ready undealt R/C/BG/A/H lobby, exact CHOAM/main35/cache10 and no modules. Only original unopened starting-card/Spice positions and scalar lotteries are selected. Richese really receives printed Karama outside combat; CHOAM really purchases cache1 Semuta. Harkonnen really receives two native ordinary starting cards; Atreides receives original Shield.',
|
||||
mutualTraitors ? `Before any Traitor is dealt, the actual prediction action consumes the selected original ${originalTraitors.length}-card permutation from the registered native traitorDeck, including its actual Advanced Auditor handling. Atreides is really offered and chooses Iakin; Harkonnen naturally keeps Thufir in its original four-card packet. Both real revealed-battle votes call those held Traitors.` : `The original ${originalTraitors.length}-card native traitorDeck shuffle keeps identity order, including actual Advanced Auditor handling. Native private choices/kept packets execute; both actual revealed-battle votes decline Traitor calls.`,
|
||||
'BG actually places its original advisor beside A10 Arrakeen10 and declines native preparation. Atreides really moves nine to Basin10; Harkonnen really moves one original Carthag counter to Arrakeen, leaving nine at home. Choose the real A1/H1 battle; Thufir5/dial0.5/support0/Shield spends the last unsupported normal against Iakin2/dial0/unarmed.',
|
||||
'Actual plan seals, final readiness, revealed votes, casualties and leader effects execute. Only observed earlier mandatory Shield disposal groups receive actual passes, preserving the paid Semuta. Stop at native battleLosses conversion with battle=null and mandatory costs finished, before optional winner cleanup/aftermath.',
|
||||
'No played hand, leader, Traitor, force, stance, wallet, battle, clock, receipt or parent is installed. The original normal Shield keeper reply remains real; mutual-Traitor Shield stays mandatorily discarded. Native Collection follows original cleanup once.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaAdvisorBattleFixture = ReturnType<typeof createSemutaAdvisorBattleFixture>;
|
||||
@@ -0,0 +1,264 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import { advisorFaceDanceFlipTraitors } from './fixture-advisor-face-dance-flip';
|
||||
import {
|
||||
actSemutaCombatCapabilities, nextSemutaCombatCapabilities,
|
||||
semutaCombatCapabilitiesPlayer, semutaCombatCapabilitiesInventory, semutaCombatCapabilitiesClean,
|
||||
passSemutaCombatCapabilities, claimSemutaCombatCapabilities,
|
||||
} from './fixture-semuta-combat-capabilities';
|
||||
|
||||
export type SemutaAdvisorFaceDanceStep = { actor: string; action: Action };
|
||||
export const actSemutaAdvisorFaceDance = actSemutaCombatCapabilities;
|
||||
export const semutaAdvisorFaceDancePlayer = semutaCombatCapabilitiesPlayer;
|
||||
export const semutaAdvisorFaceDanceInventory = semutaCombatCapabilitiesInventory;
|
||||
export const semutaAdvisorFaceDanceClean = semutaCombatCapabilitiesClean;
|
||||
export const semutaAdvisorFaceDanceTraitors = advisorFaceDanceFlipTraitors;
|
||||
export const passSemutaAdvisorFaceDance = passSemutaCombatCapabilities;
|
||||
export const claimSemutaAdvisorFaceDance = claimSemutaCombatCapabilities;
|
||||
export const semutaAdvisorFaceDancePhysical = (game: Game) => game.players.map(p => ({
|
||||
id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites,
|
||||
leaders: p.leaders, battleLosses: p.battleLosses, faceDancers: p.faceDancers,
|
||||
}));
|
||||
|
||||
/** Native controls only. Face Dance and a completed replacement's conversion
|
||||
* remain explicit caller choices; actual earlier Semuta groups receive passes. */
|
||||
export function nextSemutaAdvisorFaceDance(game: Game): SemutaAdvisorFaceDanceStep | null {
|
||||
if (game.response?.automaticAdvisorFlip) 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 } };
|
||||
if (game.status === 'setup' && game.setupStage === 'traitors') {
|
||||
const p = game.players.find(p => p.traitorChoices.length); assert.ok(p);
|
||||
return { actor: p.id, action: { type: 'traitor', leader: p.traitorChoices[0] } };
|
||||
}
|
||||
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 === 'faceDance') return null;
|
||||
if (d?.kind === 'advisorBattle' || d?.kind === 'advisor' || d?.kind === 'intrusion')
|
||||
return { actor: d.player, action: { type: 'decision', accept: false } };
|
||||
if (d?.kind === 'revivalStop' || d?.kind === 'fullPlanOffer')
|
||||
return { actor: d.player, action: { type: 'decision', decline: true } };
|
||||
if (d?.kind === 'fullPlanRead') return { actor: d.player, action: { type: 'decision', continue: true } };
|
||||
}
|
||||
return nextSemutaCombatCapabilities(game);
|
||||
}
|
||||
export const nextSemutaAdvisorFaceDanceStep = nextSemutaAdvisorFaceDance;
|
||||
export function advanceSemutaAdvisorFaceDance(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaAdvisorFaceDanceStep[] = []): Game {
|
||||
for (let count = 0; count < 3600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextSemutaAdvisorFaceDance(game);
|
||||
assert.ok(next, `Supply the real Face Dance/private plan at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSemutaAdvisorFaceDance(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native zero-stock Face Dance programme did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaAdvisorFaceDance(game: Game, actions: SemutaAdvisorFaceDanceStep[] = []): Game {
|
||||
assert.ok(game.response?.automaticAdvisorFlip?.producer === 'faceDance');
|
||||
for (let count = 0; game.response?.automaticAdvisorFlip?.producer === 'faceDance' && count < game.players.length; count++) {
|
||||
const p = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(p);
|
||||
game = actSemutaAdvisorFaceDance(game, p.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.notEqual(game.response?.automaticAdvisorFlip?.producer, 'faceDance'); return game;
|
||||
}
|
||||
/** Complete only original aftermath/Collection entry; no new return, Face
|
||||
* Dance, shipment or battle declaration is made by this driver. */
|
||||
export function finishSemutaAdvisorFaceDance(game: Game, actions: SemutaAdvisorFaceDanceStep[] = []): Game {
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
return advanceSemutaAdvisorFaceDance(game, state => !state.battle && state.phase >= 7 && state.phase <= 8 &&
|
||||
semutaAdvisorFaceDanceClean(state), actions);
|
||||
}
|
||||
|
||||
/** Select only the original finishSetup Face Dancer Fisher-Yates shuffle on
|
||||
* actual advisorSetup. The real engine deals the conserved physical cards. */
|
||||
function dealOriginalFaceDancers(game: Game, step: SemutaAdvisorFaceDanceStep, actions: SemutaAdvisorFaceDanceStep[]) {
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.setupStage, 'forces'); assert.equal(step.action.type, 'advisorSetup');
|
||||
assert.ok(game.players.every(p => !p.traitorChoices.length && !p.faceDancers));
|
||||
const held = new Set(game.players.flatMap(p => p.traitors));
|
||||
const original = traitorDeck(game.players, true).filter(id => !held.has(id));
|
||||
const first = ['tleilaxu-3', 'tleilaxu-4', 'atreides-0']; assert.ok(first.every(id => original.includes(id)));
|
||||
const order = [...first, ...original.filter(id => !first.includes(id))], 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);
|
||||
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) {
|
||||
assert.ok(cursor < scalars.length, 'Only the first actual Face Dancer shuffle consumes selected scalar entropy');
|
||||
array[0] = scalars[cursor++];
|
||||
} 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 { game: next, original, order, scalars };
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Real deferred bribes fund all twenty Tleilaxu reserves. The real losing
|
||||
* battle then leaves no reserve or board counter available for replacement. */
|
||||
export function createSemutaAdvisorFaceDanceFixture() {
|
||||
const richese = 'richese', holder = 'choam', owner = 'beneGesserit', tleilaxu = 'tleilaxu', winner = 'atreides', spender = 'emperor';
|
||||
const territory = 'arrakeen', sector = 10, location = `${territory}:${sector}`;
|
||||
let game = createGame('SEMUTAADVISORFACEDANCE', newPlayer(richese, 'Richese', 'richese'), true, ['choam', 'ix']);
|
||||
for (const faction of ['choam', 'beneGesserit', 'tleilaxu', 'atreides', 'emperor'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SemutaAdvisorFaceDanceStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaAdvisorFaceDance(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorFaceDance(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.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.ok(game.players.every(p => p.hand.length === 0 && !p.faceDancers));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaAdvisorFaceDanceInventory(game); assert.equal(originalInventory.length, 57);
|
||||
const originalTraitors = traitorDeck(game.players, true).sort();
|
||||
const printed = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-')); 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;
|
||||
let faceDancerDeal: ReturnType<typeof dealOriginalFaceDancers> | null = null;
|
||||
let advisorSetup: { before: Game; steps: SemutaAdvisorFaceDanceStep[]; after: Game } | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 150; count++) {
|
||||
const next = nextSemutaAdvisorFaceDance(game); assert.ok(next);
|
||||
if (next.action.type === 'advisorSetup') {
|
||||
const before = structuredClone(game), at = actions.length;
|
||||
faceDancerDeal = dealOriginalFaceDancers(game, next, actions); game = faceDancerDeal.game;
|
||||
advisorSetup = { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
|
||||
} else act(next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(faceDancerDeal && advisorSetup);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(semutaAdvisorFaceDancePlayer(game, owner).forces[location], 1);
|
||||
assert.ok(semutaAdvisorFaceDancePlayer(game, owner).advisors?.[territory]);
|
||||
assert.equal(semutaAdvisorFaceDancePlayer(game, winner).forces[location], 10);
|
||||
const dancers = structuredClone(semutaAdvisorFaceDancePlayer(game, tleilaxu).faceDancers!);
|
||||
const winningDisc = semutaAdvisorFaceDancePlayer(game, winner).leaders.filter(leader =>
|
||||
dancers.some(card => card.leader === leader.id && !card.revealed)).toSorted((a, b) => b.strength - a.strength)[0];
|
||||
const losingDisc = semutaAdvisorFaceDancePlayer(game, tleilaxu).leaders.toSorted((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(winningDisc && losingDisc); const winnerLeader = winningDisc.id, loserLeader = losingDisc.id;
|
||||
assert.deepEqual(semutaAdvisorFaceDanceTraitors(game), originalTraitors);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1, 2, 3].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SemutaAdvisorFaceDanceStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 1000 && !semutaAdvisorFaceDancePlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const next = nextSemutaAdvisorFaceDance(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 === 5 && semutaAdvisorFaceDanceClean(state));
|
||||
const funding = transition(() => {
|
||||
act(winner, { type: 'bribe', target: tleilaxu, amount: 9 });
|
||||
act(spender, { type: 'bribe', target: tleilaxu, amount: 10 });
|
||||
});
|
||||
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).bribes, 19);
|
||||
reach(state => state.turn === 1 && state.phase === 7 && semutaAdvisorFaceDanceClean(state));
|
||||
const fundingCollection = transition(() => {
|
||||
reach(state => state.turn === 1 && state.phase === 8 && semutaAdvisorFaceDanceClean(state));
|
||||
});
|
||||
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).bribes, 0);
|
||||
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).spice - semutaAdvisorFaceDancePlayer(fundingCollection.before, tleilaxu).spice, 19);
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === tleilaxu && semutaAdvisorFaceDanceClean(state));
|
||||
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).reserves, 20);
|
||||
const shipment = transition(() => {
|
||||
act(tleilaxu, { type: 'ship', territory, sector, amount: 20, elite: 0, allyPayment: 0 });
|
||||
reach(semutaAdvisorFaceDanceClean);
|
||||
});
|
||||
assert.equal(semutaAdvisorFaceDancePlayer(shipment.before, tleilaxu).spice - semutaAdvisorFaceDancePlayer(game, tleilaxu).spice, 20);
|
||||
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).reserves, 0);
|
||||
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).forces[location], 20);
|
||||
reach(state => state.turn === 2 && state.phase === 6 && semutaAdvisorFaceDanceClean(state) && !!state.active);
|
||||
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices.find(row => row.territory === territory);
|
||||
assert.ok(choice); const battleAction: Action = { type: 'chooseBattle', territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker };
|
||||
act(choice.chooser, battleAction);
|
||||
reach(state => !!state.battle && semutaAdvisorFaceDanceClean(state) && !state.battle.preparation &&
|
||||
state.battle.preLeader?.closed !== false);
|
||||
const beforePlans = structuredClone(game), planSteps: SemutaAdvisorFaceDanceStep[] = [
|
||||
{ actor: winner, action: { type: 'battlePlan', leader: winnerLeader, dial: 1, support: 1, weapon: null, defense: null } },
|
||||
{ actor: tleilaxu, action: { type: 'battlePlan', leader: loserLeader, dial: 0, support: 0, weapon: null, defense: null } },
|
||||
];
|
||||
for (const step of planSteps) act(step.actor, step.action);
|
||||
const revealed = structuredClone(game), cleanupAt = actions.length;
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'faceDance');
|
||||
assert.ok(game.decision?.kind === 'faceDance'); assert.equal(game.decision.player, tleilaxu);
|
||||
assert.equal(game.decision.winner, winner); assert.equal(game.decision.identity, winnerLeader);
|
||||
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).reserves, 0);
|
||||
assert.equal(Object.values(semutaAdvisorFaceDancePlayer(game, tleilaxu).forces).reduce((a, b) => a + b, 0), 0);
|
||||
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).tanks, 20);
|
||||
assert.equal(semutaAdvisorFaceDancePlayer(game, winner).forces[location], 9);
|
||||
const cleanup = { before: revealed, steps: structuredClone(actions.slice(cleanupAt)), after: structuredClone(game) };
|
||||
const faceDanceAction: Action = { type: 'decision', reveal: true, sources: {}, sector };
|
||||
const replacement = transition(() => { act(tleilaxu, faceDanceAction); });
|
||||
assert.ok(game.response?.automaticAdvisorFlip?.producer === 'faceDance');
|
||||
const automatic = game.response.automaticAdvisorFlip;
|
||||
assert.equal(automatic.faceDance.amount, 0); assert.deepEqual(automatic.faceDance.sources, {});
|
||||
assert.deepEqual(automatic.faceDance.returned, { [winner]: 9 });
|
||||
assert.equal(semutaAdvisorFaceDancePlayer(game, winner).forces[location] ?? 0, 0);
|
||||
assert.equal(semutaAdvisorFaceDancePlayer(game, winner).reserves, semutaAdvisorFaceDancePlayer(replacement.before, winner).reserves + 9);
|
||||
assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).faceDancers!.find(card => card.leader === winnerLeader)?.revealed, true);
|
||||
assert.equal(game.battle, null); assert.equal(game.pendingFaceDance, null);
|
||||
assert.deepEqual(semutaAdvisorFaceDanceInventory(game), originalInventory);
|
||||
assert.deepEqual(semutaAdvisorFaceDanceTraitors(game), originalTraitors);
|
||||
return { game, kind: 'advisorFlip' as const, advanced: true as const, zeroReplacement: true as const,
|
||||
richese, holder, owner, tleilaxu, winner, spender, cost, karama: cost, semuta: SEMUTA_DRUG_ID,
|
||||
action: { type: 'card', card: cost, mode: 'cancel' } as Action,
|
||||
originalResponse: structuredClone(game.response), originalAutomaticSource: structuredClone(automatic),
|
||||
originalParent: structuredClone(game.pendingAutomaticAdvisorFaceDance!), originalQueue: structuredClone(game.pendingAutomaticAdvisorFlips!),
|
||||
originalPhysical: structuredClone(semutaAdvisorFaceDancePhysical(game)), originalStance: structuredClone(semutaAdvisorFaceDancePlayer(game, owner).advisors!),
|
||||
originalBattle: structuredClone(revealed.battle!), originalBattleContext: structuredClone(game.lastBattleContext!),
|
||||
originalBalances: game.players.map(p => ({ id: p.id, spice: p.spice })), originalDeck: structuredClone(game.deck),
|
||||
originalFaceDancers: structuredClone(semutaAdvisorFaceDancePlayer(game, tleilaxu).faceDancers!),
|
||||
territory, location, sector, winnerLeader, loserLeader, faceDanceAction, battleAction, choice,
|
||||
initial, lobby, setup, beforeStartingDeal, afterSetup, advisorSetup,
|
||||
faceDancerDeal: { original: faceDancerDeal.original, order: faceDancerDeal.order, scalars: faceDancerDeal.scalars,
|
||||
before: advisorSetup.before, steps: advisorSetup.steps, after: advisorSetup.after },
|
||||
dancers, dealOrder, deal: selected.map(card => card.id), originalInventory, originalTraitors, originalSpice, spiceOrder,
|
||||
acquisition, acquired, cache: acquisition, funding, fundingCollection, fuel: { funding, fundingCollection, shipment },
|
||||
shipment, beforeBattle, beforePlans, planSteps, revealed, cleanup, replacement,
|
||||
actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Original undealt Advanced R/C/BG/T/A/E Semuta initializer with native CHOAM/Ix decks and no other module. Actual BG setup places one advisor beside original Atreides10. Only unopened main/Spice positions and native scalar lotteries are selected; C buys cache1 Semuta and E is dealt printed Karama.',
|
||||
'The actual final setup advisor action consumes a selected conserved permutation of the original Face Dancer shuffle: T3/T4/A0 first. All actual private Traitor choices precede it; the engine physically deals all three unrevealed cards. The winning leader is selected from that actual matching living hand.',
|
||||
'At clean first movement A really bribes T9 and E really bribes T10. The nineteen deferred spice becomes spendable only through real Mentat collection; T’s native five then funds the actual turn2 city shipment of all twenty reserves for twenty spice. No stock or wallet is installed.',
|
||||
'The actual unarmed Arrakeen battle uses winning A leader5/dial1/support1 and weak T leader/dial0. All twenty T counters really enter Tanks; A retains nine. Native preparation, Traitor declines, final outcome and cleanup remain real actions; earlier Semuta offers are passed so C retains Drug.',
|
||||
'T has no reserve or board counter left, then actually reveals its held matching Face Dancer with sources{}. Real replacement returns A9, kills its leader, spends the physical Face Dancer and leaves original BG alone. Stop at the resulting native mandatory conversion before printed Karama cost.',
|
||||
] };
|
||||
}
|
||||
export type SemutaAdvisorFaceDanceFixture = ReturnType<typeof createSemutaAdvisorFaceDanceFixture>;
|
||||
@@ -0,0 +1,250 @@
|
||||
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 { homeworldContext } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options';
|
||||
import { junctionTransportChoice } from '../game/junction-transport-options';
|
||||
import type { HomeworldShipmentSources } from '../game/homeworld-shipment-options';
|
||||
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 SemutaAdvisorHomeworldKind = 'native' | 'junction';
|
||||
export type SemutaAdvisorHomeworldStep = { actor: string; action: Action };
|
||||
export const actSemutaAdvisorHomeworld = actSemutaRicheseCacheKarama;
|
||||
export const passSemutaAdvisorHomeworld = passSemutaFremenGuildMovement;
|
||||
export const claimSemutaAdvisorHomeworld = claimSemutaFremenGuildMovement;
|
||||
export const semutaAdvisorHomeworldPlayer = semutaRicheseCacheKaramaPlayer;
|
||||
export const semutaAdvisorHomeworldInventory = semutaRicheseCacheKaramaInventory;
|
||||
export const semutaAdvisorHomeworldClean = semutaRicheseCacheKaramaClean;
|
||||
export const semutaAdvisorHomeworldPhysical = (game: Game) => ({
|
||||
turn: game.turn, phase: game.phase, storm: game.storm, order: game.order, active: game.active, ready: game.ready,
|
||||
movementRemaining: game.movementRemaining, guildTimingGranted: game.guildTimingGranted,
|
||||
guildTimingLocked: game.guildTimingLocked, homeworlds: game.homeworlds,
|
||||
spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
|
||||
auction: game.auction, richeseAuction: game.richeseAuction, richeseBidding: game.richeseBidding,
|
||||
basicAlliedShipment: game.basicAlliedShipment,
|
||||
players: game.players.map(player => ({ id: player.id, spice: player.spice,
|
||||
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
|
||||
leaders: player.leaders, shipped: player.shipped, moved: player.moved, revived: player.revived, ally: player.ally })),
|
||||
});
|
||||
|
||||
/** Keep setup, Guild timing and typed Homeworld interception native. Neither
|
||||
* the automatic conversion nor a fresh discard is silently answered here. */
|
||||
export function nextSemutaAdvisorHomeworld(game: Game,
|
||||
kind: SemutaAdvisorHomeworldKind = 'native'): SemutaAdvisorHomeworldStep | null {
|
||||
if (game.pendingTreacheryDiscard || game.truthtrance || game.response?.automaticAdvisorFlip) return null;
|
||||
if (!game.phaseOpening && !game.response) {
|
||||
const bg = game.players.find(player => player.faction === 'beneGesserit'); assert.ok(bg);
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||
return { actor: bg.id, action: { type: 'predict', faction: 'atreides', turn: 10 } };
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && !bg.advisorSetup)
|
||||
return { actor: bg.id, action: { type: 'advisorSetup',
|
||||
territory: kind === 'native' ? 'tueks_sietch' : 'arrakeen', sector: kind === 'native' ? 5 : 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;
|
||||
if (game.decision?.kind === 'guildTiming')
|
||||
return { actor: game.decision.player, action: { type: 'decision', take: kind === 'native' } };
|
||||
if (game.decision?.kind === 'homeworldShipmentGuild')
|
||||
return { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, allow: true } };
|
||||
}
|
||||
return nextSemutaRicheseCacheKarama(game);
|
||||
}
|
||||
export function advanceSemutaAdvisorHomeworld(game: Game, until: (state: Game) => boolean,
|
||||
actions: SemutaAdvisorHomeworldStep[] = [], kind: SemutaAdvisorHomeworldKind = 'native'): Game {
|
||||
for (let count = 0; count < 1400; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextSemutaAdvisorHomeworld(game, kind);
|
||||
assert.ok(step, 'Choose the original conversion or fresh discard before continuing');
|
||||
game = actSemutaAdvisorHomeworld(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Homeworld-conversion programme did not reach its finite boundary');
|
||||
}
|
||||
export function allowSemutaAdvisorHomeworld(game: Game,
|
||||
actions: SemutaAdvisorHomeworldStep[] = []): Game {
|
||||
assert.equal(game.response?.automaticAdvisorFlip?.producer, 'homeworldTransport');
|
||||
for (let count = 0; game.response?.kind === 'advisorFlip' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actSemutaAdvisorHomeworld(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.equal(game.response ?? null, null); return game;
|
||||
}
|
||||
/** Both original destinations are native Worlds and have no board-arrival
|
||||
* child. Close only the already-shipped original actor's actual native turn. */
|
||||
export function finishSemutaAdvisorHomeworld(game: Game,
|
||||
actions: SemutaAdvisorHomeworldStep[] = []): Game {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
assert.equal(game.pendingAutomaticAdvisorHomeworldTransport, undefined);
|
||||
assert.equal(game.pendingAutomaticAdvisorFlips, undefined);
|
||||
assert.equal(game.pendingArrivalReaction ?? null, null); assert.equal(game.pendingHomeworldShipment ?? null, null);
|
||||
assert.ok(semutaAdvisorHomeworldClean(game)); assert.equal(game.phase, 5); assert.ok(game.active);
|
||||
const actor = game.active, shipper = semutaAdvisorHomeworldPlayer(game, actor);
|
||||
assert.equal(shipper.shipped, true); assert.equal(shipper.moved, 0);
|
||||
game = actSemutaAdvisorHomeworld(game, actor, { type: 'endMovement' }, actions);
|
||||
assert.ok(!game.movementRemaining?.includes(actor)); return game;
|
||||
}
|
||||
|
||||
/** Original Advanced exact-CHOAM Homeworld initializer, real paid Semuta,
|
||||
* and a native return or sponsored Junction return. Original typed custody,
|
||||
* source quotes, payment and public receipts all come from native actions. */
|
||||
export function createSemutaAdvisorHomeworldFixture(options: { kind: SemutaAdvisorHomeworldKind }) {
|
||||
const { kind } = options, richese = 'richese', holder = 'choam', owner = 'beneGesserit', spender = 'atreides', guild = 'guild';
|
||||
const territory = kind === 'native' ? 'tueks_sietch' : 'arrakeen', sector = kind === 'native' ? 5 : 10;
|
||||
let game = createGame('SEMUTAADVISORHOMEWORLD', newPlayer(richese, '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: SemutaAdvisorHomeworldStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSemutaAdvisorHomeworld(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorHomeworld(game, until, actions, kind); };
|
||||
const transition = (run: () => void) => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
const enableHomeworlds = transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); });
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
|
||||
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
|
||||
entry: 'original-undealt-semuta-homeworld-preview' };
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true);
|
||||
assert.equal(game.homeworlds?.custody, null); assert.deepEqual(game.expansions, ['choam']);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
assert.ok(!game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
|
||||
!game.discoveryEnabled && !game.ecazTreachery);
|
||||
const originalInventory = semutaAdvisorHomeworldInventory(game);
|
||||
assert.equal(originalInventory.length, 45); assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10);
|
||||
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, 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);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), from = `${territory}:${sector}`;
|
||||
assert.ok(game.homeworlds?.custody);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(semutaAdvisorHomeworldPlayer(game, owner).forces[from], 1);
|
||||
assert.equal(isAdvisor(semutaAdvisorHomeworldPlayer(game, owner), territory), true);
|
||||
assert.equal(semutaAdvisorHomeworldPlayer(game, kind === 'native' ? guild : spender).forces[from], kind === 'native' ? 5 : 10);
|
||||
assert.ok(semutaAdvisorHomeworldPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
for (let position = 0; position < 2; position++) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); spiceOrder.push({ position, from, card: structuredClone(card) });
|
||||
}
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SemutaAdvisorHomeworldStep | undefined, bidAt = -1;
|
||||
for (let count = 0; count < 900 && !semutaAdvisorHomeworldPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
|
||||
const step = nextSemutaAdvisorHomeworld(game, kind); 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 really buys the original cache Semuta');
|
||||
const acquired = structuredClone(game), acquisition = { before: beforeBid, bid,
|
||||
steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 };
|
||||
assert.equal(semutaAdvisorHomeworldPlayer(beforeBid, holder).spice - semutaAdvisorHomeworldPlayer(game, holder).spice, 1);
|
||||
assert.equal(semutaAdvisorHomeworldPlayer(game, richese).spice - semutaAdvisorHomeworldPlayer(beforeBid, richese).spice, 1);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && !state.phaseOpening && !state.response && state.decision?.kind === 'advisorBattle');
|
||||
const openingOffer = structuredClone(game);
|
||||
const declinedPreparation = transition(() => {
|
||||
act(owner, { type: 'decision', accept: false });
|
||||
reach(state => state.phase === 5 && state.active === (kind === 'native' ? guild : owner) && semutaAdvisorHomeworldClean(state));
|
||||
});
|
||||
let bgEnd: ReturnType<typeof transition> | null = null;
|
||||
let junctionOffer: ReturnType<typeof transition> | null = null;
|
||||
let offerStep: SemutaAdvisorHomeworldStep | null = null;
|
||||
if (kind === 'junction') {
|
||||
bgEnd = transition(() => {
|
||||
act(owner, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === spender && semutaAdvisorHomeworldClean(state));
|
||||
});
|
||||
const sponsor = viewGame(game, guild).junctionTransport; assert.ok(sponsor?.canOffer);
|
||||
assert.equal(sponsor.recipient, spender);
|
||||
offerStep = { actor: guild, action: { type: 'offerJunctionTransport', event: sponsor.offerEvent, rate: 'half' } };
|
||||
junctionOffer = transition(() => { act(offerStep!.actor, offerStep!.action); });
|
||||
}
|
||||
const shipper = kind === 'native' ? guild : spender, amount = kind === 'native' ? 5 : 10;
|
||||
const destination = `homeworld:${shipper}`, sources: HomeworldShipmentSources = { [from]: { normal: amount, elite: 0 } };
|
||||
const routeQuote = kind === 'native'
|
||||
? guildHomeworldShipmentChoice(viewGame(game, shipper), destination, sources, 0)
|
||||
: junctionTransportChoice(viewGame(game, shipper), destination, sources, 0);
|
||||
assert.equal(routeQuote.blocked, null); assert.ok(routeQuote.action);
|
||||
const nativeAction = routeQuote.action, price = routeQuote.cost;
|
||||
assert.equal(price, kind === 'native' ? 3 : 5);
|
||||
const beforeDeclaration = structuredClone(game);
|
||||
const beforeTypedGroups = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
|
||||
const depositBefore = beforeTypedGroups.find(world => world.id === destination)?.forces[shipper]; assert.ok(depositBefore);
|
||||
const transport = transition(() => {
|
||||
act(shipper, nativeAction);
|
||||
reach(state => state.response?.automaticAdvisorFlip?.producer === 'homeworldTransport');
|
||||
});
|
||||
const actor = semutaAdvisorHomeworldPlayer(game, shipper);
|
||||
assert.equal(semutaAdvisorHomeworldPlayer(transport.before, shipper).spice - actor.spice, price);
|
||||
assert.equal(actor.forces[from], undefined); assert.equal(actor.shipped, true); assert.equal(actor.moved, 0);
|
||||
const afterTypedGroups = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
|
||||
const depositAfter = afterTypedGroups.find(world => world.id === destination)?.forces[shipper]; assert.ok(depositAfter);
|
||||
assert.equal(depositAfter.normal, depositBefore.normal + amount); assert.equal(depositAfter.elite, depositBefore.elite);
|
||||
assert.equal(actor.reserves, semutaAdvisorHomeworldPlayer(transport.before, shipper).reserves + amount);
|
||||
assert.equal(game.response?.kind, 'advisorFlip'); assert.equal(game.response?.owner, owner);
|
||||
assert.equal(game.response?.automaticAdvisorFlip?.producer, 'homeworldTransport');
|
||||
assert.ok(game.pendingAutomaticAdvisorHomeworldTransport && game.pendingAutomaticAdvisorFlips?.length === 1);
|
||||
assert.equal(game.pendingAutomaticAdvisorHomeworldTransport.receipt.kind, kind);
|
||||
assert.equal(game.pendingAutomaticAdvisorHomeworldTransport.arrival, undefined);
|
||||
assert.equal(isAdvisor(semutaAdvisorHomeworldPlayer(game, owner), territory), true);
|
||||
assert.ok(semutaAdvisorHomeworldPlayer(game, owner).advisors?.[territory]?.aloneFlipHold);
|
||||
assert.equal(game.pendingHomeworldShipment ?? null, null);
|
||||
assert.equal(game.phaseOpening ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
assert.ok(semutaAdvisorHomeworldPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
assert.ok(semutaAdvisorHomeworldPlayer(game, spender).hand.some(card => card.id === cost));
|
||||
assert.deepEqual(semutaAdvisorHomeworldInventory(game), originalInventory);
|
||||
const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
|
||||
return { game, source, beforeCost: source, kind, advanced: true as const, richese, holder, owner, bg: owner,
|
||||
spender, atreides: spender, guild, shipper, actor: shipper, cost, semuta: SEMUTA_DRUG_ID, action, nativeAction,
|
||||
from, to: destination, destination, territory, sector, location: from, amount, elite: 0, sources, price, routeQuote,
|
||||
originalResponse: structuredClone(game.response!),
|
||||
originalAutomaticSource: structuredClone(game.response!.automaticAdvisorFlip!),
|
||||
originalParent: structuredClone(game.pendingAutomaticAdvisorHomeworldTransport!),
|
||||
originalQueue: structuredClone(game.pendingAutomaticAdvisorFlips!),
|
||||
originalArrival: structuredClone(game.pendingArrivalReaction ?? null),
|
||||
originalHeldStance: structuredClone(semutaAdvisorHomeworldPlayer(game, owner).advisors![territory]),
|
||||
originalPhysical: structuredClone(semutaAdvisorHomeworldPhysical(game)), originalHistory: structuredClone(game.log),
|
||||
originalCustody: structuredClone(game.homeworlds!.custody!), beforeTypedGroups, afterTypedGroups,
|
||||
depositBefore: structuredClone(depositBefore), depositAfter: structuredClone(depositAfter),
|
||||
initial, enableHomeworlds, lobby, setup, initialization, beforeStartingDeal, afterSetup, dealOrder, spiceOrder, originalSpice,
|
||||
acquired, acquisition, openingOffer, declinedPreparation, bgEnd, junctionOffer, offerStep,
|
||||
beforeDeclaration, transport: { ...transport, price }, originalInventory,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Actual lobby Homeworlds enable action, original Advanced Semuta initializer on the ready undealt R/C/BG/A/G exact-CHOAM roster, and no other optional modules. Original physical native pools remain.',
|
||||
'Select original unopened starting-card positions so Atreides really receives printed Karama, plus two original ordinary Spice positions. CHOAM actually purchases original cache Semuta for one spice.',
|
||||
kind === 'native'
|
||||
? 'Actual advisorSetup at Tueks Sietch5 beside Guilds five native board counters. Native Storm, Spice, Charity, Bidding and Revival execute, BG declines preparation, and Guild takes its actual early turn.'
|
||||
: 'Actual advisorSetup at Arrakeen10 beside Atreides ten native board counters. Native Storm, Spice, Charity, Bidding and Revival execute, BG declines preparation and Guild waits. BG really ends its turn; Guild then offers its real current high-Junction half tariff to Atreides.',
|
||||
kind === 'native'
|
||||
? 'Actual owner-view guildHomeworldShipmentChoice returns G5 from Tueks to Junction for three. Native Homeworld interception, transport permission and Guild rate are allowed; payment and typed custody commit before the automatic conversion.'
|
||||
: 'Actual owner-view junctionTransportChoice returns A10 from Arrakeen to Caladan for five. The original sponsored offer, payment and typed custody commit before the automatic conversion; any original Guild income resolves through native passes.',
|
||||
'Stop at the original homeworldTransport advisorFlip before Atreides spends its original printed Karama. No played hand, counter, stance, wallet, custody, clock, source or response is installed. Finish closes only that original shipped actor turn with actual endMovement.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type SemutaAdvisorHomeworldFixture = ReturnType<typeof createSemutaAdvisorHomeworldFixture>;
|
||||
@@ -0,0 +1,199 @@
|
||||
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 { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import {
|
||||
createSemutaAdvisorBattleFixture as fixture, actSemutaAdvisorBattle as act,
|
||||
passSemutaAdvisorBattle as pass, claimSemutaAdvisorBattle as claim,
|
||||
allowSemutaAdvisorBattle as allow, finishSemutaAdvisorBattle as finish,
|
||||
semutaAdvisorBattlePlayer as player, semutaAdvisorBattleInventory as inventory,
|
||||
semutaAdvisorBattleTraitors as traitors, semutaAdvisorBattlePhysical as physical,
|
||||
type SemutaAdvisorBattleFixture,
|
||||
} from './fixture-semuta-advisor-battle';
|
||||
|
||||
const balances = (game: Game) => Object.fromEntries(game.players.map(p => [p.id, p.spice]));
|
||||
const clock = (game: Game) => ({ turn: game.turn, phase: game.phase, active: game.active, ready: game.ready,
|
||||
order: game.order, lastBattle: game.lastBattle, movementRemaining: game.movementRemaining,
|
||||
storm: game.storm, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard });
|
||||
const battleFacts = (game: Game) => {
|
||||
const b = game.lastBattleContext; assert.ok(b);
|
||||
return { event: b.event, turn: b.turn, territory: b.territory, combatants: b.combatants,
|
||||
winner: b.winner, result: b.result };
|
||||
};
|
||||
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: SemutaAdvisorBattleFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.ok(f.beforeTraitorDeal.players.every(p => !p.hand.length && !p.traitorChoices.length && !p.traitors.length));
|
||||
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.harkonnen).hand.length, 2);
|
||||
assert.ok(player(f.afterSetup, f.atreides).hand.some(card => card.id === f.shield));
|
||||
assert.equal(player(f.afterSetup, f.owner).forces[f.location], 1);
|
||||
assert.equal(player(f.afterSetup, f.atreides).forces[f.location], 10);
|
||||
assert.equal(player(f.afterSetup, f.harkonnen).forces['carthag:11'], 10);
|
||||
assert.equal(f.traitorScalars.length, f.originalTraitors.length - 1);
|
||||
assert.deepEqual(traitors(f.afterSetup), f.originalTraitors);
|
||||
if (f.mutualTraitors) {
|
||||
assert.ok(player(f.afterSetup, f.atreides).traitors.includes('harkonnen-3'));
|
||||
assert.ok(player(f.afterSetup, f.harkonnen).traitors.includes('atreides-0'));
|
||||
}
|
||||
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.ok(f.atreidesDeparture.steps.some(step => step.actor === f.atreides && step.action.type === 'move' && step.action.amount === 9));
|
||||
assert.ok(f.harkonnenArrival.steps.some(step => step.actor === f.harkonnen && step.action.type === 'move' && step.action.amount === 1));
|
||||
assert.equal(player(f.beforeBattle, f.atreides).forces[f.location], 1);
|
||||
assert.equal(player(f.beforeBattle, f.harkonnen).forces[f.location], 1);
|
||||
assert.equal(player(f.beforeBattle, f.atreides).forces['imperial_basin:10'], 9);
|
||||
assert.equal(player(f.beforeBattle, f.harkonnen).forces['carthag:11'], 9);
|
||||
assert.equal(f.planSteps[0].action.dial, 0.5); assert.equal(f.planSteps[0].action.support, 0);
|
||||
assert.equal(f.planSteps[0].action.defense, f.shield); assert.equal(f.planSteps[1].action.weapon, null);
|
||||
assert.equal(f.revealed.battle?.revealed, true); assert.deepEqual(f.revealed.battle?.plans, f.originalPlans);
|
||||
assert.equal(f.voteSteps.length, 2); assert.deepEqual(new Set(f.voteSteps.map(step => step.actor)), new Set([f.atreides, f.harkonnen]));
|
||||
assert.ok(f.voteSteps.every(step => step.action.call === f.mutualTraitors));
|
||||
for (const disposal of f.disposals) {
|
||||
assert.deepEqual(disposal.before.pendingTreacheryDiscard?.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy })),
|
||||
[{ card: f.shield, by: f.atreides }]);
|
||||
assert.ok(disposal.steps.every(step => step.action.type === 'semutaPass'));
|
||||
assert.notEqual(disposal.after.pendingTreacheryDiscard?.batch.event, disposal.event);
|
||||
}
|
||||
assert.equal(f.disposals.length, Number(f.mutualTraitors));
|
||||
assert.equal(f.game.battle, null); assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(f.game.pendingWinnerDiscards ?? null, null); assert.equal(f.game.decision ?? null, null);
|
||||
assert.equal(f.game.phase, 6); assert.equal(f.game.turn, 1);
|
||||
assert.equal(f.game.response?.kind, 'advisorFlip'); assert.deepEqual(f.game.response, f.originalResponse);
|
||||
assert.equal(f.originalAutomaticSource.producer, 'battleLosses'); assert.equal(f.originalAutomaticSource.battle.event, f.event);
|
||||
assert.equal(f.originalAutomaticSource.battle.result, f.mutualTraitors ? 'mutualTraitors' : 'normal');
|
||||
assert.equal(f.originalAutomaticSource.battle.winner, f.winner);
|
||||
assert.deepEqual(f.game.pendingAutomaticAdvisorBattle, f.originalParent);
|
||||
assert.deepEqual(f.game.pendingAutomaticAdvisorFlips, f.originalQueue);
|
||||
assert.equal(f.originalParent.resume?.kind, f.mutualTraitors ? 'aftermath' : 'winner');
|
||||
assert.equal(f.originalParent.signature, JSON.stringify(f.originalParent.resume));
|
||||
assert.equal(f.originalStance.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
assert.equal(player(f.game, f.atreides).tanks, 1); assert.equal(player(f.game, f.harkonnen).tanks, 1);
|
||||
assert.equal(player(f.game, f.atreides).forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(f.game, f.harkonnen).forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(f.game, f.owner).forces[f.location], 1); assert.equal(isAdvisor(player(f.game, f.owner), f.territory), true);
|
||||
assert.equal(f.game.discard.filter(card => card.id === f.shield).length, Number(f.mutualTraitors));
|
||||
assert.equal(player(f.game, f.atreides).hand.some(card => card.id === f.shield), !f.mutualTraitors);
|
||||
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.deepEqual(traitors(f.game), f.originalTraitors); assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaAdvisorBattleFixture) {
|
||||
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 committed battle conversion 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.pendingAutomaticAdvisorBattle, f.originalParent);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue);
|
||||
assert.deepEqual(game.lastBattleContext, f.originalBattle); assert.equal(game.battle, null);
|
||||
assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalStance);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(balances(game), f.originalBalances);
|
||||
assert.deepEqual(clock(game), clock(f.game)); assert.equal(game.discard.filter(card => card.id === f.shield).length, Number(f.mutualTraitors));
|
||||
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function nativeOutcome(state: Game, f: SemutaAdvisorBattleFixture, canceled: boolean) {
|
||||
assert.equal(state.pendingAutomaticAdvisorBattle ?? null, null); assert.deepEqual(state.pendingAutomaticAdvisorFlips ?? [], []);
|
||||
assert.equal(state.response?.automaticAdvisorFlip ?? null, null); assert.equal(state.battle, null);
|
||||
assert.deepEqual(physical(state), f.originalPhysical); assert.deepEqual(battleFacts(state), battleFacts(f.game));
|
||||
assert.equal(isAdvisor(player(state, f.owner), f.territory), canceled);
|
||||
if (canceled) assert.deepEqual(player(state, f.owner).advisors?.[f.territory], f.originalStance);
|
||||
if (!f.mutualTraitors) {
|
||||
assert.deepEqual(state.decision, { kind: 'battleCards', player: f.atreides, territory: f.territory, cards: [f.shield] });
|
||||
assert.ok(player(state, f.atreides).hand.some(card => card.id === f.shield));
|
||||
}
|
||||
const cleanupSteps: { actor: string; action: Action }[] = [], game = finish(state, cleanupSteps);
|
||||
assert.equal(game.phase, 7); assert.equal(game.turn, 1); assert.equal(game.battle, null);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingWinnerDiscards ?? null, null);
|
||||
assert.equal(game.pendingAutomaticAdvisorBattle ?? null, null); assert.deepEqual(game.pendingAutomaticAdvisorFlips ?? [], []);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(battleFacts(game), battleFacts(f.game));
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.territory), canceled);
|
||||
if (canceled) assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalStance);
|
||||
assert.equal(game.discard.filter(card => card.id === f.shield).length, Number(f.mutualTraitors));
|
||||
assert.equal(player(game, f.atreides).hand.some(card => card.id === f.shield), !f.mutualTraitors);
|
||||
const keepReplies = cleanupSteps.filter(step => step.actor === f.atreides && step.action.type === 'decision' && Array.isArray(step.action.discard));
|
||||
assert.equal(keepReplies.length, Number(!f.mutualTraitors));
|
||||
if (keepReplies.length) assert.deepEqual(keepReplies[0].action.discard, []);
|
||||
assert.ok(cleanupSteps.every(step => !['battlePlan', 'traitorCall', 'chooseBattle'].includes(step.action.type)));
|
||||
// Quote the actual resulting board at the already earned pre-Collection
|
||||
// balances. This is a read-only expectation, not an installed income source.
|
||||
const collection = quoteSpiceCollection({ ...game, spice: f.game.spice,
|
||||
players: game.players.map(p => ({ ...p, spice: f.originalBalances[p.id] })) });
|
||||
for (const receipt of collection.receipts) assert.equal(player(game, receipt.player).spice, receipt.balance);
|
||||
assert.deepEqual(game.spice, collection.spice);
|
||||
assert.equal(player(game, f.owner).spice, f.originalBalances[f.owner] + (canceled ? 0 : 2));
|
||||
assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(traitors(game), f.originalTraitors);
|
||||
for (const step of f.planSteps) reject(game, step.actor, step.action);
|
||||
return game;
|
||||
}
|
||||
function denied(state: Game, f: SemutaAdvisorBattleFixture, claimed: boolean) {
|
||||
const game = nativeOutcome(state, f, true);
|
||||
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));
|
||||
reject(game, f.spender, f.action); return game;
|
||||
}
|
||||
|
||||
for (const mutualTraitors of [false, true]) for (const claimed of [false, true]) void test(
|
||||
`native ${mutualTraitors ? 'mutual Traitors' : 'unsupported winner'} battle conversion cost Semuta ${claimed ? 'claim' : 'pass'} preserves losses and resumes original cleanup once`, () => {
|
||||
const f = fixture({ mutualTraitors }); nativeSource(f); const offered = open(f);
|
||||
denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
|
||||
},
|
||||
);
|
||||
|
||||
void test('native committed battle conversion allowance flips the real surviving advisor before original cleanup and Collection', () => {
|
||||
for (const mutualTraitors of [false, true]) {
|
||||
const f = fixture({ mutualTraitors }); nativeSource(f); const game = nativeOutcome(allow(structuredClone(f.game)), f, false);
|
||||
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));
|
||||
}
|
||||
});
|
||||
|
||||
void test('native battle conversion cost rejects stale foreign disposal and underlying winner replies before cleanup', () => {
|
||||
for (const mutualTraitors of [false, true]) {
|
||||
const f = fixture({ mutualTraitors }), 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.atreides, { type: 'decision', discard: [] }); reject(game, f.owner, { type: 'passResponse' });
|
||||
for (const step of f.planSteps) reject(game, step.actor, step.action);
|
||||
for (const disposal of f.disposals) reject(game, f.holder, { type: 'semutaCommit', event: disposal.event });
|
||||
const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally claim or pass native normal and mutual-Traitor battle conversion costs', () => {
|
||||
for (const mutualTraitors of [false, true]) {
|
||||
const f = fixture({ mutualTraitors }), 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, difficulty);
|
||||
assert.ok(proposed.type === 'semutaPass' || proposed.type === 'semutaCommit'); 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
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 { fighterCount, isAdvisor } from '../game/advisors';
|
||||
import { quoteAutomaticAdvisorFlips } from '../game/advisor-automatic-flip';
|
||||
import {
|
||||
createSemutaAdvisorFaceDanceFixture as fixture, actSemutaAdvisorFaceDance as act,
|
||||
passSemutaAdvisorFaceDance as pass, claimSemutaAdvisorFaceDance as claim,
|
||||
allowSemutaAdvisorFaceDance as allow, finishSemutaAdvisorFaceDance as finish,
|
||||
semutaAdvisorFaceDancePlayer as player, semutaAdvisorFaceDanceInventory as inventory,
|
||||
semutaAdvisorFaceDancePhysical as physical, semutaAdvisorFaceDanceTraitors as traitors,
|
||||
type SemutaAdvisorFaceDanceFixture,
|
||||
} from './fixture-semuta-advisor-face-dance';
|
||||
|
||||
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: SemutaAdvisorFaceDanceFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.deepEqual(f.setup.expansions, ['choam', 'ix']); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'beneGesserit', 'tleilaxu', '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.advisorSetup.steps.length, 1); assert.equal(f.advisorSetup.steps[0].action.type, 'advisorSetup');
|
||||
assert.equal(player(f.afterSetup, f.owner).forces[f.location], 1);
|
||||
assert.equal(isAdvisor(player(f.afterSetup, f.owner), f.territory), true);
|
||||
assert.equal(player(f.afterSetup, f.winner).forces[f.location], 10);
|
||||
assert.equal(f.dancers.length, 3); assert.ok(f.dancers.every(card => !card.revealed));
|
||||
assert.ok(f.dancers.some(card => card.leader === f.winnerLeader));
|
||||
assert.deepEqual([...f.faceDancerDeal.order].sort(), [...f.faceDancerDeal.original].sort());
|
||||
assert.equal(f.faceDancerDeal.scalars.length, f.faceDancerDeal.original.length - 1);
|
||||
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.deepEqual(f.funding.steps, [
|
||||
{ actor: f.winner, action: { type: 'bribe', target: f.tleilaxu, amount: 9 } },
|
||||
{ actor: f.spender, action: { type: 'bribe', target: f.tleilaxu, amount: 10 } },
|
||||
]);
|
||||
assert.equal(player(f.funding.after, f.tleilaxu).spice, player(f.funding.before, f.tleilaxu).spice);
|
||||
assert.equal(player(f.funding.after, f.tleilaxu).bribes, 19);
|
||||
assert.equal(player(f.fundingCollection.after, f.tleilaxu).spice - player(f.fundingCollection.before, f.tleilaxu).spice, 19);
|
||||
assert.equal(player(f.fundingCollection.after, f.tleilaxu).bribes, 0);
|
||||
assert.equal(f.shipment.before.turn, 2); assert.equal(f.shipment.before.phase, 5);
|
||||
assert.deepEqual(f.shipment.steps[0], { actor: f.tleilaxu,
|
||||
action: { type: 'ship', territory: f.territory, sector: f.sector, amount: 20, elite: 0, allyPayment: 0 } });
|
||||
assert.equal(player(f.shipment.before, f.tleilaxu).spice - player(f.shipment.after, f.tleilaxu).spice, 20);
|
||||
assert.equal(player(f.shipment.before, f.tleilaxu).reserves, 20);
|
||||
assert.equal(player(f.shipment.after, f.tleilaxu).reserves, 0);
|
||||
assert.equal(player(f.shipment.after, f.tleilaxu).forces[f.location], 20);
|
||||
assert.equal(f.planSteps.length, 2);
|
||||
assert.ok(f.planSteps.some(step => step.actor === f.winner && step.action.dial === 1 && step.action.support === 1));
|
||||
assert.ok(f.planSteps.some(step => step.actor === f.tleilaxu && step.action.dial === 0));
|
||||
assert.equal(f.cleanup.steps.filter(step => step.action.type === 'traitorCall' && step.action.call === false).length, 2);
|
||||
assert.equal(f.cleanup.steps.filter(step => step.action.type === 'battleOutcomeReady').length, 2);
|
||||
assert.equal(f.replacement.before.decision?.kind, 'faceDance');
|
||||
assert.equal(player(f.replacement.before, f.tleilaxu).reserves, 0);
|
||||
assert.equal(Object.values(player(f.replacement.before, f.tleilaxu).forces).reduce((sum, n) => sum + n, 0), 0);
|
||||
assert.equal(player(f.replacement.before, f.tleilaxu).tanks, 20,
|
||||
'All replacement stock is actually exhausted by the funded native battle');
|
||||
assert.equal(player(f.replacement.before, f.winner).forces[f.location], 9);
|
||||
assert.deepEqual(f.replacement.steps, [{ actor: f.tleilaxu, action: f.faceDanceAction }]);
|
||||
assert.equal(f.game.phase, 6); assert.equal(f.game.turn, 2); assert.equal(f.game.battle, null); assert.equal(f.game.pendingFaceDance, null);
|
||||
assert.equal(f.originalBattle.event, f.originalBattleContext.event); assert.equal(f.originalBattleContext.result, 'normal');
|
||||
assert.equal(f.originalBattleContext.winner, f.winner);
|
||||
assert.equal(f.originalAutomaticSource.producer, 'faceDance'); assert.deepEqual(f.originalAutomaticSource.faceDance, f.originalParent);
|
||||
assert.equal(f.originalParent.amount, 0); assert.deepEqual(f.originalParent.sources, {});
|
||||
assert.deepEqual(f.originalParent.returned, { [f.winner]: 9 });
|
||||
assert.deepEqual(f.originalAutomaticSource.forces, { [f.location]: 1 });
|
||||
assert.deepEqual(f.originalQueue, [f.originalAutomaticSource]);
|
||||
assert.equal(player(f.game, f.winner).reserves, player(f.replacement.before, f.winner).reserves + 9);
|
||||
const dead = player(f.game, f.winner).leaders.find(leader => leader.id === f.winnerLeader)!;
|
||||
const living = player(f.replacement.before, f.winner).leaders.find(leader => leader.id === f.winnerLeader)!;
|
||||
assert.equal(living.dead, false); assert.equal(dead.dead, true); assert.equal(dead.deaths, living.deaths + 1);
|
||||
assert.equal(player(f.game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)?.revealed, true);
|
||||
assert.equal(player(f.game, f.tleilaxu).faceDancers!.filter(card => card.revealed).length, 1);
|
||||
assert.deepEqual(f.game.players.map(p => p.spice), f.replacement.before.players.map(p => p.spice), 'Face Dance supplies no invented bounty');
|
||||
assert.equal(player(f.game, f.owner).advisors?.[f.territory]?.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
assert.ok(f.actions.every(step => step.action.type !== 'semutaCommit' && step.action.type !== 'semutaSelect'));
|
||||
assert.equal(f.originalInventory.length, 57); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.deepEqual(traitors(f.game), f.originalTraitors); assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaAdvisorFaceDanceFixture) {
|
||||
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 actual completed Face Dance conversion 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.equal(game.battle, null); assert.equal(game.pendingFaceDance, null);
|
||||
assert.deepEqual(game.lastBattleContext, f.originalBattleContext);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFaceDance, f.originalParent);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(player(game, f.owner).advisors, f.originalStance);
|
||||
assert.deepEqual(game.players.map(p => ({ id: p.id, spice: p.spice })), f.originalBalances);
|
||||
assert.deepEqual(player(game, f.tleilaxu).faceDancers, f.originalFaceDancers);
|
||||
assert.deepEqual(game.deck, f.originalDeck); 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); assert.deepEqual(traitors(game), f.originalTraitors); return game;
|
||||
}
|
||||
function finished(state: Game, f: SemutaAdvisorFaceDanceFixture, prevented: boolean) {
|
||||
const steps: { actor: string; action: Action }[] = [], game = finish(state, steps);
|
||||
assert.equal(game.phase, 7); assert.equal(game.turn, 2); assert.equal(game.battle, null); assert.equal(game.pendingFaceDance, null);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingAutomaticAdvisorFaceDance, undefined);
|
||||
assert.equal(game.pendingAutomaticAdvisorFlips, undefined); assert.deepEqual(game.lastBattleContext, f.originalBattleContext);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(player(game, f.tleilaxu).faceDancers, f.originalFaceDancers);
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.territory), prevented);
|
||||
assert.equal(fighterCount(player(game, f.owner), f.territory), prevented ? 0 : 1);
|
||||
if (prevented) assert.deepEqual(player(game, f.owner).advisors, f.originalStance);
|
||||
else assert.equal(player(game, f.owner).advisors?.[f.territory], undefined);
|
||||
assert.equal(player(game, f.owner).spice, f.originalBalances.find(p => p.id === f.owner)!.spice + (prevented ? 0 : 2));
|
||||
assert.equal(player(game, f.tleilaxu).tanks, 20); assert.equal(player(game, f.tleilaxu).reserves, 0);
|
||||
assert.equal(player(game, f.winner).forces[f.location] ?? 0, 0);
|
||||
assert.deepEqual(quoteAutomaticAdvisorFlips(game), []); assert.deepEqual(game.deck, f.originalDeck);
|
||||
assert.ok(steps.every(step => !['ship', 'move', 'card', 'battlePlan', 'traitorCall', 'chooseBattle'].includes(step.action.type)));
|
||||
const later = game.log.slice(f.game.log.length);
|
||||
assert.equal(later.filter(row => row.text.includes('revealed a Face Dancer and replaced')).length, 0);
|
||||
assert.equal(later.filter(row => row.text.includes('drew three new Face Dancers')).length, 0);
|
||||
reject(game, f.tleilaxu, f.faceDanceAction);
|
||||
assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(traitors(game), f.originalTraitors); return game;
|
||||
}
|
||||
function canceled(state: Game, f: SemutaAdvisorFaceDanceFixture, claimed: boolean) {
|
||||
const game = finished(state, f, true);
|
||||
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.spender, f.action); return game;
|
||||
}
|
||||
|
||||
for (const claimed of [false, true]) void test(
|
||||
`Advanced native exhausted-stock Face Dance advisor conversion cost Semuta ${claimed ? 'claim' : 'pass'} preserves the real return and revealed Face Dancer then resumes cleanup once`, () => {
|
||||
const f = fixture(); 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 });
|
||||
},
|
||||
);
|
||||
|
||||
void test('native exhausted-stock Face Dance allowance converts the surviving BG counter and collects its actual stronghold income once', () => {
|
||||
const f = fixture(); nativeSource(f); const game = finished(allow(structuredClone(f.game)), f, false);
|
||||
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));
|
||||
});
|
||||
|
||||
void test('the genuine zero-stock source rejects a positive replacement and fresh cost rejects stale underlying replay', () => {
|
||||
const f = fixture();
|
||||
reject(f.replacement.before, f.tleilaxu, { ...f.faceDanceAction, sources: { reserves: 1 } });
|
||||
const 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.tleilaxu, f.faceDanceAction); reject(game, f.owner, { type: 'passResponse' });
|
||||
reject(game, f.winner, f.planSteps[0].action); reject(game, f.tleilaxu, f.planSteps[1].action);
|
||||
reject(game, f.owner, { type: 'prepareAdvisors', territory: f.territory });
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFaceDance, f.originalParent);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); canceled(pass(game), f, false);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally answer the actual exhausted-stock Face Dance conversion used cost', () => {
|
||||
const f = fixture(), 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);
|
||||
canceled(game, f, claimed);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
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 { homeworldContext } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createSemutaAdvisorHomeworldFixture as fixture, actSemutaAdvisorHomeworld as act,
|
||||
passSemutaAdvisorHomeworld as pass, claimSemutaAdvisorHomeworld as claim,
|
||||
allowSemutaAdvisorHomeworld as allow, finishSemutaAdvisorHomeworld as finish,
|
||||
semutaAdvisorHomeworldPlayer as player, semutaAdvisorHomeworldInventory as inventory,
|
||||
semutaAdvisorHomeworldPhysical as physical, type SemutaAdvisorHomeworldFixture,
|
||||
} from './fixture-semuta-advisor-homeworld';
|
||||
|
||||
const transportLogs = (game: Game, f: SemutaAdvisorHomeworldFixture) => game.log.filter(row =>
|
||||
row.automatic?.name === (f.kind === 'native' ? 'Homeworld shipment' : 'Junction transport')).length;
|
||||
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: SemutaAdvisorHomeworldFixture) {
|
||||
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true);
|
||||
assert.equal(f.setup.homeworlds?.custody, null); assert.ok(f.afterSetup.homeworlds?.custody);
|
||||
assert.deepEqual(f.setup.expansions, ['choam']);
|
||||
assert.deepEqual(f.enableHomeworlds.steps, [{ actor: f.initial.host, action: { type: 'homeworlds', enabled: true } }]);
|
||||
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'beneGesserit', 'atreides', 'guild']);
|
||||
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
|
||||
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'predict' && step.action.faction === 'atreides'));
|
||||
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'advisorSetup' &&
|
||||
step.action.territory === f.territory && step.action.sector === f.sector));
|
||||
assert.equal(player(f.afterSetup, f.owner).forces[f.from], 1);
|
||||
assert.equal(player(f.afterSetup, f.shipper).forces[f.from], f.amount);
|
||||
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.equal(f.openingOffer.decision?.kind, 'advisorBattle');
|
||||
if (f.kind === 'junction') {
|
||||
assert.ok(f.bgEnd && f.offerStep && f.junctionOffer);
|
||||
assert.deepEqual(f.bgEnd.steps[0], { actor: f.owner, action: { type: 'endMovement' } });
|
||||
assert.equal(f.offerStep.actor, f.guild); assert.equal(f.offerStep.action.type, 'offerJunctionTransport');
|
||||
assert.equal(f.offerStep.action.rate, 'half'); assert.deepEqual(f.junctionOffer.steps, [f.offerStep]);
|
||||
} else assert.equal(f.junctionOffer, null);
|
||||
assert.equal(f.routeQuote.blocked, null); assert.deepEqual(f.routeQuote.action, f.nativeAction);
|
||||
assert.equal(f.transport.before.active, f.shipper); assert.equal(player(f.transport.before, f.shipper).shipped, false);
|
||||
assert.deepEqual(f.transport.steps[0], { actor: f.shipper, action: f.nativeAction });
|
||||
assert.equal(f.price, f.kind === 'native' ? 3 : 5);
|
||||
assert.equal(player(f.game, f.shipper).spice, player(f.transport.before, f.shipper).spice - f.price);
|
||||
assert.equal(player(f.game, f.shipper).forces[f.from], undefined);
|
||||
assert.equal(player(f.game, f.shipper).reserves, player(f.transport.before, f.shipper).reserves + f.amount);
|
||||
assert.equal(f.depositAfter.normal, f.depositBefore.normal + f.amount);
|
||||
assert.equal(f.depositAfter.elite, f.depositBefore.elite);
|
||||
assert.equal(f.depositAfter.normal + f.depositAfter.elite, 20);
|
||||
assert.equal(player(f.game, f.shipper).shipped, true); assert.equal(player(f.game, f.shipper).moved, 0);
|
||||
assert.equal(f.game.active, f.shipper); assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5);
|
||||
assert.deepEqual(f.game.response, f.originalResponse);
|
||||
assert.ok(f.originalAutomaticSource.producer === 'homeworldTransport');
|
||||
assert.deepEqual(f.originalAutomaticSource.transport, f.originalParent.receipt);
|
||||
assert.deepEqual(f.originalQueue, [f.originalAutomaticSource]);
|
||||
assert.equal(f.originalParent.receipt.kind, f.kind); assert.equal(f.originalParent.receipt.player, f.shipper);
|
||||
assert.equal(f.originalParent.receipt.destination, f.destination);
|
||||
assert.equal(f.originalParent.receipt.amount, f.amount); assert.equal(f.originalParent.receipt.elite, 0);
|
||||
assert.deepEqual(f.originalParent.receipt.group, { [f.from]: f.amount });
|
||||
assert.ok(f.originalParent.sourceEvent.length > 0); assert.equal(f.originalParent.arrival, undefined);
|
||||
assert.equal(f.originalHeldStance.aloneFlipHold, f.originalAutomaticSource.event);
|
||||
assert.equal(transportLogs(f.game, f) - transportLogs(f.transport.before, f), 1);
|
||||
assert.deepEqual(physical(f.game), f.originalPhysical);
|
||||
assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function open(f: SemutaAdvisorHomeworldFixture) {
|
||||
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.pendingAutomaticAdvisorHomeworldTransport, f.originalParent);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue);
|
||||
assert.deepEqual(game.pendingArrivalReaction ?? null, f.originalArrival);
|
||||
assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalHeldStance);
|
||||
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); return game;
|
||||
}
|
||||
function settled(game: Game, f: SemutaAdvisorHomeworldFixture, canceled: boolean) {
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null);
|
||||
assert.equal(game.pendingAutomaticAdvisorHomeworldTransport, undefined); assert.equal(game.pendingAutomaticAdvisorFlips, undefined);
|
||||
assert.equal(game.pendingArrivalReaction ?? null, null); assert.equal(game.pendingHomeworldShipment ?? null, null);
|
||||
assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null);
|
||||
assert.equal(game.phaseOpening ?? null, null); assert.deepEqual(physical(game), f.originalPhysical);
|
||||
assert.equal(isAdvisor(player(game, f.owner), f.territory), canceled);
|
||||
if (canceled) assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalHeldStance);
|
||||
else assert.equal(player(game, f.owner).advisors?.[f.territory], undefined);
|
||||
const deposited = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
|
||||
.find(world => world.id === f.destination)?.forces[f.shipper];
|
||||
assert.deepEqual(deposited, f.depositAfter);
|
||||
assert.equal(transportLogs(game, f), transportLogs(f.game, f));
|
||||
assert.deepEqual(inventory(game), f.originalInventory); return game;
|
||||
}
|
||||
function prevented(game: Game, f: SemutaAdvisorHomeworldFixture, claimed: boolean) {
|
||||
game = settled(game, f, true);
|
||||
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)); return game;
|
||||
}
|
||||
function closeOriginalTurn(game: Game, f: SemutaAdvisorHomeworldFixture, canceled: boolean) {
|
||||
const steps: { actor: string; action: Action }[] = [], shipper = structuredClone(player(game, f.shipper));
|
||||
const result = finish(game, steps);
|
||||
assert.deepEqual(steps, [{ actor: f.shipper, action: { type: 'endMovement' } }]);
|
||||
assert.ok(!result.movementRemaining?.includes(f.shipper));
|
||||
assert.equal(player(result, f.shipper).spice, shipper.spice);
|
||||
assert.deepEqual(player(result, f.shipper).forces, shipper.forces);
|
||||
assert.equal(player(result, f.shipper).reserves, shipper.reserves);
|
||||
assert.equal(player(result, f.shipper).moved, 0);
|
||||
assert.deepEqual(result.homeworlds!.custody, f.originalCustody);
|
||||
assert.equal(isAdvisor(player(result, f.owner), f.territory), canceled);
|
||||
assert.equal(result.pendingAutomaticAdvisorHomeworldTransport, undefined); assert.equal(result.pendingAutomaticAdvisorFlips, undefined);
|
||||
assert.equal(transportLogs(result, f), transportLogs(f.game, f));
|
||||
assert.deepEqual(inventory(result), f.originalInventory); return result;
|
||||
}
|
||||
|
||||
for (const kind of ['native', 'junction'] as const) for (const claimed of [false, true]) void test(
|
||||
`Advanced ${kind} Homeworld transport advisor conversion used cancellation cost ${claimed ? 'claim' : 'pass'} retains the paid typed return and resumes once`, () => {
|
||||
const f = fixture({ kind }); 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 });
|
||||
closeOriginalTurn(game, f, true);
|
||||
});
|
||||
|
||||
for (const kind of ['native', 'junction'] as const) void test(
|
||||
`Advanced ${kind} Homeworld transport conversion allowance flips only the stranded advisor after payment and typed deposit`, () => {
|
||||
const f = fixture({ kind }); nativeSource(f);
|
||||
const game = settled(allow(structuredClone(f.game)), f, false);
|
||||
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));
|
||||
closeOriginalTurn(game, f, false);
|
||||
});
|
||||
|
||||
for (const kind of ['native', 'junction'] as const) void test(
|
||||
`fresh ${kind} Homeworld conversion costs reject stale and underlying controls while typed return evidence stays parked`, () => {
|
||||
const f = fixture({ kind }), 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.shipper, f.nativeAction); reject(game, f.shipper, { type: 'endMovement' });
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorHomeworldTransport, f.originalParent);
|
||||
assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue);
|
||||
assert.deepEqual(physical(game), f.originalPhysical); prevented(pass(game), f, false);
|
||||
});
|
||||
|
||||
void test('changed original Homeworld source rejects before cost and before a fresh Semuta answer', () => {
|
||||
const f = fixture({ kind: 'native' }), beforeCost = structuredClone(f.game);
|
||||
assert.ok(beforeCost.pendingAutomaticAdvisorHomeworldTransport);
|
||||
beforeCost.pendingAutomaticAdvisorHomeworldTransport.sourceEvent = 'different-original-source';
|
||||
reject(beforeCost, f.spender, f.action);
|
||||
assert.ok(player(beforeCost, f.spender).hand.some(card => card.id === f.cost));
|
||||
assert.equal(beforeCost.discard.some(card => card.id === f.cost), false);
|
||||
const afterCost = open(f); assert.ok(afterCost.pendingAutomaticAdvisorHomeworldTransport);
|
||||
afterCost.pendingAutomaticAdvisorHomeworldTransport.sourceEvent = 'different-original-source';
|
||||
reject(afterCost, f.holder, { type: 'semutaCommit', event: afterCost.pendingTreacheryDiscard!.batch.event });
|
||||
});
|
||||
|
||||
void test('all four minimal policies answer both actual typed Homeworld transport conversion costs legally', () => {
|
||||
for (const kind of ['native', 'junction'] as const) {
|
||||
const f = fixture({ kind }), 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);
|
||||
closeOriginalTurn(prevented(game, f, claimed), f, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user