Compose original Homeworld battle reward cancellation costs with Semuta

This commit is contained in:
Quintine committed 2026-10-13 07:37:31 +11:00
1 parent afe3008cae
commit ec574ab0f5
12 files changed
+910 -1

No files matched your search

@@ -0,0 +1,159 @@
import assert from 'node:assert/strict';
import {
createGame, joinGame, newPlayer, initializeSemutaGameForAudit, handLimit,
viewGame, type Action, type Game,
} from '../game/engine';
import { nativeShipmentSources } from '../game/homeworld-options';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { applySemutaHomeworldChoamStep } from './fixture-semuta-homeworld-choam';
import { advanceSemutaHomeworldEmperorShare, nextSemutaHomeworldEmperorShareStep }
from './fixture-semuta-homeworld-emperor-share';
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
export type SemutaHomeworldCaptureKaramaFixture = {
initial: Game; setup: Game; beforeBattle: Game; acceptedBattle: Game;
nativecaptureOffer: Game | null; originalresponse: Game | null;
gameBeforeCost: Game; game: Game;
paidShipment: { actor: string; action: Action; before: Game; after: Game };
actor: string; owner: string; holder: string; captor: string; victim: string;
winner: string; loser: string; location: string; card: string;
winnerLeader: string; loserLeader: string; action: Action;
actions: RicheseSkillsTechStep[]; staging: string[];
};
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
export function semutaHomeworldCaptureKaramaStock(g: Game): string[] {
return [g.deck, g.discard, g.richeseCache ?? [], g.richeseRemoved ?? [],
...g.players.map(p => p.hand)].flat().map(c => c.id).sort();
}
export function semutaHomeworldCaptureKaramaResources(g: Game) {
return { custody: structuredClone(g.homeworlds?.custody), spice: structuredClone(g.spice),
players: g.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves,
tanks: p.tanks, forces: structuredClone(p.forces), elites: structuredClone(p.elites),
battleLosses: p.battleLosses, leaders: structuredClone(p.leaders),
traitors: [...p.traitors], ally: p.ally })),
context: structuredClone(g.lastBattleContext), tech: structuredClone(g.techTokens) };
}
/** Genuine first-turn R/C/E/A/H native setup. Only physical hand positions and
* still-unplayed Spice Card order are staged; no clocks or counters are assigned. */
export function createSemutaHomeworldCaptureKaramaFixture(
fullHolder = false, advanced = true,
): SemutaHomeworldCaptureKaramaFixture {
let game = createGame('SEMUTAHOMECAPTURE', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
for (const [id, name, faction] of [
['c', 'CHOAM', 'choam'], ['e', 'Emperor', 'emperor'],
['a', 'Atreides', 'atreides'], ['h', 'Harkonnen', 'harkonnen'],
] as const) joinGame(game, newPlayer(id, name, faction));
const actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
const act = (actor: string, action: Action) => {
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
};
act(game.host, { type: 'homeworlds', enabled: true });
for (const seat of game.players) act(seat.id, { type: 'ready' });
const initial = structuredClone(game);
game = initializeSemutaGameForAudit(game);
game = advanceSemutaHomeworldEmperorShare(game, g => g.status === 'playing', actions);
const setup = structuredClone(game);
const blows = game.spiceDeck.filter(c => 'territory' in c).slice(0, 2);
assert.equal(blows.length, 2);
for (const [i, card] of blows.entries())
game.spiceDeck.splice(i, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
staging.push('Reorder two original still-unplayed non-worm Spice Cards.');
game = advanceSemutaHomeworldEmperorShare(game,
g => g.phase === 5 && g.active === 'e' && clean(g), actions);
assert.equal(game.turn, 1);
assert.equal(player(game, 'h').forces['carthag:11'], 10);
assert.equal(player(game, 'e').spice, player(setup, 'e').spice,
'Explicit original Bidding readiness must leave the Emperor funding untouched for the real paid deployment');
const sources = nativeShipmentSources(viewGame(game, 'e'), 2, 0);
assert.ok(sources);
const shipment: Action = { type: 'ship', territory: 'carthag', sector: 11,
amount: 2, elite: 0, homeworldSources: sources, allyPayment: 0 };
const beforeShipment = structuredClone(game);
act('e', shipment);
game = advanceSemutaHomeworldEmperorShare(game, clean, actions);
const paidShipment = { actor: 'e', action: shipment, before: beforeShipment, after: structuredClone(game) };
assert.equal(player(game, 'e').spice, player(beforeShipment, 'e').spice - 2);
assert.equal(player(game, 'e').reserves, player(beforeShipment, 'e').reserves - 2);
assert.deepEqual(player(game, 'e').elites, player(beforeShipment, 'e').elites);
act('e', { type: 'endMovement' });
game = advanceSemutaHomeworldEmperorShare(game, g => g.phase === 6 && clean(g), actions);
// Later conserved custody, not an auction award or a copied physical face.
game.deck.push(...player(game, 'r').hand.splice(0));
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
const existingKarama = player(game, 'e').hand.find(c => c.effect === 'karama');
const card = existingKarama?.id ?? moveSemutaEmperorCard(game,
semutaEmperorCardId(game, c => c.effect === 'karama'), 'e');
assert.ok(player(game, 'e').hand.length <= handLimit(player(game, 'e')));
if (fullHolder) while (player(game, 'r').hand.length < handLimit(player(game, 'r'))) {
assert.ok(game.deck[0]);
moveSemutaEmperorCard(game, game.deck[0].id, 'r');
}
staging.push(`Conserved original ${SEMUTA_DRUG_ID} to Richese and printed ${card} to Emperor; ` +
(fullHolder ? 'Richese filled to four using original undealt cards.' : 'Richese holds only Semuta.'));
const beforeBattle = structuredClone(game);
const chooser = game.active;
assert.ok(chooser && ['e', 'h'].includes(chooser));
act(chooser, { type: 'chooseBattle', territory: 'carthag', target: chooser === 'e' ? 'h' : 'e' });
game = advanceSemutaHomeworldEmperorShare(game,
g => !!g.battle?.preLeader?.closed && clean(g), actions);
const winnerLeader = player(game, 'h').leaders.filter(l => !l.dead && !l.usedAt)
.sort((a, b) => b.strength - a.strength)[0].id;
const loserLeader = player(game, 'e').leaders.filter(l => !l.dead && !l.usedAt)
.sort((a, b) => a.strength - b.strength)[0].id;
assert.ok(player(game, 'h').leaders.find(l => l.id === winnerLeader)!.strength + 1 >
player(game, 'e').leaders.find(l => l.id === loserLeader)!.strength);
const battle = game.battle!;
for (const actor of [battle.attacker, battle.defender]) act(actor,
{ type: 'battlePlan', dial: actor === 'h' ? 1 : 0,
support: advanced && actor === 'h' ? 1 : 0,
leader: actor === 'h' ? winnerLeader : loserLeader });
for (const actor of [battle.attacker, battle.defender]) act(actor, { type: 'traitorCall', call: false });
const acceptedBattle = structuredClone(game);
let nativecaptureOffer: Game | null = null, originalresponse: Game | null = null;
if (advanced) {
game = advanceSemutaHomeworldEmperorShare(game, g => g.decision?.kind === 'captureOffer', actions);
assert.equal(game.decision?.player, 'h');
nativecaptureOffer = structuredClone(game);
act('h', { type: 'decision', accept: true });
assert.equal(game.response?.kind, 'capture');
assert.equal(game.response?.owner, 'h');
originalresponse = structuredClone(game);
} else {
game = advanceSemutaHomeworldEmperorShare(game, g => g.phase === 7 && clean(g), actions);
assert.equal(game.pendingCapture, null);
}
return { initial, setup, beforeBattle, acceptedBattle, nativecaptureOffer, originalresponse,
gameBeforeCost: game, game, paidShipment, actor: 'e', owner: 'e', holder: 'r',
captor: 'h', victim: 'e', winner: 'h', loser: 'e', location: 'carthag:11', card,
winnerLeader, loserLeader, action: { type: 'card', mode: 'cancel', card }, actions, staging };
}
export function disposeSemutaHomeworldCaptureKarama(f: SemutaHomeworldCaptureKaramaFixture,
state = f.gameBeforeCost, actions?: RicheseSkillsTechStep[]): Game {
return applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
}
/** Stop before the original final Battle/market close; quote Collection before
* its native credits, never quote an already-collected phase-seven board twice. */
export function finishSemutaHomeworldCaptureKarama(state: Game, actions: RicheseSkillsTechStep[] = []) {
assert.equal(state.pendingTreacheryDiscard, null,
'Resolve the original printed-cost offer before the native Collection continuation');
let game = state;
for (let n = 0; n < 1600; n++) {
assert.equal(game.turn, state.turn);
assert.equal(game.phase, 6);
const beforeCollection = game;
const collection = quoteSpiceCollection(beforeCollection);
const next = nextSemutaHomeworldEmperorShareStep(game);
game = applySemutaHomeworldChoamStep(game, next.actor, next.action, actions);
if (game.phase === 7) {
game = advanceSemutaHomeworldEmperorShare(game, clean, actions);
return { beforeCollection, collection, game };
}
}
throw new Error('Original native capture cleanup must reach Collection');
}