Files
Dune/tests/semuta-homeworld-battle-winner.test.ts
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

271 lines
17 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { finishSemutaBattleOutcome, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
import {
createSemutaHomeworldWinnerFixture, disposeSemutaHomeworldWinner,
finishSemutaHomeworldWinner, semutaHomeworldWinnerResources as resources,
semutaHomeworldWinnerStock as stock, type SemutaHomeworldWinnerFixture,
} from './fixture-semuta-homeworld-battle-winner';
function assertOriginalBattle(f: SemutaHomeworldWinnerFixture, game: Game) {
assert.equal(game.turn, 1);
assert.equal(game.lastBattleContext?.winner, f.winner);
assert.equal(game.lastBattleContext?.result, 'normal');
assert.equal(game.lastBattleContext?.territory, 'red_chasm');
assert.ok(!game.battle, 'The accepted original battle is not reopened');
assert.equal(player(game, f.winner).forces[f.location], 3);
assert.equal(player(game, f.winner).reserves, 16);
assert.equal(player(game, f.winner).tanks, 1);
assert.equal(player(game, f.winner).battleLosses,
player(f.beforeBattle, f.winner).battleLosses + 1);
assert.equal(player(game, f.loser).forces[f.location] ?? 0, 0);
assert.equal(player(game, f.loser).reserves, 19);
assert.equal(player(game, f.loser).tanks, 1);
assert.equal(player(game, f.loser).leaders.find(l => l.id === f.loserLeader)?.dead, true);
assert.equal(player(f.beforeBattle, f.loser).leaders.find(l => l.id === f.loserLeader)?.dead, false);
assert.equal(player(game, f.winner).spice,
player(f.beforeBattle, f.winner).spice - Number(game.advanced) +
player(f.beforeBattle, f.loser).leaders.find(l => l.id === f.loserLeader)!.strength,
'Only original supported dial cost and killed-leader income change CHOAM spice');
assert.equal(player(game, f.loser).spice, player(f.beforeBattle, f.loser).spice,
'Emperor dials and pays zero; no CHOAM opponent battle income is invented');
assert.deepEqual(game.homeworlds?.custody, f.shipments[1].after.homeworlds?.custody,
'Original paid native Homeworld withdrawal remains unchanged by battle cleanup');
for (const seat of game.players.filter(p => ![f.winner, f.loser].includes(p.id))) {
const before = player(f.beforeBattle, seat.id);
assert.deepEqual(seat.forces, before.forces);
assert.deepEqual(seat.elites, before.elites);
assert.equal(seat.reserves, before.reserves);
assert.equal(seat.tanks, before.tanks);
assert.equal(seat.spice, before.spice);
}
assert.deepEqual(stock(game), stock(f.setup));
}
function assertExchange(f: SemutaHomeworldWinnerFixture, pending: Game, game: Game, claim: boolean) {
assert.ok(!game.pendingTreacheryDiscard);
assert.deepEqual(resources(game), resources(pending), 'Semuta never repeats accepted combat or casualties');
assert.deepEqual(game.lastBattleContext, pending.lastBattleContext);
assert.equal(player(game, f.owner).hand.some(c => c.id === f.card), false);
assert.equal(player(game, f.owner).hand.some(c => c.id === f.kept), true);
if (f.kind === 'mandatory')
assert.equal(player(game, f.owner).hand.some(c => c.id === f.projectile), true);
assert.equal(player(game, f.holder).hand.filter(c => c.id === f.card).length, Number(claim));
assert.equal(game.discard.filter(c => c.id === f.card).length, Number(!claim));
assert.equal(player(game, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), !claim);
assert.equal(game.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, Number(claim));
assert.equal(game.discard.filter(c => c.id === f.worthless).length, 1,
'Earlier losing Worthless remains in its original discard, not the later offer');
for (const seat of game.players) {
const expected = player(pending, seat.id).hand.map(c => c.id)
.filter(id => seat.id !== f.holder || !claim || id !== SEMUTA_DRUG_ID);
if (seat.id === f.holder && claim) expected.push(f.card);
assert.deepEqual(seat.hand.map(c => c.id).sort(), expected.sort(),
'Exactly the chosen card exchanges with Semuta; every other physical hand card stays put');
}
assertOriginalBattle(f, game);
assert.equal(game.decision?.kind, f.kind === 'mandatory' ? 'battleCards' : 'choamMarket');
assert.equal(game.decision?.player, f.owner);
}
function finishOnce(f: SemutaHomeworldWinnerFixture, state: Game) {
const expected = resources(state), context = structuredClone(state.lastBattleContext);
const collection = quoteSpiceCollection(state);
assert.equal(collection.receipts.find(receipt => receipt.player === f.owner)!.desert, 6,
'Three surviving ordinary counters collect six from the original eight-spice blow');
for (const receipt of collection.receipts)
expected.players.find(p => p.id === receipt.player)!.spice = receipt.balance;
const game = finishSemutaHomeworldWinner(state);
assert.equal(game.phase, 7);
assert.equal(game.turn, 1);
assert.ok(!game.decision && !game.response && !game.pendingTreacheryDiscard);
assert.deepEqual(resources(game), expected,
'Original Collection credits its board once; battle casualties and death income do not repeat');
assert.deepEqual(game.spice, collection.spice);
assert.deepEqual(game.lastBattleContext, context);
assert.throws(() => applyAction(game, f.owner, { type: 'decision', done: true }),
'The original native CHOAM market cannot close twice');
return game;
}
for (const advanced of [false, true]) for (const kind of ['mandatory', 'selected'] as const) {
const label = `${advanced ? 'Advanced' : 'Basic'} paid Homeworld winner ${kind}`;
for (const claim of [false, true]) void test(`${label}: ${claim ? 'claim' : 'all-pass'} exact fresh card before one native cleanup`, () => {
const f = createSemutaHomeworldWinnerFixture(kind, advanced);
for (const shipment of f.shipments) {
assert.equal(shipment.action.type, 'ship');
assert.equal(player(shipment.after, shipment.actor).spice,
player(shipment.before, shipment.actor).spice - (shipment.actor === f.loser ? 2 : 8));
assert.equal(player(shipment.after, shipment.actor).reserves,
player(shipment.before, shipment.actor).reserves - (shipment.actor === f.loser ? 1 : 4));
assert.deepEqual(player(shipment.after, shipment.actor).elites, player(shipment.before, shipment.actor).elites,
'These quotes ship ordinary native counters, not Sardaukar');
}
const pending = disposeSemutaHomeworldWinner(f);
const event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(pending.pendingTreacheryDiscard?.batch.entries.map(e => e.card.id), [f.card]);
assert.equal(pending.discard.filter(c => c.id === f.card).length, 1);
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
assertOriginalBattle(f, pending);
assert.throws(() => applyAction(pending, f.actor, f.action), 'Accepted original disposal cannot replay');
assert.throws(() => applyAction(pending, f.owner, { type: 'decision', discard: [] }));
assert.throws(() => applyAction(pending, f.loser, { type: 'ready' }));
assert.throws(() => applyAction(pending, f.loser, { type: 'semutaCommit', event }));
assert.throws(() => applyAction(pending, f.holder, { type: 'semutaCommit', event: `${event}-retired` }));
const acknowledged = applyAction(pending, f.loser, { type: 'semutaPass', event });
assert.deepEqual(resources(acknowledged), resources(pending));
assert.equal(viewGame(acknowledged, f.holder).semutaReaction?.canCommit, true,
'A nonholder acknowledgement does not advance cleanup or close holder entitlement');
assert.throws(() => applyAction(acknowledged, f.loser, { type: 'semutaPass', event }));
assert.throws(() => applyAction(acknowledged, f.loser, { type: 'semutaCommit', event }));
const done = claim
? applyAction(acknowledged, f.holder, { type: 'semutaCommit', event })
: reactSemutaHomeworldChoam(structuredClone(pending), f.holder, false);
assertExchange(f, pending, done, claim);
for (const action of [{ type: 'semutaCommit', event }, { type: 'semutaPass', event },
{ type: 'semutaSelect', event, card: f.card }])
assert.throws(() => applyAction(done, f.holder, action));
const collected = finishOnce(f, done);
assert.equal(player(collected, f.owner).hand.some(c => c.id === f.kept), true);
assert.throws(() => applyAction(collected, f.holder, { type: 'semutaCommit', event }));
});
void test(`${label}: full native Richese hand exchanges atomically and entitlement stays private`, () => {
const f = createSemutaHomeworldWinnerFixture(kind, advanced, true);
assert.equal(player(f.game, f.holder).hand.length, 4);
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder);
const pending = disposeSemutaHomeworldWinner(f);
const neutral = disposeSemutaHomeworldWinner(f, absent);
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
for (const seat of pending.players.filter(p => p.id !== f.holder)) {
assert.deepEqual(JSON.parse(JSON.stringify(viewGame(pending, seat.id))),
JSON.parse(JSON.stringify(viewGame(neutral, seat.id))),
'Nonholders cannot infer the private Semuta entitlement from consumer views');
assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false);
}
const claimed = reactSemutaHomeworldChoam(pending, f.holder, true);
assertExchange(f, pending, claimed, true);
assert.equal(player(claimed, f.holder).hand.length, 4);
const passed = reactSemutaHomeworldChoam(neutral, f.holder, false);
assert.deepEqual(resources(claimed), resources(passed));
finishOnce(f, claimed);
finishOnce(f, passed);
});
void test(`${label}: original selected multi-card batch requires a private exact one-card commitment`, () => {
const f = createSemutaHomeworldWinnerFixture(kind, advanced, true);
let originalCleanup = f.game;
if (kind === 'mandatory') {
const mandatory = disposeSemutaHomeworldWinner(f);
originalCleanup = reactSemutaHomeworldChoam(mandatory, f.holder, false);
}
assert.equal(originalCleanup.decision?.kind, 'battleCards');
const pending = applyAction(originalCleanup, f.owner,
{ type: 'decision', discard: [f.projectile, f.kept] });
const event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(viewGame(pending, f.holder).semutaReaction?.candidates, []);
assert.equal(player(pending, f.owner).hand.some(c => c.id === f.projectile || c.id === f.kept), false);
assert.throws(() => applyAction(pending, f.holder, { type: 'semutaSelect', event, card: f.projectile }));
const committed = applyAction(pending, f.holder, { type: 'semutaCommit', event });
assert.equal(committed.pendingTreacheryDiscard?.reaction?.stage, 'select');
assert.deepEqual(viewGame(committed, f.holder).semutaReaction?.candidates.map(c => c.id).sort(),
[f.projectile, f.kept].sort());
for (const seat of committed.players.filter(p => p.id !== f.holder)) {
assert.deepEqual(viewGame(committed, seat.id).semutaReaction?.candidates, []);
assert.throws(() => applyAction(committed, seat.id, { type: 'semutaSelect', event, card: f.projectile }));
}
assert.throws(() => applyAction(committed, f.holder, { type: 'semutaPass', event }));
assert.throws(() => applyAction(committed, f.holder, { type: 'semutaCommit', event }));
assert.throws(() => applyAction(committed, f.holder, { type: 'semutaSelect', event, card: f.worthless }));
assert.deepEqual(resources(committed), resources(pending));
for (const chosen of [f.projectile, f.kept]) {
const done = applyAction(structuredClone(committed), f.holder, { type: 'semutaSelect', event, card: chosen });
const other = chosen === f.projectile ? f.kept : f.projectile;
assert.equal(player(done, f.holder).hand.filter(c => c.id === chosen).length, 1);
assert.equal(player(done, f.holder).hand.length, 4);
assert.equal(done.discard.filter(c => c.id === chosen).length, 0);
assert.equal(done.discard.filter(c => c.id === other).length, 1);
assert.equal(done.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(done.discard.filter(c => c.id === f.worthless).length, 1);
if (kind === 'mandatory') assert.equal(done.discard.filter(c => c.id === f.card).length, 1);
assert.deepEqual(resources(done), resources(pending));
assert.deepEqual(stock(done), stock(f.setup));
const remaining = player(pending, f.holder).hand.map(c => c.id).filter(id => id !== SEMUTA_DRUG_ID);
assert.deepEqual(player(done, f.holder).hand.map(c => c.id).sort(), [...remaining, chosen].sort());
assert.throws(() => applyAction(done, f.holder, { type: 'semutaSelect', event, card: other }));
finishOnce(f, done);
}
});
for (const difficulty of DIFFICULTIES) void test(`${difficulty}: legal minimum ${label} consumer and native continuation`, () => {
const f = createSemutaHomeworldWinnerFixture(kind, advanced);
const pending = disposeSemutaHomeworldWinner(f);
const own = viewGame(pending, f.holder);
own.players.find(p => p.id === f.holder)!.bot = difficulty;
const action = botActions(own)[0];
assert.ok(action);
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass');
let game = applyAction(pending, f.holder, action);
if (game.pendingTreacheryDiscard) {
const event = game.pendingTreacheryDiscard.batch.event;
for (const seat of game.players) {
if (!game.pendingTreacheryDiscard) break;
const reaction = game.pendingTreacheryDiscard.reaction;
assert.ok(reaction?.stage === 'offer');
if (!reaction.passed.includes(seat.id))
game = applyAction(game, seat.id, { type: 'semutaPass', event });
}
}
assertExchange(f, pending, game, action.type === 'semutaCommit');
finishOnce(f, game);
});
}
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} original losing, mandatory winning and selected cleanup are distinct fresh events`, () => {
const f = createSemutaHomeworldWinnerFixture('mandatory', advanced);
let game = finishSemutaBattleOutcome(applyAction(f.game, f.actor, f.action));
const losingEvent = game.pendingTreacheryDiscard!.batch.event;
assert.deepEqual(game.pendingTreacheryDiscard?.batch.entries.map(e => e.card.id), [f.worthless]);
assert.equal(player(game, f.owner).hand.some(c => c.id === f.card), true,
'Mandatory winner Hero is still physically reserved through the earlier losing offer');
assert.equal(player(game, f.owner).forces[f.location], 4, 'Winner casualties follow the first offer');
assert.equal(player(game, f.owner).tanks, 0);
assert.equal(player(game, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), true);
game = reactSemutaHomeworldChoam(game, f.holder, false);
const mandatoryEvent = game.pendingTreacheryDiscard!.batch.event;
assert.notEqual(mandatoryEvent, losingEvent);
assert.deepEqual(game.pendingTreacheryDiscard?.batch.entries.map(e => e.card.id), [f.card]);
assertOriginalBattle(f, game);
assert.throws(() => applyAction(game, f.holder, { type: 'semutaCommit', event: losingEvent }));
assert.throws(() => applyAction(game, f.loser, { type: 'semutaPass', event: losingEvent }));
game = reactSemutaHomeworldChoam(game, f.holder, false);
assert.equal(game.decision?.kind, 'battleCards');
assert.equal(game.decision?.player, f.owner);
assert.throws(() => applyAction(game, f.loser, { type: 'decision', discard: [f.projectile] }));
game = applyAction(game, f.owner, { type: 'decision', discard: [f.projectile] });
const selectedEvent = game.pendingTreacheryDiscard!.batch.event;
assert.notEqual(selectedEvent, losingEvent);
assert.notEqual(selectedEvent, mandatoryEvent);
assert.deepEqual(game.pendingTreacheryDiscard?.batch.entries.map(e => e.card.id), [f.projectile]);
assert.throws(() => applyAction(game, f.holder, { type: 'semutaCommit', event: mandatoryEvent }));
const before = resources(game);
game = reactSemutaHomeworldChoam(game, f.holder, true);
assert.deepEqual(resources(game), before);
assert.equal(player(game, f.holder).hand.filter(c => c.id === f.projectile).length, 1);
assert.equal(game.discard.filter(c => c.id === f.card).length, 1);
assert.equal(game.discard.filter(c => c.id === f.worthless).length, 1);
assert.equal(player(game, f.owner).hand.some(c => c.id === f.kept), true);
finishOnce(f, game);
});