Files
Dune/tests/semuta-homeworld-ornithopter.test.ts
Quintine 72e77d7f4d Compose Ornithopter with unused Hajr movement
Replace one unused movement entitlement by the selected card mode and retain independent Hajr movement after a completed two-group flight. Preserve distinct cohorts and later legal reuse. Adopt supplied Advanced immediate-after-movement Hajr play timing with explicit provisional online/card-group interpretation; Basic timing remains.

Validation: frozen11-file132/132, types/lint/build and12 original-action/rendered-table programmes; four minimal policies retain both continuations. Actual cards are acquired by original auctions and source groups use funded shipments. Multiple Hajr cards, advisors, wider combinations, final assurance and deployed acceptance remain open. No saved-game, reset, restart or deployment operation.
2026-10-18 12:23:37 +11:00

194 lines
13 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 prior move needs an unused entitlement and Hajr preserves the fixed maximum three`, () => {
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(advanced ? moved : 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, advanced ? 1 : 0);
assert.ok(allowed.hajr.includes(f.owner));
assert.equal(player(allowed, f.owner).hand.some(c => c.id === f.card), true);
const declared = applyAction(allowed, f.owner, advanced
? { ...f.firstMove, from: f.secondDestination, ...splitLocation(f.origin) }
: f.firstMove);
const completed = advanceSemutaHomeworldChoam(declared,
g => !g.pendingTreacheryDiscard && !g.response && !g.decision && !g.phaseOpening);
assert.equal(player(completed, f.owner).moved, advanced ? 2 : 1);
assertCounters(allowed, f.setup);
assertCounters(completed, f.setup);
});