Files
Dune/tests/semuta-homeworld-battle-sapho.test.ts
T

272 lines
16 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}: only legacy, stale or readiness-closed Sapho controls survive untouched`, () => {
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.deepEqual(viewGame(applyAction(f.game, f.owner,
{ type: 'battlePreparationReady', event: f.game.battle!.event }), f.owner).saphoOptions, [],
'The owner\'s own readiness closes the early pre-plan aggressor window');
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);
});