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.
271 lines
17 KiB
TypeScript
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);
|
|
});
|