Preserve committed casualties, payments and mandatory disposal when battle losses leave BG bystanders alone. Bind the public phase-six receipt to the original battle and resume its private winner/aftermath cursor once. Record provisional priority; explosions with no surviving advisors create no response. Repair stale Semuta fixtures to acknowledge the existing final battle window; a representative failure reproduces against unchanged base035be51. Production Semuta guards stay intact. Validation: 16 affected files264/264, types/lint/build, five native battle and GameTable SSR journeys, eight existing-profile response paths. Genuine44-action sources earn original cards/forces and actual Collection outcomes. Broader combinations and complete modes remain unfinished. Not deployed; no room/D1, reset or restart operation. Runtime fingerprint 665defe51a1f46a6b5992b6e1e4b034203f91e7101acf860eb549f5bf9865151; checkpoint prose/reference overlays are reviewed separately.
153 lines
8.4 KiB
TypeScript
153 lines
8.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { handLimit, viewGame, type Action, type Game } from '../game/engine';
|
|
import { nativeShipmentSources } from '../game/homeworld-options';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { semutaEmperorPlayer as player, moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
|
|
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
|
|
createSemutaHomeworldChoamSetup, finishSemutaBattleOutcome,
|
|
holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
|
|
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
|
|
|
export type SemutaHomeworldWinnerKind = 'mandatory' | 'selected';
|
|
export type SemutaHomeworldWinnerFixture = {
|
|
kind: SemutaHomeworldWinnerKind; setup: Game; beforeBattle: Game; game: Game;
|
|
owner: string; holder: string; winner: string; loser: string; location: string;
|
|
card: string; kept: string; projectile: string; worthless: string;
|
|
winnerLeader: string; loserLeader: string; actor: string; action: Action;
|
|
actions: RicheseSkillsTechStep[]; staging: string[];
|
|
shipments: { actor: string; action: Action; before: Game; after: Game }[];
|
|
earlierEvents: string[];
|
|
};
|
|
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
|
|
export function semutaHomeworldWinnerStock(g: Game) {
|
|
return [g.deck, g.discard, g.richeseCache ?? [], g.richeseRemoved ?? [],
|
|
...g.players.map(p => p.hand)].flat().map(c => c.id).sort();
|
|
}
|
|
export function semutaHomeworldWinnerResources(g: Game) {
|
|
return { custody: structuredClone(g.homeworlds?.custody),
|
|
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) })) };
|
|
}
|
|
function passOffer(state: Game, actions?: RicheseSkillsTechStep[]) {
|
|
let g = state;
|
|
const pending = g.pendingTreacheryDiscard;
|
|
assert.equal(pending?.reaction?.stage, 'offer');
|
|
const event = pending!.batch.event;
|
|
for (const seat of state.players) {
|
|
if (!g.pendingTreacheryDiscard || g.pendingTreacheryDiscard.batch.event !== event) break;
|
|
const reaction = g.pendingTreacheryDiscard.reaction;
|
|
assert.ok(reaction?.stage === 'offer');
|
|
if (!reaction.passed.includes(seat.id))
|
|
g = applySemutaHomeworldChoamStep(g, seat.id, { type: 'semutaPass', event }, actions);
|
|
}
|
|
return g;
|
|
}
|
|
|
|
/** Original first-turn game: native paid shipments and plans; only conserved
|
|
* physical hands and two unplayed non-worm Spice Cards are positioned. */
|
|
export function createSemutaHomeworldWinnerFixture(
|
|
kind: SemutaHomeworldWinnerKind, advanced = false, fullHolder = false,
|
|
): SemutaHomeworldWinnerFixture {
|
|
const native = createSemutaHomeworldChoamSetup(advanced);
|
|
let game = native.game;
|
|
const setup = structuredClone(game), actions = native.actions;
|
|
const owner = native.owner, holder = native.holder, loser = native.canceller;
|
|
const staging = ['Conserved original physical Semuta held by Richese.'];
|
|
const battleBlow = game.spiceDeck.find(c => 'territory' in c && c.territory === 'red_chasm');
|
|
const otherBlow = game.spiceDeck.find(c => 'territory' in c && c !== battleBlow);
|
|
assert.ok(battleBlow && otherBlow);
|
|
const blows = [battleBlow, otherBlow];
|
|
for (const [index, card] of blows.entries())
|
|
game.spiceDeck.splice(index, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
|
|
staging.push('Reorder the original Red Chasm Spice Card and another non-worm card, proving real Collection income after cleanup.');
|
|
const shipments: SemutaHomeworldWinnerFixture['shipments'] = [];
|
|
for (const [actor, amount] of [[loser, 1], [owner, 4]] as const) {
|
|
game = advanceSemutaHomeworldChoam(game,
|
|
g => g.phase === 5 && g.active === actor && clean(g), actions);
|
|
assert.equal(game.turn, 1);
|
|
const sources = nativeShipmentSources(viewGame(game, actor), amount, 0);
|
|
assert.ok(sources);
|
|
const action: Action = { type: 'ship', territory: 'red_chasm', sector: 7,
|
|
amount, elite: 0, homeworldSources: sources, allyPayment: 0 };
|
|
const before = structuredClone(game);
|
|
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
|
|
game = advanceSemutaHomeworldChoam(game, clean, actions);
|
|
shipments.push({ actor, action, before, after: structuredClone(game) });
|
|
assert.equal(player(game, actor).spice, player(before, actor).spice - 2 * amount);
|
|
assert.equal(player(game, actor).reserves, player(before, actor).reserves - amount);
|
|
game = applySemutaHomeworldChoamStep(game, actor, { type: 'endMovement' }, actions);
|
|
}
|
|
game = advanceSemutaHomeworldChoam(game, g => g.phase === 6 && clean(g), actions);
|
|
const projectile = holdSemutaHomeworldChoamCard(game, owner, 'Crysknife');
|
|
const kept = holdSemutaHomeworldChoamCard(game, owner, 'Shield');
|
|
const worthless = holdSemutaHomeworldChoamCard(game, loser, 'Baliset');
|
|
const cheap = kind === 'mandatory' ? holdSemutaHomeworldChoamCard(game, owner, 'Cheap Hero') : null;
|
|
staging.push('Conserved printed Crysknife and Shield to CHOAM; Worthless to Emperor.' +
|
|
(cheap ? ' Conserved printed Cheap Hero to CHOAM.' : ''));
|
|
if (fullHolder) {
|
|
while (player(game, holder).hand.length < handLimit(player(game, holder))) {
|
|
const card = game.deck.find(c => c.id !== SEMUTA_DRUG_ID);
|
|
assert.ok(card);
|
|
moveSemutaEmperorCard(game, card.id, holder);
|
|
}
|
|
staging.push('Conserved undealt physical cards fill the Richese hand to its native limit.');
|
|
}
|
|
assert.ok(player(game, owner).hand.length <= handLimit(player(game, owner)));
|
|
const beforeBattle = structuredClone(game);
|
|
const chooser = game.active;
|
|
assert.ok(chooser && [owner, loser].includes(chooser));
|
|
const target = chooser === owner ? loser : owner;
|
|
game = applySemutaHomeworldChoamStep(game, chooser,
|
|
{ type: 'chooseBattle', territory: 'red_chasm', target }, actions);
|
|
game = advanceSemutaHomeworldChoam(game,
|
|
g => !!g.battle?.preLeader?.closed && clean(g), actions);
|
|
const winnerLeader = cheap ?? player(game, owner).leaders.find(l => !l.dead && !l.usedAt)!.id;
|
|
const loserLeader = player(game, loser).leaders.find(l => !l.dead && !l.usedAt)!.id;
|
|
for (const actor of [game.battle!.attacker, game.battle!.defender]) {
|
|
const action: Action = actor === owner
|
|
? { type: 'battlePlan', dial: 1, support: advanced ? 1 : 0,
|
|
leader: winnerLeader, weapon: projectile, defense: kept }
|
|
: { type: 'battlePlan', dial: 0, support: 0, leader: loserLeader, weapon: worthless };
|
|
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
|
|
}
|
|
const voters = [game.battle!.attacker, game.battle!.defender];
|
|
game = applySemutaHomeworldChoamStep(game, voters[0], { type: 'traitorCall', call: false }, actions);
|
|
const finalResponse: Action = { type: 'traitorCall', call: false };
|
|
const earlierEvents: string[] = [];
|
|
let action = finalResponse;
|
|
if (kind === 'selected') {
|
|
game = applySemutaHomeworldChoamStep(game, voters[1], finalResponse, actions);
|
|
game = finishSemutaBattleOutcome(game, actions);
|
|
if (game.pendingTreacheryDiscard) {
|
|
earlierEvents.push(game.pendingTreacheryDiscard.batch.event);
|
|
game = passOffer(game, actions);
|
|
}
|
|
game = advanceSemutaHomeworldChoam(game,
|
|
g => g.decision?.kind === 'battleCards', actions);
|
|
action = { type: 'decision', discard: [projectile] };
|
|
}
|
|
return { kind, setup, beforeBattle, game, owner, holder, winner: owner, loser,
|
|
location: 'red_chasm:7', card: cheap ?? projectile, kept, projectile, worthless,
|
|
winnerLeader, loserLeader, actor: kind === 'mandatory' ? voters[1] : owner,
|
|
action, actions, staging, shipments, earlierEvents };
|
|
}
|
|
|
|
export function disposeSemutaHomeworldWinner(f: SemutaHomeworldWinnerFixture,
|
|
state = f.game, actions?: RicheseSkillsTechStep[]): Game {
|
|
let game = applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
|
|
game = finishSemutaBattleOutcome(game, actions);
|
|
if (f.kind === 'mandatory' && game.pendingTreacheryDiscard?.continuation.kind === 'battleResolved')
|
|
game = passOffer(game, actions);
|
|
return game;
|
|
}
|
|
|
|
/** Retain unselected original winner cards, complete the real CHOAM market and
|
|
* reach Collection without ever replaying battle or editing the native clock. */
|
|
export function finishSemutaHomeworldWinner(state: Game,
|
|
actions?: RicheseSkillsTechStep[]): Game {
|
|
const game = advanceSemutaHomeworldChoam(state, g => g.phase === 7 && clean(g), actions);
|
|
assert.equal(game.turn, state.turn);
|
|
return game;
|
|
}
|