155 lines
9.1 KiB
TypeScript
155 lines
9.1 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { handLimit, viewGame, type Action, type Game } from '../game/engine';
|
|
import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board';
|
|
import { nativeShipmentSources } from '../game/homeworld-options';
|
|
import { choamPowerAction, choamPowerPlays } from '../game/choam-power-options';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
|
|
createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
|
|
import { moveSemutaEmperorCard, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
|
|
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
|
|
|
export type SemutaHomeworldChoamMovementEffect = 'kulon' | 'baliset';
|
|
export type SemutaHomeworldChoamMovementFixture = {
|
|
setup: Game; game: Game; beforeShipment: Game; afterShipment: Game; beforeMove: Game;
|
|
owner: string; holder: string; target: string; canceller: string; card: string;
|
|
effect: SemutaHomeworldChoamMovementEffect; action: Action; shipment: Action;
|
|
move: Action; tooFar: Action | null; alternateMove: Action | null;
|
|
origin: string; destination: string; actions: RicheseSkillsTechStep[]; staging: string[];
|
|
};
|
|
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
|
export function semutaHomeworldChoamMovementStock(game: Game): string[] {
|
|
return [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
|
|
...game.players.map(player => player.hand)].flat().map(card => card.id).sort();
|
|
}
|
|
export function semutaHomeworldChoamMovementResources(game: Game) {
|
|
return { custody: structuredClone(game.homeworlds?.custody),
|
|
players: game.players.map(player => ({ id: player.id, spice: player.spice,
|
|
reserves: player.reserves, tanks: player.tanks, forces: structuredClone(player.forces),
|
|
elites: structuredClone(player.elites), shipped: player.shipped, moved: player.moved,
|
|
ally: player.ally })), spice: structuredClone(game.spice) };
|
|
}
|
|
function destinationAtRange(game: Game, origin: string, range: number, excluded: string[] = []) {
|
|
const destination = TERRITORIES.filter(t => t.type === 'sand' && !excluded.includes(t.id))
|
|
.flatMap(t => t.sectors.map(sector => location(t.id, sector)))
|
|
.find(key => splitLocation(key).sector !== game.storm &&
|
|
game.players.every(player => !(player.forces[key] > 0)) &&
|
|
gameDistance(game, origin, key, k => splitLocation(k).sector === game.storm) === range);
|
|
assert.ok(destination, `Original board has a free sand destination at range ${range}`);
|
|
return destination;
|
|
}
|
|
|
|
/** Original setup, first-turn order and paid native reserves shipment. Only
|
|
* conserved physical hands and two still-unplayed Spice Cards are positioned. */
|
|
export function createSemutaHomeworldChoamMovementFixture(
|
|
effect: SemutaHomeworldChoamMovementEffect, advanced = false, fullHolder = false,
|
|
): SemutaHomeworldChoamMovementFixture {
|
|
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 new deck.');
|
|
game = advanceSemutaHomeworldChoam(game,
|
|
g => g.phase === 5 && g.active === native.owner && clean(g), actions);
|
|
assert.equal(game.turn, 1);
|
|
const card = holdSemutaHomeworldChoamCard(game, native.owner, effect === 'kulon' ? 'Kulon' : 'Baliset');
|
|
holdSemutaHomeworldChoamCard(game, native.canceller, 'Karama');
|
|
staging.push(`Conserved printed ${effect} to CHOAM and original Karama to Emperor.`);
|
|
assert.ok(semutaEmperorPlayer(game, native.owner).hand.length <= handLimit(semutaEmperorPlayer(game, native.owner)));
|
|
if (fullHolder) {
|
|
const holder = semutaEmperorPlayer(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 origin = effect === 'kulon' ? 'red_chasm:7'
|
|
: Object.keys(semutaEmperorPlayer(game, native.target).forces).find(key => semutaEmperorPlayer(game, native.target).forces[key] > 0)!;
|
|
assert.ok(origin);
|
|
const destination = destinationAtRange(game, origin, effect === 'kulon' ? 2 : 1);
|
|
const shipTo = effect === 'kulon' ? origin : destination;
|
|
const sources = nativeShipmentSources(viewGame(game, native.owner), 1, 0);
|
|
assert.ok(sources);
|
|
const shipment: Action = { type: 'ship', ...splitLocation(shipTo), amount: 1, elite: 0,
|
|
homeworldSources: sources, allyPayment: 0 };
|
|
const beforeShipment = structuredClone(game);
|
|
game = applySemutaHomeworldChoamStep(game, native.owner, shipment, actions);
|
|
game = advanceSemutaHomeworldChoam(game, g => clean(g), actions);
|
|
const afterShipment = structuredClone(game);
|
|
const move: Action = { type: 'move', from: origin, amount: 1, elite: 0, ...splitLocation(destination) };
|
|
let beforeMove = structuredClone(game);
|
|
let tooFar: Action | null = null, alternateMove: Action | null = null;
|
|
if (effect === 'kulon') {
|
|
tooFar = { type: 'move', from: origin, amount: 1, elite: 0,
|
|
...splitLocation(destinationAtRange(game, origin, 3)) };
|
|
} else {
|
|
game = applySemutaHomeworldChoamStep(game, native.owner, { type: 'endMovement' }, actions);
|
|
game = advanceSemutaHomeworldChoam(game,
|
|
g => g.phase === 5 && g.active === native.target && clean(g), actions);
|
|
alternateMove = { type: 'move', from: origin, amount: 1, elite: 0,
|
|
...splitLocation(destinationAtRange(game, origin, 1, [splitLocation(destination).territory])) };
|
|
beforeMove = structuredClone(game);
|
|
game = applySemutaHomeworldChoamStep(game, native.target, move, actions);
|
|
assert.equal(game.decision?.kind, 'choamMovement');
|
|
assert.deepEqual(semutaEmperorPlayer(game, native.target).forces, semutaEmperorPlayer(beforeMove, native.target).forces);
|
|
assert.equal(semutaEmperorPlayer(game, native.target).moved, 0);
|
|
}
|
|
const view = viewGame(game, native.owner);
|
|
const printed = choamPowerPlays(view, effect).find(play => play.source === 'printed' && play.card.id === card);
|
|
assert.ok(printed);
|
|
const action = choamPowerAction(view, printed, effect === 'baliset'
|
|
? { target: native.target, territory: splitLocation(destination).territory } : {});
|
|
assert.ok(action);
|
|
return { setup, game, beforeShipment, afterShipment, beforeMove,
|
|
owner: native.owner, holder: native.holder, target: effect === 'kulon' ? native.owner : native.target,
|
|
canceller: native.canceller, card, effect, action, shipment, move, tooFar, alternateMove,
|
|
origin, destination, actions, staging };
|
|
}
|
|
|
|
/** Pass only actual entitled native Worthless cancellation controls. */
|
|
export function allowSemutaHomeworldChoamMovement(state: Game, actions?: RicheseSkillsTechStep[]): Game {
|
|
let game = state;
|
|
for (let count = 0; game.response && count < 32; count++) {
|
|
assert.equal(game.response.kind, 'choamWorthless');
|
|
const entitled = game.players.find(player => {
|
|
const controls = viewGame(game, player.id).responseControls;
|
|
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
|
|
});
|
|
assert.ok(entitled, 'An original entitled cancellation control must allow the power');
|
|
game = applySemutaHomeworldChoamStep(game, entitled.id, { type: 'passResponse' }, actions);
|
|
}
|
|
assert.equal(game.response, null);
|
|
return game;
|
|
}
|
|
export function disposeSemutaHomeworldChoamMovement(fixture: SemutaHomeworldChoamMovementFixture,
|
|
state = fixture.game, actions?: RicheseSkillsTechStep[]): Game {
|
|
return allowSemutaHomeworldChoamMovement(
|
|
applySemutaHomeworldChoamStep(state, fixture.owner, fixture.action, actions), actions);
|
|
}
|
|
|
|
/** Execute a useful original ground move, then finish the original phase by
|
|
* native controls. No phase, active player, wallet or custody is reassigned. */
|
|
export function finishSemutaHomeworldChoamMovement(state: Game,
|
|
fixture: SemutaHomeworldChoamMovementFixture, actions?: RicheseSkillsTechStep[]): Game {
|
|
let game = applySemutaHomeworldChoamStep(state, fixture.target,
|
|
fixture.effect === 'kulon' ? fixture.move : fixture.alternateMove!, actions);
|
|
game = advanceSemutaHomeworldChoam(game, g => g.phase > 5 && clean(g), actions);
|
|
assert.equal(game.turn, 1);
|
|
return game;
|
|
}
|
|
export function withoutSemutaHomeworldChoamMovementHolder(state: Game, holder: string): Game {
|
|
const game = structuredClone(state), hand = semutaEmperorPlayer(game, holder).hand;
|
|
const index = hand.findIndex(card => card.id === SEMUTA_DRUG_ID);
|
|
const replacement = game.deck.findIndex(card => card.effect !== 'karama');
|
|
assert.ok(index >= 0 && replacement >= 0);
|
|
const semuta = hand[index];
|
|
hand[index] = game.deck.splice(replacement, 1)[0];
|
|
game.deck.push(semuta);
|
|
return game;
|
|
}
|