Files
Dune/tests/fixture-semuta-homeworld-bg-charity.ts

127 lines
6.7 KiB
TypeScript

import assert from 'node:assert/strict';
import {
createGame, handLimit, initializeSemutaGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
import { moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
import { applySemutaHomeworldChoamStep } from './fixture-semuta-homeworld-choam';
export type SemutaHomeworldBgCharityFixture = {
initial: Game; setup: Game; gameBeforeBGclaim: Game; game: Game;
originalIncomeResponse: Game; originalResponse: Game | null;
actor: string; owner: string; holder: string; payer: string; target: string;
card: string; amount: number; claim: Action; action: Action; event: string | null;
actions: RicheseSkillsTechStep[]; staging: string[];
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
/** Native human continuation: decline markets, allow responses, and ready the
* original queues. Never use the optional paid Kaitain end-of-Bidding control. */
function advance(state: Game, until: (game: Game) => boolean, actions?: RicheseSkillsTechStep[]): Game {
let game = state;
for (let n = 0; !until(game) && n < 1600; n++) {
assert.notEqual(game.status, 'finished');
let next: RicheseSkillsTechStep;
const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction;
if (pending && reaction?.stage === 'offer') next = {
actor: game.players.find(p => !reaction.passed.includes(p.id))!.id,
action: { type: 'semutaPass', event: pending.batch.event },
};
else if (game.decision?.kind === 'choamMarket') next = {
actor: game.decision.player, action: { type: 'decision', done: true },
};
else if (game.biddingEnd && clean(game)) next = {
actor: game.biddingEnd.owners.find(id => !game.biddingEnd!.ready.includes(id))!,
action: { type: 'biddingEnd', event: game.biddingEnd.event, mode: 'ready' },
};
else next = nextRicheseSkillsTechNativeStep(game);
game = applySemutaHomeworldChoamStep(game, next.actor, next.action, actions);
}
assert.ok(until(game), 'Original native controls must reach the BG charity boundary');
return game;
}
/** Five original ready participants, actual prediction/force setup and first
* CHOAM income. Only conserved physical cards and unplayed Spice order stage. */
export function createSemutaHomeworldBgCharityFixture(
advanced = true, fullHolder = false,
): SemutaHomeworldBgCharityFixture {
let game = createGame('SEMUTAHWBG', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
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 = advance(game, g => g.status === 'playing', actions);
const setup = structuredClone(game);
const blows = game.spiceDeck.filter(card => 'territory' in card).slice(0, 2);
assert.equal(blows.length, 2);
for (const [position, card] of blows.entries())
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
staging.push('Order two original still-unplayed non-worm Spice Cards.');
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
const card = moveSemutaEmperorCard(game, semutaEmperorCardId(game, c => c.effect === 'karama'), 'e');
staging.push('Conserved original Semuta to Richese and printed Karama to Emperor.');
if (fullHolder) {
const holder = semutaEmperorPlayer(game, 'r');
while (holder.hand.length < handLimit(holder)) {
assert.ok(game.deck[0]);
moveSemutaEmperorCard(game, game.deck[0].id, holder.id);
}
staging.push('Conserved undealt stock fills the original Richese hand to four.');
}
game = advance(game, g => g.response?.kind === 'choamCharity', actions);
const originalIncomeResponse = structuredClone(game);
game = advance(game, g => g.phase === 2 && clean(g), actions);
const gameBeforeBGclaim = structuredClone(game), claim: Action = { type: 'charity' };
if (advanced) game = applySemutaHomeworldChoamStep(game, 'b', claim, actions);
assert.equal(semutaEmperorPlayer(game, 'b').spice, 5);
assert.equal(semutaEmperorPlayer(game, 'c').spice, 12);
if (advanced) {
assert.equal(game.response?.kind, 'bgCharity');
assert.equal(game.response?.amount, 2);
assert.ok(viewGame(game, 'e').responseControls?.cancelCards.includes(card));
}
return { initial, setup, gameBeforeBGclaim, originalIncomeResponse,
originalResponse: advanced ? structuredClone(game) : null, game,
actor: 'e', owner: 'b', holder: 'r', payer: 'c', target: 'a', card,
amount: advanced ? game.response!.amount! : viewGame(game, 'b').charity.amount,
claim, action: { type: 'card', mode: 'cancel', card }, event: null, actions, staging };
}
/** Spend the real printed card before the bound original BG denial. */
export function disposeSemutaHomeworldBgCharity(
f: SemutaHomeworldBgCharityFixture, state = f.game, actions?: RicheseSkillsTechStep[],
): Game {
const game = applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
f.event = game.pendingTreacheryDiscard?.batch.event ?? null;
return game;
}
export function reactSemutaHomeworldBgCharity(
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;
}
/** Continue the same original native charity close into usable Bidding. */
export function finishSemutaHomeworldBgCharity(
state: Game, actions?: RicheseSkillsTechStep[],
): Game {
assert.ok(!state.pendingTreacheryDiscard && state.response?.kind !== 'bgCharity');
return advance(state, g => g.phase === 3 && clean(g), actions);
}