Offer Semuta on pending revival benefit cancellation costs

This commit is contained in:
Quintine committed 2026-10-26 02:00:26 +11:00
1 parent d018a506e1
commit 4d09b6bffa
9 files changed
+1152 -5

No files matched your search

@@ -0,0 +1,252 @@
import assert from 'node:assert/strict';
import {
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game,
} from '../game/engine';
import { TERRITORIES, location } from '../game/board';
import { quoteRevivalCancellation } from '../game/revival-cancellation';
import { quoteRevivalResume } from '../game/revival-resume';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import {
actSemutaRevivalIncome, advanceSemutaRevivalIncome, nextSemutaRevivalIncomeStep,
semutaRevivalIncomeClean, semutaRevivalIncomeInventory, semutaRevivalIncomePlayer,
passSemutaRevivalIncome, claimSemutaRevivalIncome,
type SemutaRevivalIncomeStep,
} from './fixture-semuta-revival-income';
export type SemutaRevivalBenefitsForcesKind = 'choamRevival' | 'revivalLimit' | 'revivalDiscount';
export type SemutaRevivalBenefitsForcesStep = SemutaRevivalIncomeStep;
export const actSemutaRevivalBenefitsForces = actSemutaRevivalIncome;
export const nextSemutaRevivalBenefitsForces = nextSemutaRevivalIncomeStep;
export const advanceSemutaRevivalBenefitsForces = advanceSemutaRevivalIncome;
export const semutaRevivalBenefitsForcesPlayer = semutaRevivalIncomePlayer;
export const semutaRevivalBenefitsForcesInventory = semutaRevivalIncomeInventory;
export const semutaRevivalBenefitsForcesClean = semutaRevivalIncomeClean;
export const passSemutaRevivalBenefitsForces = passSemutaRevivalIncome;
export const claimSemutaRevivalBenefitsForces = claimSemutaRevivalIncome;
/** Pass exactly the current native window, leaving a subsequent benefit or
* earned-income window explicit. This never submits another revival request. */
export function allowSemutaRevivalBenefitsForces(game: Game,
actions: SemutaRevivalBenefitsForcesStep[] = []): Game {
const response = game.response; assert.ok(response);
const original = { kind: response.kind, owner: response.owner, recipient: response.recipient };
for (let count = 0; count < game.players.length && game.response &&
game.response.kind === original.kind && game.response.owner === original.owner &&
game.response.recipient === original.recipient; count++) {
assert.equal(game.pendingTreacheryDiscard ?? null, null);
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actSemutaRevivalBenefitsForces(game, actor.id, { type: 'passResponse' }, actions);
}
return game;
}
/** Finish only the already submitted return's actual optional free-revival,
* benefit and income continuations. Ordinary phase readiness remains untouched. */
export function finishSemutaRevivalBenefitsForces(game: Game,
actions: SemutaRevivalBenefitsForcesStep[] = []): Game {
for (let count = 0; count < 30; count++) {
assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the fresh used-cost batch first');
if (game.response) {
assert.ok(['choamRevival', 'revivalLimit', 'revivalDiscount', 'revivalIncome'].includes(game.response.kind));
game = allowSemutaRevivalBenefitsForces(game, actions);
} else if (game.decision?.kind === 'revivalStop' || game.decision?.kind === 'choamFreeRevival') {
game = actSemutaRevivalBenefitsForces(game, game.decision.player, { type: 'decision', decline: true }, actions);
} else {
assert.equal(game.pendingRevival ?? null, null);
assert.equal(game.decision ?? null, null);
return game;
}
}
throw Error('The original force-revival continuations did not settle');
}
/** Genuine bare native source. Only unopened starting-card/Spice positions and
* original scalar lotteries are selected. No played forces, Tanks, wallets,
* free quotas, phase, pending return or cancellation response is installed. */
export function createSemutaRevivalBenefitsForcesFixture(options: {
kind: SemutaRevivalBenefitsForcesKind; advanced?: boolean; amount?: number; unfunded?: boolean;
}) {
const kind = options.kind, advanced = options.advanced ?? false, unfunded = options.unfunded ?? false;
const amount = options.amount ?? (kind === 'choamRevival' ? 3 : kind === 'revivalLimit' ? 5 : 4);
assert.ok(kind === 'revivalLimit' ? amount === 5 :
kind === 'choamRevival' ? amount === 3 || amount === 4 : amount === 4);
assert.ok(!unfunded || kind === 'revivalDiscount', 'Only the own-T discount source needs its real bribe variant');
const seller = 'richese', holder = 'choam', tleilaxu = 'tleilaxu', atreides = 'atreides', spender = 'emperor';
const recipient = kind === 'choamRevival' ? holder : kind === 'revivalLimit' ? atreides : tleilaxu;
const owner = kind === 'choamRevival' ? holder : tleilaxu;
let game = createGame('SEMUTAREVIVALFORCES', newPlayer(seller, 'Richese', 'richese'), advanced, ['choam', 'ix']);
for (const faction of ['choam', 'tleilaxu', 'atreides', 'emperor'] as const)
joinGame(game, newPlayer(faction, faction, faction));
const actions: SemutaRevivalBenefitsForcesStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaRevivalBenefitsForces(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaRevivalBenefitsForces(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
else act(game.host, { type: 'start' });
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
entry: advanced ? 'explicit-original-semuta-initializer' : 'public-basic-start' };
assert.equal(game.status, 'setup'); assert.equal(game.semutaPreview, true);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalInventory = semutaRevivalBenefitsForcesInventory(game); assert.equal(originalInventory.length, 57);
const costCard = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-'));
const ordinary = game.deck.filter(card => !card.effect &&
['projectile', 'poison', 'shield', 'antidote'].includes(card.kind)).slice(0, 4);
assert.ok(costCard); assert.equal(ordinary.length, 4);
const deal = [...ordinary.map(card => card.id), costCard.id];
const dealOrder = deal.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { card, from, position };
});
const beforeStartingDeal = structuredClone(game), cost = costCard.id;
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
assert.equal(semutaRevivalBenefitsForcesPlayer(game, tleilaxu).faceDancers?.length, 3);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
const spiceDraws = (advanced ? 2 : 1) * (kind === 'revivalDiscount' ? 3 : 2);
for (let position = 0; position < spiceDraws; position++) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
TERRITORIES.some(territory => territory.id === card.territory && territory.sectors.every(sector => sector >= 9)));
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: SemutaRevivalBenefitsForcesStep | undefined, bidAt = -1;
for (let count = 0; count < 900 && !semutaRevivalBenefitsForcesPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
const next = nextSemutaRevivalBenefitsForces(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 };
assert.equal(semutaRevivalBenefitsForcesPlayer(beforeBid, holder).spice - semutaRevivalBenefitsForcesPlayer(game, holder).spice, 1);
assert.equal(semutaRevivalBenefitsForcesPlayer(game, seller).spice - semutaRevivalBenefitsForcesPlayer(beforeBid, seller).spice, 1);
reach(state => state.turn === 1 && state.phase === 4 && semutaRevivalBenefitsForcesClean(state));
const firstRevival = structuredClone(game);
assert.equal(game.storm, 18);
assert.equal(semutaRevivalBenefitsForcesPlayer(game, atreides).tanks, 0);
assert.equal(semutaRevivalBenefitsForcesPlayer(game, atreides).forces['arrakeen:10'], 10);
let financingReturn: ReturnType<typeof transition> | null = null;
let financingFunding: ReturnType<typeof transition> | null = null;
let funding: ReturnType<typeof transition> | null = null;
if (kind !== 'choamRevival') {
financingFunding = transition(() => { act(spender, { type: 'bribe', target: atreides, amount: 6 }); });
assert.equal(semutaRevivalBenefitsForcesPlayer(game, atreides).spice, 10);
assert.equal(semutaRevivalBenefitsForcesPlayer(game, atreides).bribes, 6);
}
const firstShipper = kind === 'choamRevival' ? holder : atreides;
const firstAmount = kind === 'choamRevival' ? 4 : 5;
reach(state => state.turn === 1 && state.phase === 5 && state.active === firstShipper && semutaRevivalBenefitsForcesClean(state));
const firstShipment = transition(() => {
act(firstShipper, { type: 'ship', territory: 'cielago_north', sector: 1, amount: firstAmount, elite: 0, allyPayment: 0 });
reach(semutaRevivalBenefitsForcesClean);
});
assert.equal(semutaRevivalBenefitsForcesPlayer(firstShipment.before, firstShipper).spice - semutaRevivalBenefitsForcesPlayer(game, firstShipper).spice, 2 * firstAmount);
assert.equal(semutaRevivalBenefitsForcesPlayer(game, firstShipper).forces['cielago_north:1'], firstAmount);
act(firstShipper, { type: 'endMovement' });
reach(state => state.turn === 2 && state.phase === 0 && semutaRevivalBenefitsForcesClean(state));
const firstStorm = transition(() => {
reach(state => state.turn === 2 && state.phase === 1 && semutaRevivalBenefitsForcesClean(state));
});
assert.equal(firstStorm.before.storm, 18); assert.ok(firstStorm.after.storm >= 1 && firstStorm.after.storm <= 6);
assert.equal(semutaRevivalBenefitsForcesPlayer(game, firstShipper).tanks, firstAmount);
assert.equal(semutaRevivalBenefitsForcesPlayer(game, firstShipper).forces['cielago_north:1'] ?? 0, 0);
reach(state => state.turn === 2 && state.phase === 4 && semutaRevivalBenefitsForcesClean(state));
if (kind !== 'choamRevival') {
assert.equal(semutaRevivalBenefitsForcesPlayer(game, atreides).bribes, 0);
assert.equal(semutaRevivalBenefitsForcesPlayer(game, atreides).spice, 6 + (advanced ? 2 : 0));
}
let shipment = firstShipment, storm = firstStorm;
let shipmentDestination = { territory: 'cielago_north', sector: 1 };
if (kind === 'revivalDiscount') {
financingReturn = transition(() => {
act(tleilaxu, { type: 'tleilaxuRevivalLimit', target: atreides });
act(atreides, { type: 'revive', amount: 5, elite: 0 });
game = finishSemutaRevivalBenefitsForces(game, actions);
});
assert.equal(semutaRevivalBenefitsForcesPlayer(game, atreides).revived, 5);
assert.equal(semutaRevivalBenefitsForcesPlayer(game, tleilaxu).spice, 12);
reach(state => state.turn === 2 && state.phase === 5 && state.active === recipient && semutaRevivalBenefitsForcesClean(state));
const sector = game.storm % 18 + 1;
const exposed = TERRITORIES.find(territory => territory.type === 'sand' && territory.sectors.includes(sector) &&
!Object.entries(game.spice).some(([key, amount]) => amount > 0 && key.startsWith(`${territory.id}:`)) && game.players.every(player =>
!Object.entries(player.forces).some(([key, count]) => count > 0 && key.startsWith(`${territory.id}:`))));
assert.ok(exposed, 'Choose actual spice-free exposed sand in the next Storm path');
shipmentDestination = { territory: exposed.id, sector };
shipment = transition(() => {
act(recipient, { type: 'ship', ...shipmentDestination, amount: 4, elite: 0, allyPayment: 0 });
reach(semutaRevivalBenefitsForcesClean);
});
assert.equal(semutaRevivalBenefitsForcesPlayer(shipment.before, recipient).spice - semutaRevivalBenefitsForcesPlayer(game, recipient).spice, 8);
assert.equal(semutaRevivalBenefitsForcesPlayer(game, recipient).forces[location(exposed.id, sector)], 4);
assert.equal(semutaRevivalBenefitsForcesPlayer(game, recipient).spice, 4);
act(recipient, { type: 'endMovement' });
reach(state => state.turn === 3 && state.phase === 0 && semutaRevivalBenefitsForcesClean(state));
assert.equal(semutaRevivalBenefitsForcesPlayer(game, recipient).spice, 4, 'No collection credit funds the reprice');
storm = transition(() => {
reach(state => state.turn === 3 && state.phase === 1 && semutaRevivalBenefitsForcesClean(state));
});
assert.equal(semutaRevivalBenefitsForcesPlayer(game, recipient).tanks, 4);
assert.equal(semutaRevivalBenefitsForcesPlayer(game, recipient).forces[location(exposed.id, sector)] ?? 0, 0);
reach(state => state.turn === 3 && state.phase === 4 && semutaRevivalBenefitsForcesClean(state));
if (unfunded) {
const bribe = semutaRevivalBenefitsForcesPlayer(game, recipient).spice - 2;
assert.ok(bribe > 0);
funding = transition(() => { act(recipient, { type: 'bribe', target: spender, amount: bribe }); });
assert.equal(semutaRevivalBenefitsForcesPlayer(game, recipient).spice, 2);
}
}
const beforeRevival = structuredClone(game), nativeAction: Action = { type: 'revive', amount, elite: 0 };
const revival = transition(() => {
if (kind === 'revivalLimit') act(tleilaxu, { type: 'tleilaxuRevivalLimit', target: recipient });
act(recipient, nativeAction);
for (let count = 0; count < 20 && game.response?.kind !== kind; count++) {
if (game.decision?.kind === 'revivalStop' || game.decision?.kind === 'choamFreeRevival')
act(game.decision.player, { type: 'decision', decline: true });
else if (game.response?.kind === 'revivalLimit' && kind === 'revivalDiscount')
game = allowSemutaRevivalBenefitsForces(game, actions);
else throw Error('The original force request did not reach its native benefit response');
}
});
assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner);
assert.equal(game.response?.recipient, recipient); assert.ok(game.pendingRevival);
assert.equal(game.phaseOpening ?? null, null); assert.equal(game.decision ?? null, null);
assert.equal(game.pendingTreacheryDiscard ?? null, null);
assert.deepEqual(semutaRevivalBenefitsForcesPlayer(game, recipient), semutaRevivalBenefitsForcesPlayer(beforeRevival, recipient));
assert.ok(semutaRevivalBenefitsForcesPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.ok(semutaRevivalBenefitsForcesPlayer(game, spender).hand.some(card => card.id === cost));
const source = structuredClone(game), pending = structuredClone(game.pendingRevival!);
const originalResponse = structuredClone(game.response!), expected = quoteRevivalCancellation(game, game.response!);
const allowedResume = quoteRevivalResume(game, 'finish');
const expectedResume = expected.pending ? quoteRevivalResume({ ...game,
revivalRules: expected.rules, pendingRevival: expected.pending }, 'finish') : null;
const action: Action = { type: 'card', card: cost, mode: 'cancel' };
assert.deepEqual(semutaRevivalBenefitsForcesInventory(game), originalInventory);
return { game, source, beforeCost: source, kind, advanced, amount, unfunded,
holder, spender, owner, recipient, payer: recipient, seller, tleilaxu, atreides,
cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, pending, expected, expectedResume, allowedResume, originalResponse,
lobby, setup, initialization, beforeStartingDeal, afterSetup, deal, dealOrder, originalSpice, spiceOrder,
acquisition, acquired, firstRevival, financingFunding, firstShipment, firstStorm,
financingReturn, shipment, shipmentDestination, storm, funding, beforeRevival, revival,
casualties: { first: firstStorm.after, later: storm.after },
originalInventory, actions, sourceActions: actions, sourceActionCount: actions.length,
staging: [
'Public Basic start or original Advanced Semuta initializer on a ready undealt R/C/T/A/E lobby; original union47/cache10 retained. Only unopened starting-card/Spice positions and scalar lotteries are selected. Emperor really receives printed Karama; CHOAM pays1 for cache Semuta.',
'Actual first Storm17+0 ends18; Atreides protected Arrakeen10 survives. Actual turn1 shipment (CHOAM4 or Atreides5) to exposed Cielago North1 is killed by the next Storm. No casualties, played forces, wallets, clocks or pending controls are installed.',
kind === 'revivalLimit' ? 'Emperor actually bribes Atreides6 before the first Mentat; Atreides ships5 for10, then the deferred6 becomes spendable at Mentat. After actual turn2 Storm casualties, Tleilaxu grants5 permission and Atreides submits the funded5-counter request (native free2, paid3) for6. The unpaid native limit response is the source.' :
kind === 'choamRevival' ? 'CHOAM uses its real post-cache11 to ship4 for8 into Cielago North1. The next actual Storm kills4; native turn2 CHOAM opening income funds the original cheap3/4-counter request.' :
'Emperor actually bribes Atreides6 before first Mentat; Atreides ships5 for10, then the deferred6 becomes spendable (Advanced also retains its actual Arrakeen city income2). After real turn2 Storm deaths Atreides revives5 (free2, paid3), paying6 and earning Tleilaxu7 paid/free income (5→12). Tleilaxu ships4 for8 into actual spice-free exposed sand in the next Storm path, retaining4; turn3 Storm kills4. Its request4 keeps native free2, cheap2/full4. The unfunded branch actually bribes2 away before requesting.',
'Stop before Emperor spends its actual cancellation cost. The pure native cancellation quote supplies abandonment/repricing/funding; later income is finished only through actual current response passes, never a replacement request.',
] };
}
export type SemutaRevivalBenefitsForcesFixture = ReturnType<typeof createSemutaRevivalBenefitsForcesFixture>;
@@ -0,0 +1,237 @@
import assert from 'node:assert/strict';
import {
createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { quoteRevivalCancellation } from '../game/revival-cancellation';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { actTruthMovement } from './fixture-truth-movement';
import { nextSemutaRicheseCacheKarama } from './fixture-semuta-richese-cache-karama';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export type SemutaRevivalBenefitsLeadersStep = { actor: string; action: Action };
export type SemutaRevivalBenefitsLeadersKind = 'earlyRevival' | 'revivalDiscount' | 'foreignGhola';
export const actSemutaRevivalBenefitsLeaders = actTruthMovement;
export const semutaRevivalBenefitsLeadersPlayer = (game: Game, id: string) => {
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
};
export const semutaRevivalBenefitsLeadersClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
/** Sealed plans and discard batches refer to these zones without duplicating cards. */
export function semutaRevivalBenefitsLeadersInventory(game: Game) {
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length); return ids;
}
/** Original preparation/readiness and cleanup only. Private plans and every
* fresh disposal are explicit; no optional replacement or revival is invented. */
export function nextSemutaRevivalBenefitsLeadersStep(game: Game): SemutaRevivalBenefitsLeadersStep | null {
if (game.pendingTreacheryDiscard || game.truthtrance) return null;
if (!game.response && !game.phaseOpening) {
const d = game.decision;
if (d?.kind === 'revivalStop') return { actor: d.player, action: { type: 'decision', decline: true } };
if (d?.kind === 'faceDance') return { actor: d.player, action: { type: 'decision', reveal: false } };
if (d?.kind === 'battleCards') return { actor: d.player, action: { type: 'decision', discard: [...d.cards] } };
const window = game.battle?.outcomeWindow;
if (!d && window && !window.closed) {
const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id)); assert.ok(actor);
return { actor, action: { type: 'battleOutcomeReady', event: window.event } };
}
}
return nextSemutaRicheseCacheKarama(game);
}
export function advanceSemutaRevivalBenefitsLeaders(game: Game, until: (state: Game) => boolean,
actions: SemutaRevivalBenefitsLeadersStep[] = []): Game {
for (let count = 0; count < 2200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextSemutaRevivalBenefitsLeadersStep(game);
assert.ok(next, 'Supply the real private plans or fresh-discard reaction before continuing');
game = actSemutaRevivalBenefitsLeaders(game, next.actor, next.action, actions);
}
throw Error('The native leader-revival programme did not reach its finite boundary');
}
export function passSemutaRevivalBenefitsLeaders(game: Game, actions: SemutaRevivalBenefitsLeadersStep[] = []): Game {
const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event);
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
const pending: NonNullable<Game['pendingTreacheryDiscard']> = game.pendingTreacheryDiscard!;
const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer');
const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
game = actSemutaRevivalBenefitsLeaders(game, actor.id, { type: 'semutaPass', event }, actions);
}
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game;
}
export function claimSemutaRevivalBenefitsLeaders(game: Game, holder: string,
actions: SemutaRevivalBenefitsLeadersStep[] = []): Game {
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.batch.entries.length === 1);
const event = pending.batch.event, card = pending.batch.entries[0].card.id;
game = actSemutaRevivalBenefitsLeaders(game, holder, { type: 'semutaCommit', event }, actions);
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = actSemutaRevivalBenefitsLeaders(game, holder, { type: 'semutaSelect', event, card }, actions);
return game;
}
/** Allow exactly one native benefit; later checks stay explicit until finish. */
export function allowSemutaRevivalBenefitsLeaders(game: Game,
actions: SemutaRevivalBenefitsLeadersStep[] = []): Game {
const kind = game.response?.kind;
assert.ok(kind === 'earlyRevival' || kind === 'revivalDiscount' || kind === 'foreignGhola');
for (let count = 0; game.response?.kind === kind && count < game.players.length; count++) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
game = actSemutaRevivalBenefitsLeaders(game, actor.id, { type: 'passResponse' }, actions);
}
assert.notEqual(game.response?.kind, kind); return game;
}
/** Complete only the accepted original request's native remaining benefit,
* consent/income controls. No request is replayed and fresh costs stay owned. */
export function finishSemutaRevivalBenefitsLeaders(game: Game,
actions: SemutaRevivalBenefitsLeadersStep[] = []): Game {
return advanceSemutaRevivalBenefitsLeaders(game, semutaRevivalBenefitsLeadersClean, actions);
}
/** Actual first-turn two-Poison battle earns both native dead discs. Original
* starting cards, paid cache, shipments, plans, votes and disposal stay real;
* no dead/deaths/ghola/custody/resource/phase/response state is assigned. */
export function createSemutaRevivalBenefitsLeadersFixture(options: {
kind?: SemutaRevivalBenefitsLeadersKind; advanced?: boolean;
} = {}) {
const kind = options.kind ?? 'earlyRevival', advanced = options.advanced ?? kind === 'foreignGhola';
assert.ok(kind !== 'foreignGhola' || advanced, 'Native foreign Ghola is Advanced only');
const seller = 'richese', holder = 'choam', owner = 'tleilaxu', opponent = 'atreides', spender = 'emperor';
const territory = 'imperial_basin', sector = 9, location = `${territory}:${sector}`;
let game = createGame('SEMUTAREVIVALBENEFITSLEADERS', newPlayer(seller, 'Richese', 'richese'), advanced, ['choam', 'ix']);
for (const faction of ['choam', 'tleilaxu', 'atreides', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: SemutaRevivalBenefitsLeadersStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaRevivalBenefitsLeaders(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSemutaRevivalBenefitsLeaders(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
for (const p of game.players) act(p.id, { type: 'ready' });
const lobby = structuredClone(game);
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
else act(game.host, { type: 'start' });
const setup = structuredClone(game), initialization = { before: lobby, after: setup,
entry: advanced ? 'explicit-original-semuta-initializer' : 'public-basic-start' };
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true);
assert.ok(game.players.every(p => !p.hand.length));
const originalInventory = semutaRevivalBenefitsLeadersInventory(game); assert.equal(originalInventory.length, 57);
const printed = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-'));
const poisons = game.deck.filter(card => card.kind === 'poison' && !card.effect).slice(0, 2);
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 2);
assert.ok(printed); assert.equal(poisons.length, 2); assert.equal(ordinary.length, 2);
const deal = [ordinary[0].id, ordinary[1].id, poisons[0].id, poisons[1].id, printed.id];
const dealOrder = deal.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { card, position, from };
});
const cost = printed.id, beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), deal);
const ownLeader = semutaRevivalBenefitsLeadersPlayer(game, owner).leaders.filter(leader => leader.strength > 1)
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
const foreignLeader = semutaRevivalBenefitsLeadersPlayer(game, opponent).leaders
.slice().sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
assert.ok(ownLeader && foreignLeader); assert.equal(ownLeader.strength, 2); assert.equal(foreignLeader.strength, 1);
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 < (advanced ? 4 : 2); position++) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
['hagga_basin', 'rock_outcroppings', 'broken_land', 'oh_gap'].includes(card.territory));
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: SemutaRevivalBenefitsLeadersStep | undefined, bidAt = -1;
for (let count = 0; count < 900 && !semutaRevivalBenefitsLeadersPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) {
const next = nextSemutaRevivalBenefitsLeadersStep(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 };
assert.ok(semutaRevivalBenefitsLeadersPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
assert.equal(semutaRevivalBenefitsLeadersPlayer(beforeBid, holder).spice - semutaRevivalBenefitsLeadersPlayer(game, holder).spice, 1);
const shipments: { actor: string; before: Game; steps: SemutaRevivalBenefitsLeadersStep[]; after: Game }[] = [];
for (const actor of [owner, opponent]) {
reach(state => state.turn === 1 && state.phase === 5 && state.active === actor && semutaRevivalBenefitsLeadersClean(state));
const shipment = transition(() => {
act(actor, { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 });
reach(semutaRevivalBenefitsLeadersClean);
});
assert.equal(semutaRevivalBenefitsLeadersPlayer(shipment.before, actor).spice - semutaRevivalBenefitsLeadersPlayer(game, actor).spice, 2);
shipments.push({ actor, ...shipment }); act(actor, { type: 'endMovement' });
}
reach(state => state.phase === 6 && semutaRevivalBenefitsLeadersClean(state) && !!state.active);
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices.find(choice => choice.territory === territory);
assert.ok(choice); assert.equal(choice.chooser, owner); assert.equal(choice.attacker, owner);
const battleAction: Action = { type: 'chooseBattle', territory, target: opponent };
act(choice.chooser, battleAction);
reach(state => !!state.battle && semutaRevivalBenefitsLeadersClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false);
const beforePlans = structuredClone(game), planSteps: SemutaRevivalBenefitsLeadersStep[] = [
{ actor: owner, action: { type: 'battlePlan', leader: ownLeader.id, dial: 0, support: 0, weapon: poisons[0].id, defense: null } },
{ actor: opponent, action: { type: 'battlePlan', leader: foreignLeader.id, dial: 0, support: 0, weapon: poisons[1].id, defense: null } },
];
for (const step of planSteps) act(step.actor, step.action);
const revealed = structuredClone(game), battleStepsAt = actions.length;
const disposals: { before: Game; steps: SemutaRevivalBenefitsLeadersStep[]; after: Game; cards: string[] }[] = [];
for (let count = 0; (game.battle || !semutaRevivalBenefitsLeadersClean(game)) && count < 100; count++) {
if (game.pendingTreacheryDiscard) {
const cards: string[] = game.pendingTreacheryDiscard.batch.entries.map(entry => entry.card.id);
disposals.push({ ...transition(() => { game = passSemutaRevivalBenefitsLeaders(game, actions); }), cards });
} else {
const next = nextSemutaRevivalBenefitsLeadersStep(game); assert.ok(next, 'Finish the original final battle/cleanup controls');
act(next.actor, next.action);
}
}
assert.ok(!game.battle && semutaRevivalBenefitsLeadersClean(game));
const afterBattle = structuredClone(game), battleResolution = structuredClone(actions.slice(battleStepsAt));
const ownDead = semutaRevivalBenefitsLeadersPlayer(game, owner).leaders.find(leader => leader.id === ownLeader.id)!;
const foreignDead = semutaRevivalBenefitsLeadersPlayer(game, opponent).leaders.find(leader => leader.id === foreignLeader.id)!;
assert.equal(ownDead.dead, true); assert.equal(ownDead.deaths, 1);
assert.equal(foreignDead.dead, true); assert.equal(foreignDead.deaths, 1);
assert.equal(game.lastBattleContext?.winner, owner);
assert.equal(semutaRevivalBenefitsLeadersPlayer(game, owner).forces[location], 1);
assert.equal(semutaRevivalBenefitsLeadersPlayer(game, opponent).tanks, 1);
assert.ok(disposals.length && disposals.every(disposal => disposal.steps.every(step => step.action.type === 'semutaPass')));
assert.ok(semutaRevivalBenefitsLeadersPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
reach(state => state.turn === 2 && state.phase === 4 && semutaRevivalBenefitsLeadersClean(state));
const beforeRequest = structuredClone(game), targetLeader = kind === 'foreignGhola' ? foreignLeader.id : ownLeader.id;
assert.ok(semutaRevivalBenefitsLeadersPlayer(game, owner).spice >= ownLeader.strength);
const nativeAction: Action = kind === 'foreignGhola'
? { type: 'reviveForeignGhola', leader: targetLeader } : { type: 'reviveLeader', leader: targetLeader };
const declaration = transition(() => { act(owner, nativeAction); });
let priorEarly: { before: Game; steps: SemutaRevivalBenefitsLeadersStep[]; after: Game } | null = null;
if (kind === 'revivalDiscount') {
assert.equal(game.response?.kind, 'earlyRevival');
priorEarly = transition(() => { game = allowSemutaRevivalBenefitsLeaders(game, actions); });
}
assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner); assert.equal(game.response?.recipient, owner);
assert.ok(game.pendingRevival && game.pendingRevival.leader === targetLeader);
assert.equal(semutaRevivalBenefitsLeadersPlayer(game, owner).spice, semutaRevivalBenefitsLeadersPlayer(beforeRequest, owner).spice);
assert.ok(semutaRevivalBenefitsLeadersPlayer(game, spender).hand.some(card => card.id === cost));
assert.deepEqual(semutaRevivalBenefitsLeadersInventory(game), originalInventory);
const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' };
const expected = quoteRevivalCancellation(game, game.response!); assert.ok(expected);
return { game, source, beforeCost: source, kind, advanced, holder, spender, owner, recipient: owner, payer: owner,
opponent, seller, cost, karama: cost, card: cost, semuta: SEMUTA_DRUG_ID, action, nativeAction,
targetLeader, ownLeader: ownLeader.id, foreignLeader: foreignLeader.id, normalCost: game.pendingRevival!.normalCost,
discountedCost: game.pendingRevival!.cost, pending: structuredClone(game.pendingRevival), expected,
territory, location, lobby, setup, initialization, beforeStartingDeal, afterSetup, deal, dealOrder,
originalSpice, spiceOrder, acquired, acquisition, shipments, beforeBattle, battleAction,
beforePlans, planSteps, revealed, disposals, battleResolution, afterBattle, beforeRequest, declaration, priorEarly,
originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Original public Basic or explicit undealt Advanced Semuta setup uses native R/CHOAM/Tleilaxu/Atreides/Emperor, main47/cache10. Only unopened card/Spice positions and original scalar lotteries are selected. Tleilaxu/Atreides actually receive separate printed Poison weapons; Emperor receives the printed Karama.',
'CHOAM really buys Semuta cache1. Tleilaxu and Atreides each really pay2 to ship one normal into otherwise empty Imperial Basin9. Native battle seals Wykk2/Yueh1, dial0/support0, both original Poison and no defense, then submits actual final readiness/no-Traitor votes. Both native leaders die through the real weapons; no dead/deaths/ghola/custody/army/phase assignment occurs.',
'All real losing/winner played-card disposal groups are explicitly passed so the original paid Semuta remains held. Native aftermath, Collection, Mentat, next Storm/Spice/Charity/Bidding lead to clean turn2 Revival. The surviving native Tleilaxu group and four active own leaders remain actual outcomes; original funds cover full2 without a bribe.',
kind === 'foreignGhola' ? 'The original Advanced foreign-Ghola declaration targets the different actually dead Atreides disc while the real dead own disc leaves Tleilaxu active capacity4.' : kind === 'revivalDiscount' ? 'Actually declare the original own dead Wykk revival and pass its earlier earlyRevival window before stopping at the native remaining discount response.' : 'Actually declare own dead Wykk revival and stop at its native earlyRevival response before any return or payment.',
'Stop before the actual Emperor cancellation; source pending/quote are read, not installed. Native cancellation decides abandonment or current full-price continuation; caller owns fresh-cost Semuta and later original checks/income.',
] };
}
export type SemutaRevivalBenefitsLeadersFixture = ReturnType<typeof createSemutaRevivalBenefitsLeadersFixture>;
@@ -0,0 +1,278 @@
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 { location } from '../game/board';
import { quoteRevivalCancellation } from '../game/revival-cancellation';
import {
createSemutaRevivalBenefitsForcesFixture as fixture, actSemutaRevivalBenefitsForces as act,
passSemutaRevivalBenefitsForces as pass, claimSemutaRevivalBenefitsForces as claim,
allowSemutaRevivalBenefitsForces as allow, finishSemutaRevivalBenefitsForces as finish,
semutaRevivalBenefitsForcesPlayer as player, semutaRevivalBenefitsForcesInventory as inventory,
type SemutaRevivalBenefitsForcesFixture,
} from './fixture-semuta-revival-benefits-forces';
const variants = [
{ kind: 'choamRevival' as const, advanced: false },
{ kind: 'choamRevival' as const, advanced: true },
{ kind: 'revivalLimit' as const, advanced: false },
{ kind: 'revivalLimit' as const, advanced: true },
{ kind: 'revivalDiscount' as const, advanced: false },
{ kind: 'revivalDiscount' as const, advanced: true },
{ kind: 'choamRevival' as const, advanced: false, amount: 4 },
{ kind: 'revivalDiscount' as const, advanced: false, unfunded: true },
];
const physical = (game: Game) => ({
turn: game.turn, phase: game.phase, storm: game.storm, active: game.active, order: game.order, ready: game.ready,
revivalRules: game.revivalRules, freeRevival: game.freeRevival, revivalFreeIncome: game.revivalFreeIncome,
emperorExtra: game.emperorExtra, revivalRequests: game.revivalRequests,
spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard,
players: game.players.map(player => ({ id: player.id, spice: player.spice,
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
revived: player.revived, freeForcesRevived: player.freeForcesRevived,
leaderRevived: player.leaderRevived, leaders: player.leaders, shipped: player.shipped, moved: player.moved })),
});
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: SemutaRevivalBenefitsForcesFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.semutaPreview, true);
assert.equal(f.game.advanced, f.advanced); assert.equal(f.setup.advanced, f.advanced);
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama'));
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.seller).spice - player(f.acquisition.before, f.seller).spice, 1);
assert.equal(f.firstRevival.storm, 18); assert.equal(player(f.firstRevival, f.atreides).tanks, 0);
assert.equal(player(f.firstRevival, f.atreides).forces['arrakeen:10'], 10);
assert.deepEqual(f.actions.filter(step => step.action.type === 'stormDial' &&
f.actions.indexOf(step) < f.actions.findIndex(action => action.action.type === 'richeseBid'))
.map(step => Number(step.action.amount)).sort((a, b) => a - b), [0, 17]);
const destination = location(f.shipmentDestination.territory, f.shipmentDestination.sector);
const shipped = f.kind === 'revivalLimit' ? 5 : 4;
assert.equal(player(f.shipment.before, f.recipient).spice - player(f.shipment.after, f.recipient).spice, 2 * shipped);
assert.equal(player(f.shipment.after, f.recipient).forces[destination], shipped);
assert.ok(f.shipment.steps.some(step => step.actor === f.recipient && step.action.type === 'ship' && step.action.amount === shipped));
assert.equal(player(f.storm.before, f.recipient).tanks, 0); assert.equal(player(f.storm.after, f.recipient).tanks, shipped);
assert.equal(player(f.storm.after, f.recipient).forces[destination] ?? 0, 0);
assert.equal(f.firstStorm.before.storm, 18);
if (f.kind !== 'choamRevival') {
assert.ok(f.financingFunding);
assert.equal(player(f.financingFunding.before, f.spender).spice - player(f.financingFunding.after, f.spender).spice, 6);
assert.equal(player(f.financingFunding.after, f.atreides).spice, 10);
assert.equal(player(f.financingFunding.after, f.atreides).bribes, 6);
assert.equal(player(f.firstShipment.after, f.atreides).spice, 0);
assert.equal(player(f.firstShipment.after, f.atreides).forces['cielago_north:1'], 5);
assert.equal(player(f.firstStorm.before, f.atreides).spice, 6 + (f.advanced ? 2 : 0));
assert.equal(player(f.firstStorm.before, f.atreides).bribes, 0);
assert.equal(player(f.firstStorm.after, f.atreides).tanks, 5);
}
if (f.kind === 'revivalLimit') {
assert.equal(f.game.turn, 2); assert.equal(f.financingReturn, null);
assert.ok(f.revival.steps.some(step => step.actor === f.tleilaxu && step.action.type === 'tleilaxuRevivalLimit' && step.action.target === f.recipient));
assert.equal(f.pending.free, 2); assert.equal(f.pending.cost, 2 * (f.amount - 2));
assert.equal(f.expected.outcome, 'abandoned');
} else {
assert.equal(f.game.turn, f.kind === 'choamRevival' ? 2 : 3);
if (f.kind === 'choamRevival') {
assert.equal(f.financingFunding, null); assert.equal(f.financingReturn, null);
assert.equal(player(f.shipment.before, f.holder).spice, 11);
assert.equal(f.pending.cost, f.amount); assert.equal(f.pending.free, 0);
assert.equal(f.expected.outcome, f.amount === 4 ? 'abandoned' : 'revive');
if (f.amount === 3) assert.equal(f.expected.pending?.cost, 6);
} else {
assert.ok(f.financingReturn);
assert.equal(player(f.financingReturn.before, f.atreides).spice - player(f.financingReturn.after, f.atreides).spice, 6);
assert.equal(player(f.financingReturn.after, f.atreides).revived, 5);
assert.equal(player(f.financingReturn.after, f.tleilaxu).spice - player(f.financingReturn.before, f.tleilaxu).spice, 7);
assert.equal(player(f.shipment.before, f.tleilaxu).spice, 12);
assert.equal(player(f.shipment.after, f.tleilaxu).spice, 4);
assert.equal(player(f.storm.before, f.tleilaxu).spice, 4);
assert.equal(f.shipmentDestination.sector, f.shipment.before.storm % 18 + 1);
assert.ok(Object.entries(f.shipment.before.spice).every(([key, amount]) =>
amount === 0 || !key.startsWith(`${f.shipmentDestination.territory}:`)));
assert.equal(f.pending.amount, 4); assert.equal(f.pending.free, 2);
assert.equal(f.pending.cost, 2); assert.equal(f.pending.normalCost, 4);
assert.equal(f.expected.pending?.cost, 4); assert.equal(f.expected.outcome, f.unfunded ? 'unfunded' : 'revive');
assert.ok(f.revival.steps.some(step => step.action.type === 'passResponse'), 'The earlier own-T limit response is actually allowed');
if (f.unfunded) {
assert.ok(f.funding); assert.equal(player(f.funding.after, f.payer).spice, 2);
assert.ok(f.funding.steps.some(step => step.actor === f.payer && step.action.type === 'bribe' && step.action.target === f.spender));
assert.equal(player(f.game, f.payer).spice, f.pending.cost);
}
}
}
assert.equal(f.game.phase, 4); assert.equal(f.game.response?.kind, f.kind);
assert.equal(f.game.response?.owner, f.owner); assert.equal(f.game.response?.recipient, f.recipient);
assert.deepEqual(f.game.pendingRevival, f.pending);
assert.deepEqual(player(f.game, f.recipient), player(f.beforeRevival, f.recipient));
assert.deepEqual(quoteRevivalCancellation(f.game, f.game.response!), f.expected);
assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.decision ?? null, null);
assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 57);
assert.deepEqual(inventory(f.game), f.originalInventory);
}
function open(f: SemutaRevivalBenefitsForcesFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaRevivalDiscard');
assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, `karama:${f.kind}`);
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 !== 'karamaRevivalDiscard') throw Error('Use the exact revival-benefit cost');
assert.deepEqual(pending.continuation.use.response, f.originalResponse);
assert.equal(pending.continuation.player, f.spender); assert.equal(pending.continuation.card, f.cost);
assert.deepEqual(game.pendingRevival, f.pending); assert.equal(game.response ?? null, null);
assert.equal(game.decision ?? null, null); assert.equal(game.phaseOpening ?? null, null);
assert.deepEqual(physical(game), physical(f.game));
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;
}
/** Predict the exact price/free-event income independently. The last usable
* counter's disposal may make that income auto-allow, while a reclaimed cost
* may leave the same native income window actionable. */
function settled(game: Game, f: SemutaRevivalBenefitsForcesFixture, claimed: boolean) {
assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingRevival ?? null, null);
assert.equal(game.pendingKarama ?? null, null); assert.equal(game.decision ?? null, null);
assert.deepEqual(game.revivalRules, f.expected.rules);
const returned = f.expected.outcome === 'revive', quote = f.expected.pending;
const nativeIncome = returned && f.expectedResume?.nextResponse?.kind === 'revivalIncome'
? f.expectedResume.nextResponse : null;
const income = game.response ? structuredClone(game.response) : null;
const outstanding = income?.kind === 'revivalIncome' ? income.amount! : 0;
const credited = (nativeIncome?.amount ?? 0) - outstanding;
assert.ok(outstanding === 0 || outstanding === nativeIncome?.amount);
assert.ok(credited === 0 || credited === nativeIncome?.amount);
if (income) {
assert.equal(income.kind, 'revivalIncome'); assert.ok(nativeIncome);
assert.equal(income.owner, nativeIncome.owner); assert.equal(income.recipient, nativeIncome.recipient);
assert.equal(income.amount, nativeIncome.amount);
}
const original = player(f.game, f.recipient), recipient = player(game, f.recipient);
assert.equal(recipient.tanks, original.tanks - (returned ? f.amount : 0));
assert.equal(recipient.reserves, original.reserves + (returned ? f.amount : 0));
assert.equal(recipient.revived, original.revived + (returned ? f.amount : 0));
assert.equal(recipient.freeForcesRevived ?? 0, (original.freeForcesRevived ?? 0) + (returned ? quote!.free : 0));
assert.equal(player(game, f.payer).spice, player(f.game, f.payer).spice - (returned ? quote!.cost : 0) +
(f.payer === f.tleilaxu ? credited : 0));
assert.equal(player(game, f.tleilaxu).spice, player(f.game, f.tleilaxu).spice -
(returned && f.payer === f.tleilaxu ? quote!.cost : 0) + credited);
assert.deepEqual(recipient.forces, original.forces); assert.deepEqual(recipient.elites, original.elites);
assert.deepEqual(game.freeRevival, f.game.freeRevival);
assert.equal(game.active, f.game.active); assert.deepEqual(game.ready, f.game.ready);
assert.equal(game.phase, 4); assert.equal(game.turn, f.game.turn);
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));
if (returned) {
const resumed = f.expectedResume; assert.ok(resumed);
assert.equal(resumed.outcome, 'revive'); assert.equal(resumed.pending.cost, quote!.cost);
assert.ok(nativeIncome); assert.equal(nativeIncome.owner, f.tleilaxu);
assert.equal(nativeIncome.recipient, f.payer); assert.equal(nativeIncome.amount, f.payer === f.tleilaxu ? 1 : quote!.cost);
if (resumed.freeIncome) {
assert.equal(resumed.freeIncome.player, f.payer);
assert.equal(game.revivalFreeIncome?.[f.payer], f.game.turn);
} else assert.deepEqual(game.revivalFreeIncome, f.game.revivalFreeIncome);
} else {
assert.equal(income, null); assert.deepEqual(game.revivalFreeIncome, f.game.revivalFreeIncome);
}
const beforeIncome = structuredClone(game), steps: { actor: string; action: Action }[] = [];
game = finish(game, steps);
assert.ok(steps.every(step => step.action.type === 'passResponse'));
assert.equal(game.response ?? null, null); assert.equal(game.pendingRevival ?? null, null);
assert.equal(player(game, f.tleilaxu).spice, player(beforeIncome, f.tleilaxu).spice + outstanding);
for (const id of game.players.map(player => player.id).filter(id => id !== f.tleilaxu))
assert.deepEqual(player(game, id), player(beforeIncome, id));
assert.equal(player(game, f.recipient).tanks, recipient.tanks);
assert.equal(player(game, f.recipient).reserves, recipient.reserves);
assert.equal(player(game, f.recipient).revived, recipient.revived);
assert.deepEqual(inventory(game), f.originalInventory);
return game;
}
for (const variant of variants) for (const claimed of [false, true]) void test(
`${variant.advanced ? 'Advanced' : 'Basic'} native ${variant.kind}${'amount' in variant ? ' above normal cap' : ''}${'unfunded' in variant ? ' unfunded' : ''} cost ${claimed ? 'claim' : 'pass'} precedes its original return outcome`, () => {
const f = fixture(variant); nativeSource(f);
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
const game = settled(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
reject(game, f.holder, { type: 'semutaCommit', event });
reject(game, f.holder, { type: 'semutaPass', event });
reject(game, f.recipient, f.nativeAction);
});
void test('native benefit allowances complete the original cheap/granted return once without spending either held card', () => {
for (const variant of variants.filter(variant => !('unfunded' in variant))) {
const f = fixture(variant); nativeSource(f);
let game = allow(structuredClone(f.game));
const beforeIncome = structuredClone(game), income = game.response?.kind === 'revivalIncome' ? game.response.amount! : 0;
assert.equal(f.allowedResume.outcome, 'revive'); assert.equal(f.allowedResume.pending.cost, f.pending.cost);
const expectedIncome = f.allowedResume.nextResponse?.kind === 'revivalIncome' ? f.allowedResume.nextResponse.amount! : 0;
const credited = expectedIncome - income;
assert.ok(credited === 0 || credited === expectedIncome);
assert.equal(player(game, f.payer).spice, player(f.game, f.payer).spice - f.pending.cost +
(f.payer === f.tleilaxu ? credited : 0));
assert.equal(player(game, f.tleilaxu).spice, player(f.game, f.tleilaxu).spice -
(f.payer === f.tleilaxu ? f.pending.cost : 0) + credited);
if (f.allowedResume.freeIncome) assert.equal(game.revivalFreeIncome?.[f.payer], f.game.turn);
assert.equal(player(game, f.recipient).tanks, player(f.game, f.recipient).tanks - f.amount);
assert.equal(player(game, f.recipient).reserves, player(f.game, f.recipient).reserves + f.amount);
assert.equal(player(game, f.recipient).revived, player(f.game, f.recipient).revived + f.amount);
game = finish(game);
assert.equal(player(game, f.tleilaxu).spice, player(beforeIncome, f.tleilaxu).spice + income);
assert.equal(game.pendingRevival ?? null, null); assert.equal(game.response ?? null, null);
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
assert.deepEqual(inventory(game), f.originalInventory);
}
});
void test('abandoned and unfunded native requests preserve a real smaller return opportunity', () => {
for (const variant of [variants[2], variants[6], variants[7]]) {
const f = fixture(variant), game = settled(claim(open(f), f.holder), f, true);
const amount = f.kind === 'revivalDiscount' ? 2 : 3, before = structuredClone(game);
let next = act(game, f.recipient, { type: 'revive', amount, elite: 0 });
next = finish(next);
assert.equal(player(next, f.recipient).tanks, player(before, f.recipient).tanks - amount);
assert.equal(player(next, f.recipient).reserves, player(before, f.recipient).reserves + amount);
assert.equal(player(next, f.recipient).revived, player(before, f.recipient).revived + amount);
assert.equal(next.pendingRevival ?? null, null); assert.equal(next.response ?? null, null);
assert.deepEqual(inventory(next), f.originalInventory);
}
});
void test('fresh revival-benefit costs reject stale, foreign and underlying actions while the original request is unpaid', () => {
for (const variant of variants) {
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
reject(game, f.spender, { type: 'semutaCommit', event });
reject(game, f.spender, f.action); reject(game, f.recipient, f.nativeAction);
reject(game, f.owner, { type: 'passResponse' });
assert.deepEqual(game.pendingRevival, f.pending); assert.deepEqual(physical(game), physical(f.game));
settled(pass(game), f, false);
}
});
void test('all four minimal policies answer the actual force-benefit cancellation costs legally', () => {
for (const variant of variants) {
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const own = viewGame(offered, f.holder); own.players.find(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);
settled(game, f, claimed);
}
}
});
@@ -0,0 +1,174 @@
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 {
createSemutaRevivalBenefitsLeadersFixture as fixture, actSemutaRevivalBenefitsLeaders as act,
passSemutaRevivalBenefitsLeaders as pass, claimSemutaRevivalBenefitsLeaders as claim,
allowSemutaRevivalBenefitsLeaders as allow, finishSemutaRevivalBenefitsLeaders as finish,
semutaRevivalBenefitsLeadersPlayer as player, semutaRevivalBenefitsLeadersInventory as inventory,
type SemutaRevivalBenefitsLeadersFixture,
} from './fixture-semuta-revival-benefits-leaders';
const variants = [
{ kind: 'earlyRevival' as const, advanced: false }, { kind: 'earlyRevival' as const, advanced: true },
{ kind: 'revivalDiscount' as const, advanced: false }, { kind: 'revivalDiscount' as const, advanced: true },
{ kind: 'foreignGhola' as const, advanced: true },
];
const forceQuotas = (game: Game) => game.players.map(p => ({ id: p.id, reserves: p.reserves, tanks: p.tanks,
forces: p.forces, elites: p.elites, moved: p.moved, shipped: p.shipped, revived: p.revived,
freeForcesRevived: p.freeForcesRevived }));
const unpaid = (game: Game) => ({ turn: game.turn, phase: game.phase, active: game.active, ready: game.ready,
forceQuotas: forceQuotas(game), revivalFreeIncome: game.revivalFreeIncome,
players: game.players.map(p => ({ id: p.id, spice: p.spice, leaders: p.leaders,
revivalCycle: p.revivalCycle, leaderRevived: p.leaderRevived, gholaBlocked: p.gholaBlocked })),
pending: game.pendingRevival, rules: game.revivalRules });
function leader(game: Game, id: string) {
const found = game.players.flatMap(p => p.leaders).find(l => l.id === id); assert.ok(found); return found;
}
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: SemutaRevivalBenefitsLeadersFixture) {
assert.equal(f.setup.advanced, f.advanced); assert.equal(f.game.advanced, f.advanced);
assert.equal(f.setup.semutaPreview, true); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost));
assert.equal(player(f.afterSetup, f.owner).hand[0].kind, 'poison');
assert.equal(player(f.afterSetup, f.opponent).hand[0].kind, 'poison');
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);
for (const shipment of f.shipments) {
assert.equal(player(shipment.before, shipment.actor).spice - player(shipment.after, shipment.actor).spice, 2);
assert.equal(player(shipment.after, shipment.actor).forces[f.location], 1);
assert.ok(shipment.steps.some(step => step.action.type === 'ship' && step.action.amount === 1));
}
assert.ok(f.planSteps.every(step => step.action.dial === 0 && step.action.support === 0 && step.action.defense === null));
const votes = f.battleResolution.filter(step => step.action.type === 'traitorCall');
assert.equal(votes.length, 2); assert.ok(votes.every(step => step.action.call === false));
assert.deepEqual(new Set(votes.map(step => step.actor)), new Set([f.owner, f.opponent]));
assert.equal(leader(f.afterBattle, f.ownLeader).dead, true); assert.equal(leader(f.afterBattle, f.ownLeader).deaths, 1);
assert.equal(leader(f.afterBattle, f.foreignLeader).dead, true); assert.equal(leader(f.afterBattle, f.foreignLeader).deaths, 1);
assert.equal(f.afterBattle.lastBattleContext?.winner, f.owner);
assert.equal(player(f.afterBattle, f.owner).forces[f.location], 1);
assert.equal(player(f.afterBattle, f.opponent).tanks, 1);
const played = f.planSteps.map(step => String(step.action.weapon)).sort();
assert.deepEqual(f.disposals.flatMap(disposal => disposal.cards).sort(), played);
assert.ok(f.disposals.every(disposal => disposal.steps.every(step => step.action.type === 'semutaPass')));
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 4); assert.ok(!f.game.battle);
assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner);
assert.equal(f.game.response?.recipient, f.recipient); assert.deepEqual(f.game.pendingRevival, f.pending);
assert.equal(player(f.game, f.payer).spice, player(f.beforeRequest, f.payer).spice);
assert.ok(player(f.game, f.payer).spice >= f.normalCost);
assert.equal(leader(f.game, f.targetLeader).dead, true); assert.equal(leader(f.game, f.ownLeader).dead, true);
assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta));
if (f.kind === 'revivalDiscount') {
assert.ok(f.priorEarly); assert.equal(f.priorEarly.before.response?.kind, 'earlyRevival');
assert.ok(f.priorEarly.steps.every(step => step.action.type === 'passResponse'));
assert.equal(f.priorEarly.after.response?.kind, 'revivalDiscount');
assert.equal(f.discountedCost, 1); assert.equal(f.normalCost, 2); assert.equal(f.expected.outcome, 'revive');
assert.equal(f.expected.pending?.cost, 2);
} else assert.equal(f.expected.outcome, 'abandoned');
if (f.kind === 'foreignGhola') {
assert.equal(f.advanced, true); assert.equal(f.targetLeader, f.foreignLeader);
assert.equal(player(f.game, f.owner).leaders.filter(l => !l.dead).length, 4);
assert.equal(leader(f.game, f.foreignLeader).gholaBy, undefined);
}
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 57);
assert.deepEqual(inventory(f.game), f.originalInventory);
}
function open(f: SemutaRevivalBenefitsLeadersFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; assert.ok(pending);
assert.equal(pending.continuation.kind, 'karamaRevivalDiscard');
if (pending.continuation.kind !== 'karamaRevivalDiscard') throw Error('Expected the original unpaid revival-benefit cost');
assert.deepEqual(pending.continuation.use.response, f.game.response);
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.cost]);
assert.deepEqual(pending.batch.entries.map(entry => entry.discardedBy), [f.spender]);
assert.equal(game.response, null); assert.deepEqual(unpaid(game), unpaid(f.game));
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.deepEqual(inventory(game), f.originalInventory); assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
return game;
}
function denied(state: Game, f: SemutaRevivalBenefitsLeadersFixture, claimed: boolean) {
const game = finish(state);
assert.ok(!game.pendingTreacheryDiscard); assert.equal(game.pendingRevival ?? null, null);
assert.equal(game.response, null); assert.equal(game.phase, 4); assert.equal(game.turn, 2);
assert.deepEqual(game.revivalRules, f.expected.rules); assert.deepEqual(forceQuotas(game), forceQuotas(f.game));
assert.deepEqual(game.revivalFreeIncome, f.game.revivalFreeIncome);
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));
for (const p of f.game.players.filter(p => p.id !== f.payer)) assert.equal(player(game, p.id).spice, p.spice);
const returns = f.expected.outcome === 'revive';
assert.equal(player(game, f.payer).spice, player(f.game, f.payer).spice - (returns ? f.expected.pending!.cost : 0));
assert.equal(leader(game, f.targetLeader).dead, !returns); assert.equal(leader(game, f.targetLeader).deaths, 1);
assert.equal(player(game, f.owner).leaderRevived, returns);
if (f.kind === 'earlyRevival') assert.ok(game.revivalRules!.earlyBlocked.includes(`${f.owner}:${f.targetLeader}`));
if (f.kind === 'foreignGhola') {
assert.equal(player(game, f.owner).gholaBlocked?.[f.targetLeader], game.turn);
assert.equal(leader(game, f.targetLeader).gholaBy, undefined);
}
if (f.kind === 'revivalDiscount') {
assert.equal(game.revivalRules!.fullPrice, true); assert.equal(leader(game, f.foreignLeader).dead, true);
}
assert.deepEqual(inventory(game), f.originalInventory);
reject(game, f.owner, f.nativeAction); reject(game, f.spender, f.action); return game;
}
for (const variant of variants) for (const claimed of [false, true]) void test(
`${variant.advanced ? 'Advanced' : 'Basic'} native leader ${variant.kind} cost Semuta ${claimed ? 'claim' : 'pass'} resumes only the original unpaid request`, () => {
const f = fixture(variant); nativeSource(f); const offered = open(f);
denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed);
},
);
void test('native leader benefit allowance pays the original discounted price and returns only the requested disc once', () => {
for (const variant of variants) {
const f = fixture(variant), game = finish(allow(structuredClone(f.game)));
assert.equal(game.pendingRevival ?? null, null); assert.equal(game.response, null);
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice - f.discountedCost);
assert.equal(leader(game, f.targetLeader).dead, false); assert.equal(leader(game, f.targetLeader).deaths, 1);
assert.equal(player(game, f.owner).leaderRevived, true); assert.deepEqual(forceQuotas(game), forceQuotas(f.game));
assert.equal(leader(game, f.targetLeader).gholaBy ?? null, f.kind === 'foreignGhola' ? f.owner : null);
if (f.kind === 'foreignGhola') assert.equal(leader(game, f.ownLeader).dead, true);
else assert.equal(leader(game, f.foreignLeader).dead, true);
assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost));
assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta));
assert.deepEqual(inventory(game), f.originalInventory); reject(game, f.owner, f.nativeAction);
}
});
void test('native leader revival-cost reactions reject stale, foreign and underlying replies before repricing or return', () => {
for (const variant of variants) {
const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event;
reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action);
reject(game, f.owner, f.nativeAction); reject(game, f.owner, { type: 'passResponse' });
for (const disposal of f.disposals)
reject(game, f.holder, { type: 'semutaCommit', event: disposal.before.pendingTreacheryDiscard!.batch.event });
const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event });
}
});
void test('all four minimal profiles legally answer native leader early, discount and foreign-Ghola revival costs', () => {
for (const variant of variants) {
const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
const 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);
}
}
});