286 lines
16 KiB
TypeScript
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`);
|
|
}
|
|
});
|
|
}
|