209 lines
11 KiB
TypeScript
209 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction, createGame, handLimit, initializeSemutaGameForAudit, joinGame, newPlayer,
|
|
viewGame, type Action, type Game,
|
|
} from '../game/engine';
|
|
import type { Card } from '../game/cards';
|
|
import { nativeShipmentSources } from '../game/homeworld-options';
|
|
import { forceRevivalQuote, forceRevivalRemaining } from '../game/revival';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
|
import { moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer,
|
|
type SemutaEmperorShareKind } from './fixture-semuta-emperor-share';
|
|
|
|
export type SemutaHomeworldEmperorShareFixture = {
|
|
kind: SemutaEmperorShareKind; game: Game; setup: Game; beforeShipment: Game;
|
|
paidShipment: Game; beforeWeather: Game; weather: Game; death: Game; worm: Game;
|
|
alliance: Game; beforeBenefit: Game; beforeDeclaration: Game;
|
|
owner: string; holder: string; recipient: string;
|
|
canceller: string; karama: string; karamaCard: Card; weatherCard: string;
|
|
shipment: Action; weatherAction: Action; declare: Action; cancel: Action;
|
|
actions: RicheseSkillsTechStep[]; staging: string[];
|
|
};
|
|
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
|
|
!game.pendingTreacheryDiscard;
|
|
|
|
export function nextSemutaHomeworldEmperorShareStep(game: Game): RicheseSkillsTechStep {
|
|
const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction;
|
|
if (pending && reaction?.stage === 'offer') return {
|
|
actor: game.players.find(p => !reaction.passed.includes(p.id))!.id,
|
|
action: { type: 'semutaPass', event: pending.batch.event },
|
|
};
|
|
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard && game.biddingEnd) {
|
|
const end = game.biddingEnd;
|
|
return { actor: end.owners.find(id => !end.ready.includes(id))!,
|
|
action: { type: 'biddingEnd', event: end.event, mode: 'ready' } };
|
|
}
|
|
if (game.decision?.kind === 'choamMarket') return {
|
|
actor: game.decision.player, action: { type: 'decision', done: true },
|
|
};
|
|
// The Homeworld CHOAM Mentat window is optional: decline it so its special
|
|
// effects never disturb the deployed Atreides counter before the storm.
|
|
if (game.decision?.kind === 'choamMentat') return {
|
|
actor: game.decision.player, action: { type: 'decision', done: true },
|
|
};
|
|
return nextRicheseSkillsTechNativeStep(game);
|
|
}
|
|
export function advanceSemutaHomeworldEmperorShare(state: Game, until: (game: Game) => boolean,
|
|
actions?: RicheseSkillsTechStep[]): Game {
|
|
let game = state;
|
|
for (let n = 0; !until(game) && n < 2000; n++) {
|
|
assert.notEqual(game.status, 'finished');
|
|
const next = nextSemutaHomeworldEmperorShareStep(game);
|
|
actions?.push(next);
|
|
game = applyAction(game, next.actor, next.action);
|
|
}
|
|
assert.ok(until(game), 'Original share continuation reaches the requested native boundary');
|
|
return game;
|
|
}
|
|
export function semutaHomeworldEmperorShareStock(game: Game): string[] {
|
|
return [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
|
|
...game.players.map(p => p.hand)].flat().map(card => card.id).sort();
|
|
}
|
|
export function semutaHomeworldEmperorShareResources(game: Game) {
|
|
return { custody: structuredClone(game.homeworlds?.custody), emperorExtra: { ...game.emperorExtra },
|
|
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: { ...p.forces },
|
|
reserves: p.reserves, tanks: p.tanks, elites: structuredClone(p.elites),
|
|
revived: p.revived, freeForcesRevived: p.freeForcesRevived,
|
|
battleLosses: p.battleLosses, ally: p.ally, allySinceTurn: p.allySinceTurn })) };
|
|
}
|
|
function orderUnplayedSpice(game: Game, kinds: ('land' | 'worm')[]): void {
|
|
for (const [position, kind] of kinds.entries()) {
|
|
const at = game.spiceDeck.findIndex((card, i) => i >= position &&
|
|
(kind === 'land' ? 'territory' in card : 'worm' in card));
|
|
assert.ok(at >= position);
|
|
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
|
|
}
|
|
}
|
|
|
|
/** Original R/C/E/A setup, paid three-counter deployment, real Weather casualties
|
|
* and mutual worm Nexus alliance. Only physical cards/unplayed Spice order move. */
|
|
export function createSemutaHomeworldEmperorShareFixture(
|
|
kind: SemutaEmperorShareKind, advanced = false, fullHolder = false,
|
|
): SemutaHomeworldEmperorShareFixture {
|
|
let game = createGame('SEMUTAHWSHARE', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
|
|
joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
|
|
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
|
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
|
game = applyAction(game, 'r', { type: 'homeworlds', enabled: true });
|
|
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
|
|
game = initializeSemutaGameForAudit(game);
|
|
const actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
|
|
const reach = (until: (g: Game) => boolean) => {
|
|
game = advanceSemutaHomeworldEmperorShare(game, until, actions);
|
|
};
|
|
const act = (actor: string, action: Action) => {
|
|
actions.push({ actor, action }); game = applyAction(game, actor, action);
|
|
};
|
|
reach(g => g.status === 'playing');
|
|
const setup = structuredClone(game);
|
|
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
|
|
orderUnplayedSpice(game, ['land', 'land']);
|
|
staging.push('Original physical Semuta to Richese; first two unplayed original Spice Cards are non-worm.');
|
|
reach(g => g.phase === 5 && g.active === 'a' && clean(g));
|
|
const beforeShipment = structuredClone(game);
|
|
const homeworldSources = nativeShipmentSources(viewGame(game, 'a'), 3, 0);
|
|
assert.ok(homeworldSources);
|
|
const shipment: Action = { type: 'ship', territory: 'red_chasm', sector: 7,
|
|
amount: 3, elite: 0, homeworldSources, allyPayment: 0 };
|
|
act('a', shipment);
|
|
reach(clean);
|
|
const paidShipment = structuredClone(game);
|
|
assert.equal(semutaEmperorPlayer(game, 'a').spice, semutaEmperorPlayer(beforeShipment, 'a').spice - 6);
|
|
assert.equal(semutaEmperorPlayer(game, 'a').forces['red_chasm:7'], 3);
|
|
assert.equal(semutaEmperorPlayer(game, 'a').reserves, 7);
|
|
act('a', { type: 'endMovement' });
|
|
reach(g => g.turn === 2 && g.phase === 0 && clean(g));
|
|
const weatherCard = semutaEmperorCardId(game, card => card.effect === 'weather');
|
|
moveSemutaEmperorCard(game, weatherCard, 'a');
|
|
staging.push('Conserved original printed Weather Control to Atreides before the second native Storm.');
|
|
const beforeWeather = structuredClone(game);
|
|
assert.equal(game.storm, 1);
|
|
const weatherAction: Action = { type: 'card', card: weatherCard, amount: 6 };
|
|
act('a', weatherAction);
|
|
reach(clean);
|
|
const weather = structuredClone(game);
|
|
assert.equal(game.stormPending, 6);
|
|
assert.equal(game.stormMovementSource?.kind, 'weather');
|
|
reach(g => g.phase === 1 && clean(g));
|
|
const death = structuredClone(game);
|
|
assert.equal(game.storm, 7);
|
|
assert.equal(semutaEmperorPlayer(game, 'a').forces['red_chasm:7'] ?? 0, 0);
|
|
assert.equal(semutaEmperorPlayer(game, 'a').forces['arrakeen:10'], 10);
|
|
assert.equal(semutaEmperorPlayer(game, 'a').tanks, 3);
|
|
assert.equal(semutaEmperorPlayer(game, 'e').spice, 10);
|
|
assert.ok(semutaEmperorPlayer(game, 'r').hand.some(card => card.id === SEMUTA_DRUG_ID));
|
|
orderUnplayedSpice(game, ['worm', 'land', 'land']);
|
|
staging.push('The next original unplayed worm opens the second-turn Nexus; later original land cards close Spice Blow.');
|
|
reach(g => g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g));
|
|
const worm = structuredClone(game);
|
|
act('e', { type: 'alliance', target: 'a' });
|
|
act('a', { type: 'alliance', target: 'e' });
|
|
const alliance = structuredClone(game);
|
|
reach(g => g.phase === (kind === 'emperorGift' ? 2 : 4) && clean(g));
|
|
const beforeBenefit = structuredClone(game);
|
|
// Conserved late hand positioning follows every preceding committed market pool.
|
|
for (const p of game.players) game.deck.push(...p.hand.splice(0));
|
|
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
|
|
const karama = semutaEmperorCardId(game, card => card.effect === 'karama');
|
|
moveSemutaEmperorCard(game, karama, 'a');
|
|
if (fullHolder) while (semutaEmperorPlayer(game, 'r').hand.length < handLimit(semutaEmperorPlayer(game, 'r'))) {
|
|
assert.ok(game.deck[0]); moveSemutaEmperorCard(game, game.deck[0].id, 'r');
|
|
}
|
|
const karamaCard = semutaEmperorPlayer(game, 'a').hand.find(card => card.id === karama)!;
|
|
staging.push('Conserved original printed Karama to Atreides and Semuta to Richese; optional full hand uses original undealt stock.');
|
|
assert.deepEqual(semutaHomeworldEmperorShareStock(game), semutaHomeworldEmperorShareStock(setup));
|
|
assert.equal(semutaEmperorPlayer(game, 'e').spice, 10);
|
|
assert.equal(semutaEmperorPlayer(game, 'a').tanks, 3);
|
|
assert.equal(semutaEmperorPlayer(game, 'a').revived, 0);
|
|
// Real UI entry point for the Emperor's own declaration: every conserved card
|
|
// is already positioned, and only the declared benefit response is missing.
|
|
const beforeDeclaration = structuredClone(game);
|
|
const declare: Action = { type: kind, amount: kind === 'emperorGift' ? 3 : 2 };
|
|
act('e', declare);
|
|
assert.equal(game.response?.kind, kind);
|
|
return { kind, game, setup, beforeShipment, paidShipment, beforeWeather, weather, death,
|
|
worm, alliance, beforeBenefit, beforeDeclaration,
|
|
owner: 'e', holder: 'r', recipient: 'a', canceller: 'a',
|
|
karama, karamaCard, weatherCard, shipment, weatherAction,
|
|
declare,
|
|
cancel: { type: 'card', mode: 'cancel', card: karama }, actions, staging };
|
|
}
|
|
|
|
/** Stop at the paid printed cancellation receipt, before its native denial. */
|
|
export function disposeSemutaHomeworldEmperorShare(f: SemutaHomeworldEmperorShareFixture,
|
|
state = f.game, actions?: RicheseSkillsTechStep[]): Game {
|
|
assert.equal(state.response?.kind, f.kind);
|
|
actions?.push({ actor: f.canceller, action: f.cancel });
|
|
return applyAction(state, f.canceller, f.cancel);
|
|
}
|
|
export function reactSemutaHomeworldEmperorShare(state: Game, holder: string, claim: boolean): Game {
|
|
const event = state.pendingTreacheryDiscard!.batch.event;
|
|
if (claim) return applyAction(state, holder, { type: 'semutaCommit', event });
|
|
let game = state;
|
|
for (const p of state.players) game = applyAction(game, p.id, { type: 'semutaPass', event });
|
|
return game;
|
|
}
|
|
/** The still-legal ordinary return this fixture exercises at that revival window. */
|
|
export function semutaHomeworldEmperorOrdinaryRevive(game: Game, playerId: string): number {
|
|
const seat = semutaEmperorPlayer(game, playerId);
|
|
const limit = Math.min(forceRevivalRemaining(game, seat), seat.tanks);
|
|
return Array.from({ length: limit }, (_, index) => index + 1)
|
|
.filter(count => forceRevivalQuote(game, seat, count).cost <= seat.spice).at(-1) ?? 0;
|
|
}
|
|
/** Use the same original controls. Revival additionally exercises the still-legal
|
|
* ordinary Atreides return of whatever two-cost revivals remain that turn. */
|
|
export function finishSemutaHomeworldEmperorShare(state: Game, f: SemutaHomeworldEmperorShareFixture,
|
|
actions?: RicheseSkillsTechStep[]): Game {
|
|
let game = advanceSemutaHomeworldEmperorShare(state, clean, actions);
|
|
if (f.kind === 'emperorRevival') {
|
|
const amount = semutaHomeworldEmperorOrdinaryRevive(game, f.recipient);
|
|
assert.ok(amount > 0, 'The original Tanks still support an ordinary revival this turn');
|
|
const revive: Action = { type: 'revive', amount, elite: 0 };
|
|
actions?.push({ actor: f.recipient, action: revive });
|
|
game = applyAction(game, f.recipient, revive);
|
|
}
|
|
return advanceSemutaHomeworldEmperorShare(game,
|
|
g => g.turn === 2 && g.phase === (f.kind === 'emperorGift' ? 3 : 5) && clean(g), actions);
|
|
}
|