Files
Dune/tests/semuta-homeworld-battle-loser.test.ts

286 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 { splitLocation } from '../game/board';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import {
createSemutaHomeworldLoserFixture, disposeSemutaHomeworldLoser, finishSemutaHomeworldLoser,
type SemutaHomeworldLoserFixture,
} from './fixture-semuta-homeworld-battle-loser';
function offerEvent(game: Game): string {
const pending = game.pendingTreacheryDiscard;
assert.ok(pending, 'A fresh public discard offer must be open');
return pending.batch.event;
}
function claimLoser(f: SemutaHomeworldLoserFixture, state: Game, card: string): Game {
const event = offerEvent(state);
return applyAction(applyAction(state, f.holder, { type: 'semutaCommit', event }),
f.holder, { type: 'semutaSelect', event, card });
}
function passLoser(state: Game): Game {
const event = offerEvent(state);
let game = state;
for (const seat of state.players)
game = applyAction(game, seat.id, { type: 'semutaPass', event });
return game;
}
function cleanupDecision(game: Game) {
const decision = game.decision;
assert.ok(decision && decision.kind === 'battleCards',
'The winning side keeps its held played cards under its own native cleanup');
return decision;
}
/** The public combatant facts every seat shares after the losers cede cards. */
function assertSettled(f: SemutaHomeworldLoserFixture, game: Game) {
const { winner, loser } = { winner: player(game, f.winner), loser: player(game, f.owner) };
assert.equal(winner.forces[f.location], 3, 'The winner keeps exactly its surviving dialed force');
assert.equal(winner.reserves, player(f.game, f.winner).reserves, 'Shipment already left the native reserve pool');
assert.equal(loser.forces[f.location] ?? 0, 0, 'The entire losing force leaves the board');
assert.equal(loser.tanks, 1, 'Exactly one losing force is destroyed to Tanks');
assert.equal(loser.reserves, player(f.game, f.owner).reserves);
for (const seat of game.players) {
const view = viewGame(game, seat.id);
assert.equal(view.players.find(p => p.id === f.winner)!.forces[f.location], 3);
assert.equal(view.players.find(p => p.id === f.owner)!.forces[f.location] ?? 0, 0);
assert.equal(view.players.find(p => p.id === f.owner)!.tanks, 1);
}
}
for (const advanced of [false, true]) {
const label = `${advanced ? 'Advanced' : 'Basic'} original paid Homeworld battle`;
void test(`${label}: native shipments and battle produce the public losing-card batch before any Semuta offer`, () => {
const f = createSemutaHomeworldLoserFixture(advanced);
assert.equal(f.setup.status, 'playing');
assert.equal(f.game.turn, 1);
assert.equal(f.game.phase, 6);
assert.equal(f.game.pendingTreacheryDiscard ?? null, null, 'The losing batch has not been offered yet');
assert.equal(f.game.decision ?? null, null);
assert.ok([f.winner, f.owner].includes(f.game.active!));
assert.equal(splitLocation(f.location).territory, 'red_chasm');
assert.equal(new Set([f.winner, f.owner, f.holder]).size, 3);
assert.equal(new Set([...f.cards, ...f.winnerCards]).size, 4, 'Four distinct physical weapon/defense cards');
assert.equal(player(f.game, f.owner).hand.find(c => c.id === f.cards[0])!.kind, 'projectile');
assert.equal(player(f.game, f.owner).hand.find(c => c.id === f.cards[1])!.kind, 'shield');
assert.equal(player(f.game, f.winner).hand.find(c => c.id === f.winnerCards[0])!.kind, 'projectile');
assert.equal(player(f.game, f.winner).hand.find(c => c.id === f.winnerCards[1])!.kind, 'shield');
assert.ok(f.cards.every(id => !f.game.discard.some(c => c.id === id)));
assert.equal(f.beforeBattle.phase, f.game.phase);
assert.deepEqual(f.beforeBattle.players.map(p => p.forces), f.game.players.map(p => p.forces));
const voters = viewGame(f.game, f.game.host).battle!.traitorVoters;
assert.equal(f.actor, voters[voters.length - 1], 'The last real traitor response belongs to the final public voter');
assert.equal(f.action.type, 'traitorCall');
assert.ok([f.winner, f.owner].includes(f.actor));
assert.equal(f.shipments.length, 2);
const [winnerShip, loserShip] = f.shipments;
assert.equal(winnerShip.actor, f.winner);
assert.equal(loserShip.actor, f.owner);
assert.equal(winnerShip.action.type, 'ship');
assert.equal(loserShip.action.type, 'ship');
assert.equal(winnerShip.action.amount, 4);
assert.equal(loserShip.action.amount, 1);
assert.equal(winnerShip.price, 8);
assert.equal(loserShip.price, 2);
for (const ship of f.shipments) {
const amount = ship.action.amount;
assert.ok(typeof amount === 'number');
assert.equal(player(ship.before, ship.actor).spice - player(ship.after, ship.actor).spice, ship.price);
assert.equal(ship.price, 2 * amount);
assert.equal(player(ship.before, ship.actor).reserves - player(ship.after, ship.actor).reserves, amount);
assert.equal(player(ship.after, ship.actor).forces[f.location], amount);
assert.deepEqual(player(ship.after, ship.actor).elites, player(ship.before, ship.actor).elites,
'Only ordinary native reserves depart, not Sardaukar');
assert.deepEqual(ship.after.homeworlds?.custody, ship.before.homeworlds?.custody,
'Kaitain supply comes from the native reserve pool, not staged custody');
}
assert.equal(player(f.game, f.winner).forces[f.location], 4);
assert.equal(player(f.game, f.owner).forces[f.location], 1);
const done = disposeSemutaHomeworldLoser(f);
assert.equal(done.phase, 6);
assert.equal(done.turn, 1);
assert.equal(done.lastBattleContext!.winner, f.winner);
assert.equal(done.lastBattleContext!.territory, 'red_chasm');
const pending = done.pendingTreacheryDiscard;
assert.ok(pending);
assert.equal(pending.batch.cause, 'battle:mandatory');
assert.deepEqual(pending.batch.entries.map(entry => [entry.card.id, entry.discardedBy]).sort((a, b) => a[0].localeCompare(b[0])),
f.cards.map(id => [id, f.owner]).sort((a, b) => a[0].localeCompare(b[0])), 'The batch is exactly the losing player\'s two played cards');
assert.equal(pending.reaction?.stage, 'offer');
for (const card of f.cards) assert.ok(done.discard.some(c => c.id === card));
assert.ok(player(done, f.winner).hand.every(card => !f.cards.includes(card.id)));
assert.equal(player(done, f.owner).forces[f.location] ?? 0, 0);
assert.equal(player(done, f.owner).tanks, 1);
assert.equal(player(done, f.winner).forces[f.location], 4,
'The winner\'s own casualties belong to the original continuation, not the offer');
assert.equal(player(done, f.winner).spice, player(f.game, f.winner).spice - f.support);
assert.equal(f.support, advanced ? 1 : 0);
assert.equal(player(done, f.winner).reserves, 16);
assert.equal(player(done, f.owner).reserves, 19);
assert.equal(player(done, f.holder).hand.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
assert.deepEqual(done.homeworlds?.custody, f.game.homeworlds?.custody);
for (const seat of done.players) {
const view = viewGame(done, seat.id);
assert.equal(view.semutaReaction?.canCommit, seat.id === f.holder);
if (seat.id !== f.holder) assert.deepEqual(view.semutaReaction?.candidates, []);
}
assert.throws(() => applyAction(done, f.actor, f.action), 'The resolving battle control cannot be replayed');
});
for (const fullHolder of [false, true]) {
const holderLabel = fullHolder ? ' at the Richese hand limit' : '';
void test(`${label}${holderLabel}: Richese claims one actual public losing card through Commit then Select`, () => {
const f = createSemutaHomeworldLoserFixture(advanced, fullHolder);
const done = disposeSemutaHomeworldLoser(f);
const event = offerEvent(done);
if (fullHolder)
assert.equal(player(done, f.holder).hand.length, handLimit(player(done, f.holder)));
const holderOffer = viewGame(done, f.holder).semutaReaction!;
assert.equal(holderOffer.stage, 'offer');
assert.equal(holderOffer.passed, false);
assert.deepEqual(holderOffer.candidates, [], 'Commitment precedes any private card selection');
const committed = applyAction(done, f.holder, { type: 'semutaCommit', event });
const selection = committed.pendingTreacheryDiscard?.reaction;
assert.equal(selection?.stage, 'select');
assert.deepEqual([...selection!.candidates].sort(), [...f.cards].sort(),
'The private choice is exactly the two public losing cards');
assert.deepEqual(viewGame(committed, f.winner).semutaReaction?.candidates, []);
assert.deepEqual(viewGame(committed, f.owner).semutaReaction?.candidates, []);
const snapshot = JSON.stringify(committed);
assert.throws(() => applyAction(committed, f.winner, { type: 'semutaSelect', event, card: f.cards[0] }));
assert.throws(() => applyAction(committed, f.holder,
{ type: 'semutaSelect', event, card: f.winnerCards[0] }));
assert.equal(JSON.stringify(committed), snapshot, 'An illegal selection changes nothing');
const selected = claimLoser(f, done, f.cards[0]);
assert.equal(selected.pendingTreacheryDiscard ?? null, null);
assert.equal(player(selected, f.holder).hand.filter(c => c.id === f.cards[0]).length, 1);
assert.equal(player(selected, f.holder).hand.some(c => c.id === f.cards[1]), false);
assert.ok(selected.discard.some(c => c.id === f.cards[1]), 'The unselected card stays discarded');
assert.ok(selected.discard.some(c => c.id === SEMUTA_DRUG_ID));
assert.equal(player(selected, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), false);
if (fullHolder)
assert.equal(player(selected, f.holder).hand.length, handLimit(player(selected, f.holder)));
assertSettled(f, selected);
const cleanup = cleanupDecision(selected);
assert.equal(cleanup.player, f.winner);
assert.deepEqual([...cleanup.cards].sort(), [...f.winnerCards].sort(),
'Only the winner\'s own held played cards are at stake');
const kept = applyAction(selected, f.winner, { type: 'decision', discard: [] });
assert.ok(f.winnerCards.every(card => player(kept, f.winner).hand.some(c => c.id === card)),
'Held played cards stay with the winner by default');
assert.throws(() => applyAction(kept, f.winner, { type: 'decision', discard: [] }),
'The original cleanup is consumed exactly once');
const finished = finishSemutaHomeworldLoser(kept);
assert.equal(finished.phase, 7);
assert.equal(finished.turn, 1);
assert.ok(!finished.phaseOpening && !finished.response && !finished.decision && !finished.pendingTreacheryDiscard);
assert.equal(player(finished, f.holder).hand.filter(c => c.id === f.cards[0]).length, 1);
assertSettled(f, finished);
assert.equal(player(finished, f.winner).reserves, 16);
assert.equal(player(finished, f.owner).tanks, 1);
assert.equal(player(finished, f.owner).reserves, 19);
assert.throws(() => applyAction(finished, f.holder, { type: 'semutaCommit', event }),
'The resolved losing batch cannot be claimed twice');
assert.throws(() => applyAction(finished, f.holder, { type: 'semutaSelect', event, card: f.cards[1] }));
assert.throws(() => applyAction(finished, f.actor, f.action));
});
void test(`${label}${holderLabel}: passing conserves the printed Semuta and both public losing cards`, () => {
const f = createSemutaHomeworldLoserFixture(advanced, fullHolder);
const done = disposeSemutaHomeworldLoser(f);
const event = offerEvent(done);
const holderPassed = applyAction(done, f.holder, { type: 'semutaPass', event });
assert.throws(() => applyAction(holderPassed, f.holder, { type: 'semutaPass', event }),
'One seat answers this fresh discard exactly once');
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: 'stale-discard' }));
const declined = passLoser(done);
assert.equal(declined.pendingTreacheryDiscard ?? null, null);
assert.equal(player(declined, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), true);
assert.ok(f.cards.every(card => declined.discard.some(c => c.id === card)));
assertSettled(f, declined);
assert.deepEqual([...cleanupDecision(declined).cards].sort(), [...f.winnerCards].sort());
const finished = finishSemutaHomeworldLoser(applyAction(declined, f.winner, { type: 'decision', discard: [] }));
assert.equal(finished.phase, 7);
assert.ok(f.cards.every(card => finished.discard.some(c => c.id === card)));
assert.equal(player(finished, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), true);
assertSettled(f, finished);
assert.throws(() => applyAction(finished, f.holder, { type: 'semutaPass', event }));
});
}
void test(`${label}: the winner's held played cards are kept or discarded under its own original cleanup`, () => {
const f = createSemutaHomeworldLoserFixture(advanced);
const done = disposeSemutaHomeworldLoser(f);
const mandatory = offerEvent(done);
const selected = claimLoser(f, done, f.cards[1]);
assert.equal(player(selected, f.holder).hand.filter(c => c.id === f.cards[1]).length, 1);
assert.ok(selected.discard.some(c => c.id === f.cards[0]));
const discarded = applyAction(selected, f.winner, { type: 'decision', discard: [f.winnerCards[0]] });
assert.equal(player(discarded, f.winner).hand.some(c => c.id === f.winnerCards[0]), false);
assert.ok(discarded.discard.some(c => c.id === f.winnerCards[0]));
assert.ok(player(discarded, f.winner).hand.some(c => c.id === f.winnerCards[1]),
'The unselected played card is retained');
const winnerOffer = discarded.pendingTreacheryDiscard;
assert.ok(winnerOffer, 'A discarded winner card opens its own fresh public offer');
assert.notEqual(winnerOffer.batch.event, mandatory, 'Each public card batch has its own event');
assert.deepEqual(winnerOffer.batch.entries.map(entry => [entry.card.id, entry.discardedBy]),
[[f.winnerCards[0], f.winner]]);
const resolved = passLoser(discarded);
assert.equal(resolved.pendingTreacheryDiscard ?? null, null);
const finished = finishSemutaHomeworldLoser(resolved);
assert.equal(finished.phase, 7);
assert.ok(finished.discard.some(c => c.id === f.winnerCards[0]));
assert.ok(player(finished, f.winner).hand.some(c => c.id === f.winnerCards[1]));
assertSettled(f, finished);
assert.throws(() => applyAction(finished, f.holder,
{ type: 'semutaCommit', event: mandatory }));
});
void test(`${label}: every legal bot profile claims through the original controls to the same battlefield`, () => {
const baseline = (() => {
const f = createSemutaHomeworldLoserFixture(advanced);
const selected = claimLoser(f, disposeSemutaHomeworldLoser(f), f.cards[0]);
return { f, game: applyAction(selected, f.winner, { type: 'decision', discard: [] }) };
})();
for (const difficulty of DIFFICULTIES) {
const f = createSemutaHomeworldLoserFixture(advanced);
const done = disposeSemutaHomeworldLoser(f);
const bidders = structuredClone(done);
player(bidders, f.holder).bot = difficulty;
const commit = botActions(viewGame(bidders, f.holder))[0];
assert.ok(commit);
assert.ok(commit.type === 'semutaCommit' || commit.type === 'semutaPass');
let game = applyAction(bidders, f.holder, commit);
if (game.pendingTreacheryDiscard) {
const select = botActions(viewGame(game, f.holder))[0];
assert.ok(select);
assert.ok(select.type === 'semutaSelect' || select.type === 'semutaPass');
game = applyAction(game, f.holder, select);
}
if (game.pendingTreacheryDiscard) game = passLoser(game);
const cleanup = cleanupDecision(game);
game = applyAction(game, cleanup.player, { type: 'decision', discard: [] });
const finished = finishSemutaHomeworldLoser(game);
assert.equal(finished.phase, 7);
assertSettled(f, finished);
assert.deepEqual(finished.players.map(seat => [seat.id, seat.forces, seat.tanks, seat.reserves]),
baseline.game.players.map(seat => [seat.id, seat.forces, seat.tanks, seat.reserves]),
`${difficulty} resolves the identical original casualties`);
}
});
}