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.
150 lines
7.7 KiB
TypeScript
150 lines
7.7 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { isAuditorLeader } from '../game/cards';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
|
|
|
|
export type SemutaBattleKaramaKind = 'capture' | 'choamAudit' | 'choamBattleIncome';
|
|
export const reloadBattleKarama = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
|
export const battleKaramaPlayer = (game: Game, id: string) => game.players.find(p => p.id === id)!;
|
|
|
|
function take(game: Game, id: string) {
|
|
for (const zone of [game.deck, game.discard, game.richeseCache!, ...game.players.map(p => p.hand)]) {
|
|
const index = zone.findIndex(card => card.id === id || card.effect === id);
|
|
if (index >= 0) return zone.splice(index, 1)[0];
|
|
}
|
|
throw new Error(`Missing physical ${id}`);
|
|
}
|
|
|
|
export function battleKaramaInventory(game: Game) {
|
|
const ids = [...game.deck, ...game.discard, ...game.richeseCache!, ...game.players.flatMap(p => p.hand)]
|
|
.map(card => card.id).sort();
|
|
assert.equal(new Set(ids).size, ids.length);
|
|
return ids;
|
|
}
|
|
|
|
export function passBattleKaramaOffer(game: Game): Game {
|
|
const event = game.pendingTreacheryDiscard!.batch.event;
|
|
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'semutaPass', event });
|
|
return game;
|
|
}
|
|
|
|
export function battleKaramaCostAction(card: string): Action {
|
|
return { type: 'card', mode: 'cancel', card };
|
|
}
|
|
|
|
export function settleBattleKaramaCost(game: Game, claim: boolean): Game {
|
|
return claim
|
|
? applyAction(game, 'r', { type: 'semutaCommit', event: game.pendingTreacheryDiscard!.batch.event })
|
|
: passBattleKaramaOffer(game);
|
|
}
|
|
|
|
// Complete any remaining original income window and native CHOAM market.
|
|
export function finishBattleKarama(game: Game): Game {
|
|
for (let step = 0; step < 12 && game.response?.kind === 'choamBattleIncome'; step++) {
|
|
const seat = game.players.find(p => !game.response!.passed.includes(p.id))!;
|
|
game = applyAction(game, seat.id, { type: 'passResponse' });
|
|
}
|
|
if (game.decision?.kind === 'choamMarket')
|
|
game = applyAction(game, game.decision.player, { type: 'decision', done: true });
|
|
assert.equal(game.phase, 7);
|
|
return game;
|
|
}
|
|
|
|
export function semutaBattleKaramaFixture(kind: SemutaBattleKaramaKind) {
|
|
const capture = kind === 'capture';
|
|
let game = createGame('SEMUTABK', newPlayer('r', 'Richese', 'richese'), true,
|
|
capture ? ['choam'] : ['choam', 'ix']);
|
|
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
|
joinGame(game, newPlayer('e', capture ? 'Harkonnen' : 'CHOAM', capture ? 'harkonnen' : 'choam'));
|
|
for (const seat of game.players) { seat.bot = 'Medium'; seat.ready = true; }
|
|
game = initializeSemutaGameForAudit(game);
|
|
for (let step = 0; game.status === 'setup' && step < 80; step++) {
|
|
let next: Game | undefined;
|
|
for (const seat of game.players) {
|
|
const action = botActions(viewGame(game, seat.id))[0];
|
|
if (action) { next = applyAction(game, seat.id, action); break; }
|
|
}
|
|
assert.ok(next, 'native setup must provide a legal choice');
|
|
game = next;
|
|
}
|
|
assert.equal(game.status, 'playing');
|
|
|
|
// Only later physical staging changes: every printed card and force remains conserved.
|
|
game.deck.push(...game.players.flatMap(p => p.hand));
|
|
for (const seat of game.players) {
|
|
Object.assign(seat, { hand: [], spice: 10, forces: {}, reserves: 20, tanks: 0 });
|
|
}
|
|
const semuta = take(game, SEMUTA_DRUG_ID), karama = take(game, 'karama'), privateCard = take(game, 'hajr');
|
|
const weapon = take(game, game.deck.find(card => card.kind === 'projectile')!.id);
|
|
const defense = take(game, game.deck.find(card => card.kind === 'snooper')!.id);
|
|
const winner = capture ? 'e' : 'a', loser = capture ? 'a' : 'e';
|
|
battleKaramaPlayer(game, 'r').hand.push(semuta);
|
|
battleKaramaPlayer(game, 'a').hand.push(karama, privateCard);
|
|
battleKaramaPlayer(game, winner).hand.push(weapon);
|
|
battleKaramaPlayer(game, loser).hand.push(defense);
|
|
for (const id of ['a', 'e'])
|
|
Object.assign(battleKaramaPlayer(game, id), { forces: { 'arrakeen:10': 5 }, reserves: 15 });
|
|
Object.assign(game, { turn: 2, phase: 6, active: winner, order: [winner, loser, 'r'],
|
|
storm: 18, response: null, decision: null, phaseOpening: null, stormPending: null,
|
|
movementRemaining: [], ready: [] });
|
|
const staged = reloadBattleKarama(game), inventory = battleKaramaInventory(game);
|
|
const winnerLeader = battleKaramaPlayer(game, winner).leaders[0];
|
|
const loserLeader = kind === 'choamAudit'
|
|
? battleKaramaPlayer(game, loser).leaders.find(isAuditorLeader)!
|
|
: battleKaramaPlayer(game, loser).leaders[0];
|
|
assert.equal(winnerLeader.strength, capture ? 6 : 5);
|
|
assert.equal(loserLeader.strength, capture ? 5 : kind === 'choamAudit' ? 2 : 4);
|
|
|
|
game = applyAction(game, winner, { type: 'chooseBattle', territory: 'arrakeen', target: loser });
|
|
for (let step = 0; step < 40; step++) {
|
|
if (game.response) {
|
|
const seat = game.players.find(p => !game.response!.passed.includes(p.id))!;
|
|
game = applyAction(game, seat.id, { type: 'passResponse' });
|
|
} else if (game.battle?.preparation)
|
|
game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
|
|
else if (game.decision?.kind === 'fullPlanOffer')
|
|
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
|
|
else break;
|
|
}
|
|
for (const id of [winner, loser])
|
|
game = applyAction(game, id, { type: 'battlePreparationReady', event: game.battle!.preLeader!.event });
|
|
game = applyAction(game, winner, { type: 'battlePlan', dial: 2, support: 2,
|
|
leader: winnerLeader.id, weapon: weapon.id });
|
|
game = applyAction(game, loser, { type: 'battlePlan', dial: 0, support: 0,
|
|
leader: loserLeader.id, defense: defense.id });
|
|
for (const id of [winner, loser]) game = applyAction(game, id, { type: 'traitorCall', call: false });
|
|
game = finishSemutaBattleOutcome(game);
|
|
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
const earlierEvents = [game.pendingTreacheryDiscard!.batch.event];
|
|
game = passBattleKaramaOffer(game);
|
|
assert.equal(game.decision?.kind, 'battleCards');
|
|
game = applyAction(game, winner, { type: 'decision', discard: [weapon.id] });
|
|
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
earlierEvents.push(game.pendingTreacheryDiscard!.batch.event);
|
|
game = passBattleKaramaOffer(game);
|
|
assert.equal(battleKaramaPlayer(game, 'r').hand[0].id, semuta.id);
|
|
if (kind === 'choamAudit') {
|
|
// Original force income precedes Auditor; allow it before opening the audit.
|
|
for (let step = 0; game.response?.kind === 'choamBattleIncome' && step < 12; step++) {
|
|
const seat = game.players.find(p => !game.response!.passed.includes(p.id))!;
|
|
game = applyAction(game, seat.id, { type: 'passResponse' });
|
|
}
|
|
}
|
|
const nativeOffer = reloadBattleKarama(game);
|
|
if (capture) {
|
|
assert.equal(game.decision?.kind, 'captureOffer');
|
|
game = applyAction(game, 'e', { type: 'decision', accept: true });
|
|
} else if (kind === 'choamAudit') {
|
|
assert.equal(game.decision?.kind, 'choamAudit');
|
|
game = applyAction(game, 'e', { type: 'decision', event: game.pendingAuditor!.event, audit: true });
|
|
}
|
|
assert.equal(game.response?.kind, kind);
|
|
return { kind, staged, nativeOffer, originalResponse: reloadBattleKarama(game), game,
|
|
spender: 'a', holder: 'r', winner, loser, karama: karama.id, semuta: semuta.id,
|
|
privateCard: privateCard.id, weapon: weapon.id, defense: defense.id,
|
|
winnerLeader: winnerLeader.id, loserLeader: loserLeader.id, bounty: loserLeader.strength,
|
|
earlierEvents, inventory, cancel: battleKaramaCostAction(karama.id) };
|
|
}
|