Files
Dune/tests/fixture-semuta-battle-karama.ts
T
Quintine d83db397c0 Resolve advisor conversion after battle losses before optional cleanup
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.
2026-10-24 20:37:09 +11:00

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) };
}