135 lines
8.1 KiB
TypeScript
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 };
|
|
}
|