Files
Dune/tests/fixture-semuta-homeworld-choam-battle-karama.ts
T

164 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
import {
createGame, handLimit, initializeSemutaGameForAudit, joinGame,
newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { nativeShipmentSources } from '../game/homeworld-options';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { isAuditorLeader } from '../game/choam-auditor';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { moveSemutaEmperorCard, semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { applySemutaHomeworldChoamStep,
holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
import { advanceSemutaHomeworldEmperorShare as advanceSemutaHomeworldChoam } from './fixture-semuta-homeworld-emperor-share';
export type SemutaHomeworldChoamBattleKaramaScope = 'income' | 'audit';
export type SemutaHomeworldChoamBattleKaramaFixture = {
scope: SemutaHomeworldChoamBattleKaramaScope;
initial: Game; setup: Game; beforeBattle: Game; acceptedBattle: Game;
afterPaidBattle: Game; nativeIncome: Game; auditOffer?: Game;
originalResponse: Game; gameBeforeCost: Game; game: Game;
shipments: { actor: string; action: Action; before: Game; after: Game }[];
actor: string; action: Action; declaration?: Action;
card: string; karama: string; amount: number; canceller: string; owner: string;
holder: string; winner: string; loser: string; location: string;
winnerLeader: string; loserLeader: string; usedIds: string[];
unplayedPrivateCard: string; projectile?: string; snooper?: string;
actions: RicheseSkillsTechStep[]; staging: string[];
};
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
/** Original Advanced R/E/C/A setup and paid first-turn battle. Only conserved
* physical card positions and still-unplayed Spice Card order are staged. */
export function createSemutaHomeworldChoamBattleKaramaFixture(
scope: SemutaHomeworldChoamBattleKaramaScope, fullHolder = false,
): SemutaHomeworldChoamBattleKaramaFixture {
let game = createGame('SEMUTAHOMEWORLDCHOAMBATTLE', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
const actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
game = applySemutaHomeworldChoamStep(game, game.host, { type: 'homeworlds', enabled: true }, actions);
for (const seat of game.players)
game = applySemutaHomeworldChoamStep(game, seat.id, { type: 'ready' }, actions);
const initial = structuredClone(game);
game = initializeSemutaGameForAudit(game);
game = advanceSemutaHomeworldChoam(game, g => g.status === 'playing', actions);
const setup = structuredClone(game);
const owner = 'c', canceller = 'e', holder = 'r';
const red = game.spiceDeck.find(c => 'territory' in c && c.territory === 'red_chasm');
const other = game.spiceDeck.find(c => 'territory' in c && c !== red);
assert.ok(red && other);
for (const [index, card] of [red, other].entries())
game.spiceDeck.splice(index, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
staging.push('Position two original unplayed non-worm Spice Cards, first Red Chasm; no clock or resource staging.');
const shipments: SemutaHomeworldChoamBattleKaramaFixture['shipments'] = [];
for (const [actor, amount] of [[canceller, 4], [owner, 1]] as const) {
game = advanceSemutaHomeworldChoam(game,
g => g.phase === 5 && g.active === actor && clean(g), actions);
assert.equal(game.turn, 1);
const sources = nativeShipmentSources(viewGame(game, actor), amount, 0);
assert.ok(sources);
const action: Action = { type: 'ship', territory: 'red_chasm', sector: 7,
amount, elite: 0, homeworldSources: sources, allyPayment: 0 };
const before = structuredClone(game);
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
game = advanceSemutaHomeworldChoam(game, clean, actions);
shipments.push({ actor, action, before, after: structuredClone(game) });
assert.equal(player(game, actor).spice, 10 - amount * 2);
game = applySemutaHomeworldChoamStep(game, actor, { type: 'endMovement' }, actions);
}
game = advanceSemutaHomeworldChoam(game, g => g.phase === 6 && clean(g), actions);
// Late physical staging cannot alter native readiness, shipment or battle funding.
for (const actor of [canceller, owner, holder]) game.deck.push(...player(game, actor).hand.splice(0));
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, holder);
const karama = holdSemutaHomeworldChoamCard(game, canceller, 'Karama');
const unplayedPrivateCard = holdSemutaHomeworldChoamCard(game, canceller, 'Hajr');
const projectile = scope === 'audit' ? holdSemutaHomeworldChoamCard(game, canceller, 'Crysknife') : undefined;
const snooper = scope === 'audit' ? holdSemutaHomeworldChoamCard(game, owner, 'Snooper') : undefined;
staging.push('Conserve original physical Semuta to Richese, Karama and unused Hajr to Emperor; audit only: Crysknife to Emperor and nonprojectile Snooper to CHOAM.');
if (fullHolder) {
while (player(game, holder).hand.length < handLimit(player(game, holder))) {
const card = game.deck.find(c => c.id !== SEMUTA_DRUG_ID);
assert.ok(card);
moveSemutaEmperorCard(game, card.id, holder);
}
staging.push('Conserved undealt cards fill Richese to its native hand limit.');
}
const winnerLeader = player(game, canceller).leaders.find(l => l.name === 'Hasimir Fenring')!.id;
const loserLeader = player(game, owner).leaders.find(l => scope === 'audit'
? isAuditorLeader(l) : !isAuditorLeader(l) && !l.dead && !l.usedAt)!.id;
const beforeBattle = structuredClone(game);
const chooser = game.active;
assert.ok(chooser && [owner, canceller].includes(chooser));
game = applySemutaHomeworldChoamStep(game, chooser,
{ type: 'chooseBattle', territory: 'red_chasm', target: chooser === owner ? canceller : owner }, actions);
game = advanceSemutaHomeworldChoam(game, g => !!g.battle?.preLeader?.closed && clean(g), actions);
for (const actor of [game.battle!.attacker, game.battle!.defender]) {
const action: Action = actor === canceller
? { type: 'battlePlan', dial: 2, support: 2, leader: winnerLeader, ...(projectile ? { weapon: projectile } : {}) }
: { type: 'battlePlan', dial: 0, support: 0, leader: loserLeader, ...(snooper ? { defense: snooper } : {}) };
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
}
const acceptedBattle = structuredClone(game);
// Every earlier losing/winning Semuta event is passed by genuine controls.
game = advanceSemutaHomeworldChoam(game, g => g.response?.kind === 'choamBattleIncome', actions);
const afterPaidBattle = structuredClone(game), nativeIncome = structuredClone(game);
assert.equal(game.pendingChoamBattleIncome?.amount, 1);
assert.equal(player(game, owner).spice, 8);
assert.equal(player(game, canceller).spice, scope === 'audit' ? 2 : 0);
let auditOffer: Game | undefined, declaration: Action | undefined;
if (scope === 'audit') {
game = advanceSemutaHomeworldChoam(game, g => g.decision?.kind === 'choamAudit', actions);
auditOffer = structuredClone(game);
assert.equal(player(game, owner).spice, 9);
const decision = game.decision;
assert.ok(decision?.kind === 'choamAudit');
declaration = { type: 'decision', event: decision.event, audit: true };
game = applySemutaHomeworldChoamStep(game, owner, declaration, actions);
}
assert.equal(game.response?.kind, scope === 'income' ? 'choamBattleIncome' : 'choamAudit');
assert.ok(viewGame(game, canceller).responseControls?.cancelCards.includes(karama));
const originalResponse = structuredClone(game), gameBeforeCost = structuredClone(game);
return { scope, initial, setup, beforeBattle, acceptedBattle, afterPaidBattle, nativeIncome,
auditOffer, originalResponse, gameBeforeCost, game, shipments,
actor: canceller, action: { type: 'card', mode: 'cancel', card: karama }, declaration,
card: karama, karama, amount: 1, canceller, owner, holder, winner: canceller,
loser: owner, location: 'red_chasm:7', winnerLeader, loserLeader,
usedIds: [...(game.pendingAuditor?.usedCards ?? [])], unplayedPrivateCard, projectile, snooper,
actions, staging };
}
export function disposeSemutaHomeworldChoamBattleKarama(
f: SemutaHomeworldChoamBattleKaramaFixture, state = f.game, actions?: RicheseSkillsTechStep[],
): Game {
return applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
}
export function reactSemutaHomeworldChoamBattleKarama(
state: Game, holder: string, claim: boolean, actions?: RicheseSkillsTechStep[],
): Game {
const pending = state.pendingTreacheryDiscard, reaction = pending?.reaction;
assert.ok(pending && reaction?.stage === 'offer');
const event = pending.batch.event;
if (claim) return applySemutaHomeworldChoamStep(state, holder, { type: 'semutaCommit', event }, actions);
let game = state;
for (const seat of state.players.filter(p => !reaction.passed.includes(p.id)))
game = applySemutaHomeworldChoamStep(game, seat.id, { type: 'semutaPass', event }, actions);
return game;
}
/** Quote real Collection before the final native market close, not a second
* Collection quote on an already credited phase-seven snapshot. */
export function finishSemutaHomeworldChoamBattleKarama(state: Game, actions?: RicheseSkillsTechStep[]) {
let game = advanceSemutaHomeworldChoam(state, g => g.decision?.kind === 'choamMarket', actions);
assert.equal(game.turn, state.turn);
const beforeCollection = structuredClone(game), collection = quoteSpiceCollection(game);
const actor = game.decision!.player, action: Action = { type: 'decision', done: true };
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
game = advanceSemutaHomeworldChoam(game, g => g.phase === 7 && clean(g), actions);
assert.equal(game.turn, state.turn);
return { game, beforeCollection, collection, actor, action };
}