Files
Dune/tests/fixture-semuta-revival-benefits-leaders.ts

238 lines
19 KiB
TypeScript

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>;