Files
Dune/tests/fixture-semuta-homeworld-sapho.ts

135 lines
8.1 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board';
import { nativeShipmentSources } from '../game/homeworld-options';
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
import { moveSemutaEmperorCard, semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
export type SemutaHomeworldSaphoScope = 'onceAround' | 'movement';
export type SemutaHomeworldSaphoMode = 'first' | 'last';
export type SemutaHomeworldSaphoFixture = {
setup: Game; game: Game; scope: SemutaHomeworldSaphoScope; mode: SemutaHomeworldSaphoMode;
holder: string; owner: string; card: string; action: Action; event: string;
nextActor: string; suffix: string[]; prefix: string[]; originalQueue: string[];
lotCard: string | null; actions: RicheseSkillsTechStep[]; staging: string[];
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
/** Original completed R/E/C/A Homeworld setup, native finite lot or unstarted
* Movement queue. Only original unplayed Spice order and physical cards move. */
export function createSemutaHomeworldSaphoFixture(
scope: SemutaHomeworldSaphoScope, mode: SemutaHomeworldSaphoMode,
advanced = false, fullHolder = false,
): SemutaHomeworldSaphoFixture {
const native = createSemutaHomeworldChoamSetup(advanced);
let game = native.game;
const setup = structuredClone(game), actions = native.actions;
const staging = ['Conserved original physical Semuta in Richese hand.'];
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('Reorder two original unplayed non-worm Spice Cards; no replacement stock.');
let lotCard: string | null = null;
if (scope === 'onceAround') {
game = advanceSemutaHomeworldChoam(game, g => g.decision?.kind === 'richeseDeclaration', actions);
game = applySemutaHomeworldChoamStep(game, native.holder,
{ type: 'decision', event: game.richeseBidding!.event, position: 'first' }, actions);
game = advanceSemutaHomeworldChoam(game, g => g.decision?.kind === 'richeseCache', actions);
assert.equal(game.decision?.kind, 'richeseCache');
const selected = game.richeseCache!.find(card => card.id !== 'richese-juice-of-sapho')!;
lotCard = selected.id;
game = applySemutaHomeworldChoamStep(game, native.holder,
{ type: 'decision', event: game.richeseBidding!.event, card: selected.id,
method: 'onceAround', direction: 'counterclockwise' }, actions);
game = advanceSemutaHomeworldChoam(game, g => !!g.richeseAuction && clean(g), actions);
assert.equal(game.richeseAuction!.source, 'cache');
assert.equal(game.richeseAuction!.method, 'onceAround');
const actor = game.richeseAuction!.active!;
assert.ok(player(game, actor).spice >= 1);
game = applySemutaHomeworldChoamStep(game, actor,
{ type: 'richeseBid', event: game.richeseAuction!.event, amount: 1, allyPayment: 0 }, actions);
} else {
game = advanceSemutaHomeworldChoam(game, g => g.phase === 5 && clean(g), actions);
assert.ok(game.players.every(p => !p.shipped && p.moved === 0));
assert.deepEqual(game.movementRemaining, game.order);
}
assert.equal(game.turn, 1);
const originalQueue = scope === 'onceAround' ? [...game.richeseAuction!.order] : [...game.movementRemaining!];
const prefix = scope === 'onceAround' ? [...game.richeseAuction!.acted] : [];
const remaining = originalQueue.filter(id => !prefix.includes(id));
const owner = remaining.find(id => id !== native.holder &&
(mode === 'first' ? id !== remaining[0] : id !== remaining.at(-1)));
assert.ok(owner, 'A real non-Richese unacted actor changes its selected position');
const card = holdSemutaHomeworldChoamCard(game, owner, 'Juice of Sapho');
staging.push(`Conserved original printed Juice of Sapho to ${owner}; not an auction acquisition.`);
assert.ok(player(game, owner).hand.length <= handLimit(player(game, owner)));
if (fullHolder) {
const holder = player(game, native.holder);
while (holder.hand.length < handLimit(holder)) {
assert.ok(game.deck[0]);
moveSemutaEmperorCard(game, game.deck[0].id, holder.id);
}
staging.push('Conserved original undealt cards fill Richese to its native hand limit.');
}
const option = viewGame(game, owner).saphoOptions.find(option => option.scope === scope && option.mode === mode);
assert.ok(option);
const action: Action = { type: 'card', card, scope, mode, event: option.event };
const suffix = mode === 'first' ? [owner, ...remaining.filter(id => id !== owner)]
: [...remaining.filter(id => id !== owner), owner];
assert.deepEqual(biddingPhysicalIds(game), biddingPhysicalIds(setup));
return { setup, game, scope, mode, holder: native.holder, owner, card, action,
event: option.event!, nextActor: suffix[0], suffix, prefix, originalQueue, lotCard, actions, staging };
}
export function disposeSemutaHomeworldSapho(fixture: SemutaHomeworldSaphoFixture, state = fixture.game): Game {
return applyAction(state, fixture.owner, fixture.action);
}
/** Finish only this same finite lot: each remaining bidder passes once, leaving
* the real earlier one-spice invoice to native quote/payment/acquisition. */
export function finishSemutaHomeworldSaphoAuction(state: Game, fixture: SemutaHomeworldSaphoFixture,
actions: RicheseSkillsTechStep[] = []): Game {
let game = state;
while (game.richeseAuction?.event === fixture.event && !game.richeseAuction.outcome) {
assert.ok(clean(game));
const actor = game.richeseAuction.active!;
game = applySemutaHomeworldChoamStep(game, actor,
{ type: 'richeseBid', event: fixture.event, amount: null, allyPayment: 0 }, actions);
}
return game;
}
/** One actual paid original reserve shipment and one ground move, then each
* original remaining combined turn ends once through public native controls. */
export function finishSemutaHomeworldSaphoMovement(state: Game, fixture: SemutaHomeworldSaphoFixture,
actions: RicheseSkillsTechStep[] = []): { game: Game; beforeShipment: Game; afterShipment: Game;
origin: string; destination: string; shipment: Action; move: Action } {
let game = state;
assert.ok(clean(game));
assert.equal(game.active, fixture.nextActor);
const origin = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id, s)))
.find(key => splitLocation(key).sector !== game.storm && game.players.every(p => !(p.forces[key] > 0)));
assert.ok(origin);
const destination = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id, s)))
.find(key => splitLocation(key).sector !== game.storm && game.players.every(p => !(p.forces[key] > 0)) &&
gameDistance(game, origin, key, k => splitLocation(k).sector === game.storm) === 1);
assert.ok(destination);
const sources = nativeShipmentSources(viewGame(game, fixture.nextActor), 1, 0);
assert.ok(sources);
const shipment: Action = { type: 'ship', ...splitLocation(origin), amount: 1, elite: 0,
homeworldSources: sources, allyPayment: 0 };
const beforeShipment = structuredClone(game);
game = applySemutaHomeworldChoamStep(game, fixture.nextActor, shipment, actions);
game = advanceSemutaHomeworldChoam(game, clean, actions);
const afterShipment = structuredClone(game);
const move: Action = { type: 'move', from: origin, ...splitLocation(destination), amount: 1, elite: 0 };
game = applySemutaHomeworldChoamStep(game, fixture.nextActor, move, actions);
game = advanceSemutaHomeworldChoam(game, clean, actions);
game = advanceSemutaHomeworldChoam(game, g => g.phase > 5 && clean(g), actions);
return { game, beforeShipment, afterShipment, origin, destination, shipment, move };
}