Files
Dune/tests/semuta-homeworld-battle-sapho.test.ts
Quintine 84200ebc74 Allow late Sapho priority before final battle settlement
Fresh Richese-roster battles support clean aggressor changes after plans and revelation. Both combatants explicitly finish a public final opportunity regardless of held cards or result; an actual Sapho use clears both marks with a fresh event. Close priority before physical Stone allocation and other outcome settlement. Preserve plans, votes, participants, Habbanya priority and reserved physical cards. Keep old unstamped timing and discard signature bytes. Route all final vote exits through native Homeworld defense first. Add persistent controls and finite minimal bot readiness.

Validation:16-file177/177, types/lint/build and eight original-action/rendered-table programmes. Actual cache1 and paid forces use40 Basic/47 Advanced source actions; genuine Stone1 turn1/Sapho1 turn2 uses86. Ordinary and mixed count2 ties change through sealed-plan/final-window Sapho, explicit revised acknowledgements and native losses. Sixteen minimal proposals are legal; controlled reservation and pure window/control cases are separate. Bounded review fixed the Harkonnen Nexus final-vote route; frozen feedback corrected four historical fixture assumptions. All checks then passed together.

Final-window protocol is provisional. Direct late Semuta claim/capture, combined World/Nexus/Sapho, wider combinations, full modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
2026-10-18 13:46:00 +11:00

