188 lines
12 KiB
TypeScript
188 lines
12 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { applyAction, handLimit, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
|
import { gameDistance, splitLocation } from '../game/board';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import type { OrnithopterMode } from '../game/ornithopter';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { advanceSemutaHomeworldChoam, holdSemutaHomeworldChoamCard,
|
|
reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
|
|
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
|
import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
|
|
import { createSemutaHomeworldOrnithopterFixture, disposeSemutaHomeworldOrnithopter,
|
|
finishSemutaHomeworldOrnithopter, semutaHomeworldOrnithopterResources as resources,
|
|
semutaHomeworldOrnithopterStock as stock } from './fixture-semuta-homeworld-ornithopter';
|
|
|
|
function assertCounters(game: Game, original: Game) {
|
|
for (const p of game.players) {
|
|
const before = player(original, p.id);
|
|
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, x) => n + x, 0),
|
|
before.reserves + before.tanks + Object.values(before.forces).reduce((n, x) => n + x, 0));
|
|
if (p.elites && before.elites) {
|
|
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((n, x) => n + x, 0),
|
|
before.elites.reserves + before.elites.tanks + Object.values(before.elites.forces).reduce((n, x) => n + x, 0));
|
|
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0));
|
|
}
|
|
}
|
|
assert.deepEqual(game.homeworlds?.custody, original.homeworlds?.custody);
|
|
}
|
|
const paths: { mode: OrnithopterMode; early: boolean }[] = [
|
|
{ mode: 'range3', early: false }, { mode: 'twoGroups', early: false }, { mode: 'twoGroups', early: true },
|
|
];
|
|
for (const advanced of [false, true]) for (const { mode, early } of paths) {
|
|
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld Ornithopter ${mode}${early ? ' early finish' : ''}`;
|
|
for (const claim of [false, true]) void test(`${label}: ${claim ? 'claim' : 'pass'} retires once before the original arrival or turn continuation`, () => {
|
|
const f = createSemutaHomeworldOrnithopterFixture(mode, advanced, early);
|
|
const inventory = stock(f.beforeFirstMove), owner = player(f.afterShipment, f.owner);
|
|
assert.equal(f.afterShipment.turn, 1);
|
|
assert.equal(f.afterShipment.phase, 5);
|
|
assert.equal(owner.spice, player(f.beforeShipment, f.owner).spice - f.amount * 2);
|
|
assert.equal(owner.reserves, player(f.beforeShipment, f.owner).reserves - f.amount);
|
|
assert.equal(owner.forces[f.origin], f.amount);
|
|
assertCounters(f.afterShipment, f.setup);
|
|
const distance = gameDistance(f.beforeFirstMove, f.origin, f.destination,
|
|
key => splitLocation(key).sector === f.beforeFirstMove.storm);
|
|
assert.equal(distance, mode === 'range3' ? 3 : 1);
|
|
if (mode === 'range3') assert.throws(() => applyAction(f.beforeFirstMove, f.owner,
|
|
{ ...f.firstMove, movementCard: undefined, ornithopter: undefined }));
|
|
if (mode === 'twoGroups') {
|
|
assert.equal(player(f.game, f.owner).forces[f.destination], 1);
|
|
assert.equal(player(f.game, f.owner).moved, 1);
|
|
assert.equal(player(f.game, f.owner).hand.some(c => c.id === f.card), false);
|
|
assert.equal(f.game.discard.some(c => c.id === f.card), false);
|
|
assert.equal(viewGame(f.game, f.owner).ornithopter?.active?.remaining, 1);
|
|
assert.deepEqual(viewGame(f.game, f.owner).ornithopter?.active?.cohort?.forces,
|
|
early ? {} : { [f.origin]: 1 });
|
|
assert.throws(() => applyAction(f.game, f.owner, { type: 'move', from: f.destination,
|
|
amount: 1, elite: 0, ...splitLocation(f.origin), ornithopterEvent: f.game.ornithopter!.event }));
|
|
assert.throws(() => applyAction(f.game, f.owner, { ...f.action, type: 'move',
|
|
from: f.origin, amount: 1, ...splitLocation(f.secondDestination), ornithopterEvent: 'stale' }));
|
|
}
|
|
const uninterrupted = reactSemutaHomeworldChoam(disposeSemutaHomeworldOrnithopter(f,
|
|
withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder)), f.holder, false);
|
|
const offered = disposeSemutaHomeworldOrnithopter(f);
|
|
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
|
|
assert.equal(offered.discard.filter(c => c.id === f.card).length, 1);
|
|
assert.equal(player(offered, f.owner).hand.some(c => c.id === f.card), false);
|
|
assert.equal(player(offered, f.owner).moved, f.moves);
|
|
assert.equal(player(offered, f.owner).forces[f.destination], 1);
|
|
if (mode === 'twoGroups' && !early) assert.equal(player(offered, f.owner).forces[f.secondDestination], 1);
|
|
assert.equal(player(offered, f.owner).forces[f.origin] ?? 0, 0);
|
|
assert.equal(offered.active, f.owner, 'Retirement suspends the same original turn, not its successor');
|
|
assert.deepEqual(offered.movementRemaining, f.game.movementRemaining);
|
|
assert.deepEqual(stock(offered), inventory);
|
|
assertCounters(offered, f.setup);
|
|
assert.deepEqual(normalizeAutomaticGame(structuredClone(offered)), offered);
|
|
const reloaded = normalizeAutomaticGame(JSON.parse(JSON.stringify(offered)));
|
|
assert.deepEqual(resources(reloaded), resources(offered));
|
|
assert.deepEqual(stock(reloaded), inventory);
|
|
assert.equal(reloaded.active, offered.active);
|
|
assert.deepEqual(reloaded.movementRemaining, offered.movementRemaining);
|
|
for (const p of offered.players) assert.deepEqual(viewGame(reloaded, p.id), viewGame(offered, p.id));
|
|
const frozen = resources(offered), event = offered.pendingTreacheryDiscard!.batch.event;
|
|
assert.throws(() => applyAction(offered, f.owner, { type: 'endMovement' }));
|
|
assert.throws(() => applyAction(offered, f.owner, f.action));
|
|
const resumed = reactSemutaHomeworldChoam(JSON.parse(JSON.stringify(offered)), f.holder, claim);
|
|
assert.equal(resumed.pendingTreacheryDiscard, null);
|
|
assert.deepEqual(resources(resumed), frozen, 'Resume cannot replay a paid shipment or ground move');
|
|
assert.deepEqual(resources(resumed), resources(uninterrupted));
|
|
assert.equal(resumed.active, uninterrupted.active);
|
|
assert.deepEqual(resumed.movementRemaining, uninterrupted.movementRemaining);
|
|
assert.equal(player(resumed, f.holder).hand.filter(c => c.id === f.card).length, claim ? 1 : 0);
|
|
assert.equal(resumed.discard.filter(c => c.id === f.card).length, claim ? 0 : 1);
|
|
assert.equal(resumed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, claim ? 1 : 0);
|
|
assert.deepEqual(stock(resumed), inventory);
|
|
assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event }));
|
|
assert.throws(() => applyAction(resumed, f.owner, f.firstMove));
|
|
assert.throws(() => applyAction(resumed, f.owner, { type: 'move', from: f.destination,
|
|
amount: 1, ...splitLocation(f.origin) }));
|
|
const replay = structuredClone(resumed);
|
|
replay.pendingTreacheryDiscard = structuredClone(offered.pendingTreacheryDiscard);
|
|
assert.throws(() => normalizeAutomaticGame(replay));
|
|
assert.deepEqual(normalizeAutomaticGame(structuredClone(resumed)), resumed);
|
|
const completed = finishSemutaHomeworldOrnithopter(resumed);
|
|
assert.equal(completed.turn, 1);
|
|
assert.equal(completed.phase, 7);
|
|
assert.equal(player(completed, f.owner).moved, f.moves);
|
|
assert.equal(player(completed, f.owner).reserves, owner.reserves);
|
|
assert.deepEqual(player(completed, f.owner).forces, player(offered, f.owner).forces);
|
|
assert.deepEqual(stock(completed), inventory);
|
|
assertCounters(completed, f.setup);
|
|
});
|
|
|
|
void test(`${label}: full-hand singleton entitlement is private and original forces cannot be borrowed by another player`, () => {
|
|
const f = createSemutaHomeworldOrnithopterFixture(mode, advanced, early, true);
|
|
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
|
|
assert.throws(() => applyAction(f.game, f.holder, f.action));
|
|
const offered = disposeSemutaHomeworldOrnithopter(f);
|
|
const neutral = disposeSemutaHomeworldOrnithopter(f, withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder));
|
|
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
|
|
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
|
|
const event = offered.pendingTreacheryDiscard!.batch.event;
|
|
for (const p of offered.players.filter(p => p.id !== f.holder)) {
|
|
assert.deepEqual(viewGame(offered, p.id), viewGame(neutral, p.id));
|
|
assert.equal(viewGame(offered, p.id).semutaReaction?.canCommit, false);
|
|
assert.throws(() => applyAction(offered, p.id, { type: 'semutaCommit', event }));
|
|
}
|
|
assert.throws(() => applyAction(offered, f.holder, { type: 'semutaCommit', event: 'stale' }));
|
|
const claimed = reactSemutaHomeworldChoam(offered, f.holder, true);
|
|
assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder)));
|
|
assert.equal(player(claimed, f.holder).hand.some(c => c.id === f.card), true);
|
|
assert.equal(player(claimed, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), false);
|
|
const done = finishSemutaHomeworldOrnithopter(claimed);
|
|
assert.equal(player(done, f.owner).moved, f.moves);
|
|
assert.deepEqual(stock(done), stock(f.beforeFirstMove));
|
|
});
|
|
|
|
void test(`${label}: all four bot profiles legally claim or pass without a second shipment or repeated group`, () => {
|
|
const f = createSemutaHomeworldOrnithopterFixture(mode, advanced, early);
|
|
const offered = disposeSemutaHomeworldOrnithopter(f), frozen = resources(offered);
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let game: Game = structuredClone(offered);
|
|
const own = viewGame(game, 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');
|
|
game = applyAction(game, f.holder, action);
|
|
while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
|
|
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
|
|
if (reaction?.stage !== 'offer') throw Error('Missing original retirement offer');
|
|
const actor = game.players.find(p => !reaction.passed.includes(p.id))!.id;
|
|
game = applyAction(game, actor, { type: 'semutaPass', event: pending.batch.event });
|
|
}
|
|
assert.equal(game.pendingTreacheryDiscard, null);
|
|
assert.equal(player(game, f.holder).hand.some(c => c.id === f.card), action.type === 'semutaCommit');
|
|
assert.deepEqual(resources(game), frozen);
|
|
const done = finishSemutaHomeworldOrnithopter(game);
|
|
assert.equal(player(done, f.owner).moved, f.moves);
|
|
assert.deepEqual(stock(done), stock(f.beforeFirstMove));
|
|
assertCounters(done, f.setup);
|
|
}
|
|
});
|
|
}
|
|
|
|
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld original prior-move, Hajr and fixed maximum-three guards remain real`, () => {
|
|
const f = createSemutaHomeworldOrnithopterFixture('range3', advanced);
|
|
const before = structuredClone(f.beforeFirstMove);
|
|
assert.throws(() => applyAction(before, f.owner, { ...f.firstMove, ...splitLocation(f.tooFarDestination) }));
|
|
assert.deepEqual(before, f.beforeFirstMove);
|
|
const moved = applyAction(before, f.owner, { ...f.firstMove, movementCard: undefined,
|
|
ornithopter: undefined, ...splitLocation(f.secondDestination) });
|
|
assert.equal(player(moved, f.owner).moved, 1);
|
|
assert.equal(player(moved, f.owner).hand.some(c => c.id === f.card), true);
|
|
assert.throws(() => applyAction(moved, f.owner, { ...f.firstMove, from: f.secondDestination,
|
|
...splitLocation(f.origin) }));
|
|
const hajrGame = structuredClone(before);
|
|
const hajr = holdSemutaHomeworldChoamCard(hajrGame, f.owner, 'Hajr');
|
|
const played = applyAction(hajrGame, f.owner, { type: 'card', card: hajr });
|
|
const allowed = advanceSemutaHomeworldChoam(played,
|
|
g => !g.pendingTreacheryDiscard && !g.response && !g.decision && !g.phaseOpening);
|
|
assert.equal(player(allowed, f.owner).moved, 0);
|
|
assert.ok(allowed.hajr.includes(f.owner));
|
|
assert.throws(() => applyAction(allowed, f.owner, f.firstMove));
|
|
assert.equal(player(allowed, f.owner).hand.some(c => c.id === f.card), true);
|
|
assertCounters(allowed, f.setup);
|
|
});
|