279 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 { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
import {
createSemutaHomeworldBattleSaphoFixture, disposeSemutaHomeworldBattleSapho,
finishSemutaHomeworldBattleSapho,
} from './fixture-semuta-homeworld-battle-sapho';
const combos = [
{ scope: 'aggressor', mode: 'first' },
{ scope: 'order', mode: 'first' },
{ scope: 'order', mode: 'last' },
] as const;
/** Only original force, wallet, leader and tank custody — never the committed
* Sapho card, its discard or the scheduling receipt, which the action changes. */
function custody(game: Game) {
return game.players.map(seat => ({ id: seat.id, spice: seat.spice, reserves: seat.reserves,
tanks: seat.tanks, forces: structuredClone(seat.forces), elites: structuredClone(seat.elites),
leaders: structuredClone(seat.leaders) }));
}
function offerEvent(game: Game): string {
const pending = game.pendingTreacheryDiscard;
assert.ok(pending, 'A fresh public disposal offer must be open');
assert.equal(pending.reaction?.stage, 'offer');
return pending.batch.event;
}
function passOffer(state: Game): Game {
let game = state;
if (!game.pendingTreacheryDiscard) return game;
const event = offerEvent(game);
for (const seat of state.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 });
}
return game;
}
for (const advanced of [false, true]) for (const combo of combos) {
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld Sapho ${combo.scope} ${combo.mode}`;
void test(`${label}: actual paid prefix disposes the exact card and commits its real effect`, () => {
const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced);
for (const shipment of f.shipments) {
const amount = shipment.actor === f.choam ? 4 : 1;
const price = shipment.actor === f.holder ? 1 : 2 * amount;
const destination = shipment.actor === f.holder ? 'arrakeen:10' : f.location;
assert.equal(player(shipment.after, shipment.actor).spice,
player(shipment.before, shipment.actor).spice - price);
assert.equal(player(shipment.after, shipment.actor).reserves,
player(shipment.before, shipment.actor).reserves - amount);
assert.equal(player(shipment.after, shipment.actor).forces[destination], amount);
assert.deepEqual(player(shipment.after, shipment.actor).elites,
player(shipment.before, shipment.actor).elites);
}
const pending = disposeSemutaHomeworldBattleSapho(f);
assert.equal(player(pending, f.owner).hand.some(card => card.id === f.card), false);
assert.equal(pending.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(pending.order, f.game.order, 'Physical storm order is never rewritten');
assert.deepEqual(custody(pending), custody(f.game), 'No paid prefix, leader or casualty moves');
if (f.scope === 'aggressor') {
assert.equal(pending.battle!.saphoAggressor!.uses.at(-1)!.player, f.owner);
assert.deepEqual(pending.battleOrder, f.game.battleOrder,
'Early aggression preserves original remaining chooser state');
} else {
assert.ok(!pending.battle, 'A completed prefix does not create the next battle');
assert.equal(pending.battleOrder!.uses.length, 1);
assert.equal(pending.battleOrder!.uses[0].mode, f.mode);
assert.equal(pending.battleOrder!.uses[0].player, f.owner);
assert.deepEqual([...pending.battleOrder!.priority].sort(), [...pending.order].sort());
assert.equal(player(pending, f.holder).tanks, 1, 'The original losing prefix force stays destroyed');
assert.equal(player(pending, f.holder).forces['arrakeen:10'] ?? 0, 0);
assert.equal(player(pending, f.atreides).forces['arrakeen:10'], 10, 'Atreides keeps its untouched ten');
assert.equal(player(pending, f.atreides).reserves, player(f.completedPrefix!.before, f.atreides).reserves);
assert.equal(pending.lastBattleContext?.event, f.completedPrefix!.event);
assert.equal(pending.lastBattleContext?.winner, f.atreides);
assert.equal(pending.lastBattleContext?.territory, 'arrakeen');
}
assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id), [f.card]);
assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries[0].publicFace, true);
});
void test(`${label}: the open offer exchanges exactly the physical card or stays with Semuta`, () => {
const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced);
const pending = disposeSemutaHomeworldBattleSapho(f), event = offerEvent(pending);
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
for (const seat of pending.players.filter(seat => seat.id !== f.holder))
assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false);
assert.throws(() => applyAction(pending, f.actor, f.action), 'The accepted disposal cannot replay');
assert.throws(() => applyAction(pending, f.holder,
{ type: 'semutaCommit', event: `${event}-retired` }), 'A stale offer event is rejected');
assert.throws(() => applyAction(pending, f.choam, { type: 'ready' }));
const claimed = reactSemutaHomeworldChoam(structuredClone(pending), f.holder, true);
const passed = reactSemutaHomeworldChoam(structuredClone(pending), f.holder, false);
for (const done of [claimed, passed]) {
assert.equal(done.pendingTreacheryDiscard, null);
assert.deepEqual(custody(done), custody(pending), 'Semuta never repeats the paid prefix or casualties');
assert.deepEqual(done.lastBattleContext, pending.lastBattleContext);
assert.deepEqual(done.order, pending.order);
}
assert.equal(player(claimed, f.holder).hand.filter(card => card.id === f.card).length, 1);
assert.equal(claimed.discard.filter(card => card.id === f.card).length, 0);
assert.equal(claimed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(player(passed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), true);
assert.equal(player(passed, f.holder).hand.some(card => card.id === f.card), false);
assert.equal(passed.discard.filter(card => card.id === f.card).length, 1);
for (const done of [claimed, passed])
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event }));
});
void test(`${label}: a full native hand exchanges atomically and entitlement stays private`, () => {
const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced, true);
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder);
const pending = disposeSemutaHomeworldBattleSapho(f);
const neutral = disposeSemutaHomeworldBattleSapho(f, absent);
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
assert.throws(() => applyAction(neutral, f.holder,
{ type: 'semutaCommit', event: neutral.pendingTreacheryDiscard!.batch.event }));
for (const seat of pending.players.filter(seat => seat.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 entitlement from consumer views');
const claimed = reactSemutaHomeworldChoam(pending, f.holder, true);
assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder)));
assert.equal(player(claimed, f.holder).hand.some(card => card.id === f.card), true);
assert.equal(player(claimed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false);
assert.deepEqual(custody(claimed), custody(pending));
});
void test(`${label}: the real next chooser finishes one battle and one native Collection`, () => {
const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced);
const done = passOffer(disposeSemutaHomeworldBattleSapho(f));
assert.deepEqual(done.order, f.game.order);
const result = finishSemutaHomeworldBattleSapho(done, f);
assert.equal(result.game.turn, 1);
assert.equal(result.game.phase, 7);
assert.ok(!result.game.battle && !result.game.decision && !result.game.response &&
!result.game.pendingTreacheryDiscard);
assert.deepEqual(result.game.order, f.game.order, 'The finished phase keeps its physical circle');
assert.equal(result.game.lastBattleContext?.result, 'normal');
assert.equal(result.game.lastBattleContext?.territory, 'red_chasm');
if (f.scope === 'aggressor') {
assert.equal(result.attacker, f.emperor);
assert.equal(result.defender, f.choam);
assert.equal(result.game.lastBattleContext?.winner, f.owner, 'The recorded aggressor takes the tie');
} else {
assert.equal(result.attacker, f.emperor);
assert.equal(result.defender, f.choam);
assert.equal(result.target, result.attacker, 'The chooser names the actual opposing physical slot');
assert.equal(result.game.lastBattleContext?.winner, f.emperor);
if (combo.mode === 'first')
assert.equal(result.chooser, f.owner, 'The first position makes the physical defender C the chooser');
else {
assert.equal(result.chooser, f.choam, 'A last position immunizes nobody from the current chooser');
assert.equal(done.battleOrder!.priority.at(-1), f.owner,
'The Emperor really holds the accepted last chooser position');
}
}
const victor = f.scope === 'aggressor' ? f.owner : f.emperor;
const loser = f.scope === 'aggressor' ? f.emperor : f.choam;
const committed = player(done, loser).forces['red_chasm:7'] ?? 0;
const survivors = player(result.game, victor).forces['red_chasm:7'] ?? 0;
assert.equal(survivors, player(done, victor).forces['red_chasm:7'],
'The winner keeps exactly its untouched committed force');
assert.equal(player(result.game, loser).forces['red_chasm:7'] ?? 0, 0);
assert.equal(player(result.game, loser).tanks, player(done, loser).tanks + committed,
'Battle casualties destroy the loser force once');
assert.equal(player(result.game, victor).tanks, player(done, victor).tanks);
assert.equal(result.game.spice['red_chasm:7'], 8 - 2 * survivors,
'The original eight-spice blow is drained exactly once by its surviving force');
assert.equal(player(result.game, victor).spice, player(done, victor).spice + 2 * survivors,
'Only the survivor collection credits the winner; no phase or receipt repeats');
for (const seat of result.game.players.filter(seat => seat.id !== victor))
assert.equal(seat.forces['red_chasm:7'] ?? 0, 0);
assert.throws(() => applyAction(result.game, f.choam, { type: 'endMovement' }));
});
void test(`${label}: stale and spent controls reject while fresh readiness preserves late timing`, () => {
const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced);
const scope = f.scope === 'aggressor' ? 'battleAggressor' : 'battleOrder';
assert.equal(viewGame(f.game, f.owner).saphoOptions.find(option => option.scope === scope)?.mode,
f.scope === 'aggressor' ? 'aggressor' : f.mode);
const spent = applyAction(f.game, f.actor, f.action);
assert.deepEqual(viewGame(spent, f.owner).saphoOptions, [], 'One card cannot be spent twice');
assert.throws(() => applyAction(spent, f.actor, f.action));
assert.throws(() => applyAction(f.game, f.actor, { ...f.action, event: 'expired' }));
const wrong = f.scope === 'aggressor' ? 'last' : f.mode === 'first' ? 'last' : 'first';
assert.throws(() => applyAction(f.game, f.owner, { ...f.action, mode: wrong }),
'Only the one legal position at this boundary is accepted');
if (f.scope === 'aggressor') {
assert.ok(viewGame(applyAction(f.game, f.owner,
{ type: 'battlePreparationReady', event: f.game.battle!.event }), f.owner).saphoOptions
.some(option => option.scope === 'battleAggressor'),
'Fresh battles retain late intervention after pre-plan readiness');
const legacy = structuredClone(f.game);
delete legacy.battle!.saphoTimingVersion;
assert.deepEqual(viewGame(applyAction(legacy, f.owner,
{ type: 'battlePreparationReady', event: f.game.battle!.event }), f.owner).saphoOptions, [],
'Unstamped battles retain readiness-closed early timing');
}
const other = f.game.players.find(seat => ![f.owner, f.holder].includes(seat.id))!.id;
assert.throws(() => applyAction(f.game, other, f.action));
});
void test(`${label}: every minimal bot profile plays the real producer then the native battle`, () => {
for (const difficulty of DIFFICULTIES) {
const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced);
const own = viewGame(structuredClone(f.game), f.owner);
own.players.find(seat => seat.id === f.owner)!.bot = difficulty;
const action = botActions(own).find(proposal => proposal.card === f.card);
assert.ok(action, `${difficulty}: the owner keeps a real Sapho action`);
assert.equal(action.scope, f.scope === 'aggressor' ? 'battleAggressor' : 'battleOrder');
let game = applyAction(f.game, f.owner, action);
game = passOffer(game);
assert.ok(!game.pendingTreacheryDiscard);
const result = finishSemutaHomeworldBattleSapho(game, f);
assert.equal(result.game.phase, 7, `${difficulty}: the real continuation reaches Collection`);
assert.equal(result.game.turn, 1);
}
});
}
const orders = combos.filter(combo => combo.scope === 'order');
for (const advanced of [false, true]) for (const combo of orders) {
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld Sapho order ${combo.mode}`;
void test(`${label}: finite remaining queue and the exact accepted position, no extra battle`, () => {
const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced);
const pending = disposeSemutaHomeworldBattleSapho(f);
const order = pending.battleOrder!;
const priority = order.priority;
assert.equal(new Set(priority).size, pending.order.length);
assert.deepEqual([...priority].sort(), [...pending.order].sort());
if (combo.mode === 'first') assert.equal(priority.indexOf(f.owner), 0);
else assert.equal(priority.indexOf(f.owner), priority.length - 1);
const view = viewGame(pending, f.owner).battleOrder;
assert.ok(view);
assert.deepEqual(view.remaining, [f.choam, f.emperor],
'Only the two remaining physical combatants retain one chooser opportunity');
assert.equal(view.uses.length, 1);
assert.ok(!pending.battle);
assert.deepEqual(pending.order, f.game.order);
});
}
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} early aggressor retains the opponent's completed readiness through Semuta`, () => {
const f = createSemutaHomeworldBattleSaphoFixture('aggressor', 'first', advanced, true);
const event = f.game.battle!.event!;
const ready = applyAction(f.game, f.emperor, { type: 'battlePreparationReady', event });
const pending = disposeSemutaHomeworldBattleSapho(f, ready);
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'saphoAggressorDiscard');
const done = reactSemutaHomeworldChoam(pending, f.holder, true);
assert.deepEqual(done.battle!.preLeader!.ready, [f.emperor]);
assert.deepEqual(custody(done), custody(ready));
assert.equal(done.battle!.attacker, f.emperor);
assert.equal(done.battle!.defender, f.choam);
assert.equal(done.battle!.chooser, f.emperor);
assert.equal(done.battle!.saphoAggressor!.uses.at(-1)!.player, f.choam);
const prepared = applyAction(done, f.choam, { type: 'battlePreparationReady', event });
assert.equal(prepared.battle!.preLeader!.closed, true);
const result = finishSemutaHomeworldBattleSapho(prepared, f);
assert.equal(result.game.lastBattleContext!.winner, f.choam);
assert.equal(player(result.game, f.choam).forces[f.location], 4);
assert.equal(player(result.game, f.emperor).tanks, 1);
assert.equal(result.game.phase, 7);
});