Files
Dune/tests/ornithopter-homeworld-quote.test.ts

151 lines
9.4 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import type { GameView } from '../game/engine';
import { imperialFlightPools, imperialFlightCohort } from '../game/ornithopter-homeworld';
import { createRemainingCohort, validateCohortSelection, type MovementCohort } from '../game/ornithopter';
import { emperorHomeworldMoveAction, emperorHomeworldMoveActions } from '../game/homeworld-options';
import { homeworldForceGroups, type HomeworldCustody } from '../game/homeworld-custody';
import { homeworldPopulations } from '../game/homeworld-population';
import { quoteEmperorHomeworldMove, type EmperorHomeworldMove } from '../game/homeworld-emperor-move';
import { richeseCards } from '../game/richese-cards';
const card = richeseCards().find(candidate => candidate.effect === 'ornithopter')!;
const kaitain = 'homeworld:emperor', salusa = 'homeworld:emperor:salusa';
const order: EmperorHomeworldMove = { origin: kaitain, forces: { normal: 1, elite: 0 } };
/** Controlled public custody/view supplement, not an acquired-card gameplay
* chronology. Actual native route quotes provide the typed World tables. */
function fixture(custody: HomeworldCustody = { salusa: { normal: 2, elite: 2 }, visitors: {} }) {
const context = { advanced: true, players: [
{ id: 'e', faction: 'emperor' as const, reserves: 8, eliteReserves: 3 },
{ id: 'a', faction: 'atreides' as const, reserves: 18, eliteReserves: 0 },
] };
const populations = homeworldPopulations(context, custody);
const worlds = homeworldForceGroups(context, custody).map(world => ({ ...world,
...populations.find(population => population.location === world.id)! }));
const view = { me: 'e', active: 'e', advanced: true, status: 'playing', phase: 5,
homeworldMove: { event: 'current-native-world-event', remaining: 1, blocked: null },
homeworlds: { worlds },
players: context.players.map(player => ({ ...player, name: player.faction, ally: null,
forces: player.id === 'e' ? { 'arrakeen:10': 2 } : {},
elites: { reserves: player.eliteReserves, forces: player.id === 'e' ? { 'arrakeen:10': 1 } : {} },
hand: player.id === 'e' ? [{ ...card }] : [], autopilot: null })),
ornithopter: { card: { ...card }, blocked: null,
modes: [{ mode: 'range3', blocked: null }, { mode: 'twoGroups', blocked: null }], active: null },
} as unknown as GameView;
return { context, custody, worlds, view };
}
function active(view: GameView, cohort: MovementCohort, aware = true) {
view.ornithopter!.active = { event: 'actual-active-flight-event', mode: 'twoGroups', completed: 1,
remaining: 1, cohort, ...(aware ? { homeworldCohort: true } : {}) };
view.players.find(player => player.id === view.me)!.hand = [];
view.homeworldMove!.remaining = 0;
}
void test('Imperial flight pools merge only actual native World types under canonical ID keys', () => {
const f = fixture(), board = { 'arrakeen:10': 2 }, elite = { 'arrakeen:10': 1 };
const before = structuredClone({ board, elite, worlds: f.worlds });
const pools = imperialFlightPools(board, elite, f.worlds, 'e');
assert.deepEqual(pools, { forces: { 'arrakeen:10': 2, [kaitain]: 4, [salusa]: 4 },
elites: { 'arrakeen:10': 1, [kaitain]: 1, [salusa]: 2 } });
assert.equal(Object.hasOwn(pools.forces, 'homeworld:atreides'), false);
assert.equal(Object.hasOwn(pools.forces, `${kaitain}:0`), false);
assert.deepEqual(imperialFlightPools(board, elite, [], 'e'), { forces: board, elites: elite });
assert.deepEqual({ board, elite, worlds: f.worlds }, before);
});
void test('aware depleted quotas are cloned without regeneration while legacy board cohorts gain untouched current native Worlds', () => {
const f = fixture(), cohort: MovementCohort = { forces: { 'arrakeen:10': 1 }, elites: {},
noField: { tokenId: 'old-marker', event: 'old-event', from: 'arrakeen:10' } };
const aware = imperialFlightCohort(cohort, true, f.worlds, 'e');
assert.deepEqual(aware, cohort);
assert.notEqual(aware.forces, cohort.forces);
assert.notEqual(aware.noField, cohort.noField);
const legacy = imperialFlightCohort(cohort, false, f.worlds, 'e');
assert.equal(legacy.forces[kaitain], 4);
assert.equal(legacy.elites[salusa], 2);
assert.equal(legacy.forces['arrakeen:10'], 1);
const current = imperialFlightPools({ 'arrakeen:10': 2 }, {}, f.worlds, 'e');
assert.throws(() => validateCohortSelection(aware, current.forces, current.elites, { [kaitain]: 1 }, {}), /original unmoved/);
assert.doesNotThrow(() => validateCohortSelection(legacy, current.forces, current.elites, { [kaitain]: 1 }, {}));
});
void test('ordinary Imperial packets stay unchanged and either held-card mode uses exactly the native route', () => {
const f = fixture();
const ordinary = { type: 'emperorHomeworldMove', event: f.view.homeworldMove!.event,
origin: kaitain, normal: 1, elite: 0 };
assert.deepEqual(emperorHomeworldMoveAction(f.view, order), ordinary);
for (const mode of ['range3', 'twoGroups'] as const)
assert.deepEqual(emperorHomeworldMoveAction(f.view, order, mode), { ...ordinary, movementCard: card.id, ornithopter: mode });
const invalid = { origin: 'homeworld:atreides', forces: { normal: 1, elite: 0 } } as unknown as EmperorHomeworldMove;
assert.equal(emperorHomeworldMoveAction(f.view, invalid, 'range3'), null);
assert.equal(emperorHomeworldMoveAction(f.view, { ...order, forces: { normal: 4, elite: 0 } }, 'twoGroups'), null);
assert.equal(emperorHomeworldMoveAction(f.view, { ...order, forces: { normal: 0, elite: 0 } }, 'range3'), null);
});
void test('a World first group cannot reuse arriving counters and a remaining group adds only its native flight event', () => {
const f = fixture();
const pools = imperialFlightPools({ 'arrakeen:10': 2 }, { 'arrakeen:10': 1 }, f.worlds, 'e');
const cohort = createRemainingCohort(pools.forces, pools.elites, { [kaitain]: 1 }, {});
const transferred = quoteEmperorHomeworldMove({ ...f.context, status: 'playing', phase: 5,
currentPlayer: 'e', movesLeft: 1 }, f.custody, 'e', order);
const after = fixture(transferred.state).view;
active(after, cohort);
const originalNormal = { origin: salusa, forces: { normal: 2, elite: 0 } } as const;
assert.deepEqual(emperorHomeworldMoveAction(after, originalNormal), {
type: 'emperorHomeworldMove', event: after.homeworldMove!.event, origin: salusa, normal: 2, elite: 0,
ornithopterEvent: 'actual-active-flight-event',
});
assert.equal(emperorHomeworldMoveAction(after, { ...originalNormal, forces: { normal: 3, elite: 0 } }), null);
assert.equal(emperorHomeworldMoveAction(after, originalNormal, 'range3'), null);
after.ornithopter!.active!.event = '';
assert.equal(emperorHomeworldMoveAction(after, originalNormal), null);
});
void test('continuation candidates honor original normal and elite quotas separately and support old board-only flights', () => {
const f = fixture();
active(f.view, { forces: { [kaitain]: 1 }, elites: { [kaitain]: 1 } });
assert.equal(emperorHomeworldMoveAction(f.view, order), null);
assert.ok(emperorHomeworldMoveAction(f.view, { ...order, forces: { normal: 0, elite: 1 } }));
const actions = emperorHomeworldMoveActions(f.view);
assert.ok(actions.length);
assert.ok(actions.every(action => action.origin === kaitain && action.normal === 0 && action.elite === 1));
const normal = fixture(); active(normal.view, { forces: { [kaitain]: 1 }, elites: {} });
assert.equal(emperorHomeworldMoveAction(normal.view, { ...order, forces: { normal: 0, elite: 1 } }), null);
const legacy = fixture(); active(legacy.view, { forces: { 'arrakeen:10': 1 }, elites: {} }, false);
assert.ok(emperorHomeworldMoveAction(legacy.view, order));
assert.ok(emperorHomeworldMoveActions(legacy.view).length);
});
void test('an uncompleted on-table flight can replace a prevented first ground group with native World movement before a cohort exists', () => {
for (const mode of ['range3', 'twoGroups'] as const) {
const f = fixture();
f.view.players[0].hand = [];
f.view.ornithopter!.active = { event: 'uncompleted-flight', mode, completed: 0,
remaining: mode === 'twoGroups' ? 2 : 1, cohort: undefined };
assert.deepEqual(emperorHomeworldMoveAction(f.view, order), {
type: 'emperorHomeworldMove', event: f.view.homeworldMove!.event, origin: kaitain, normal: 1, elite: 0,
ornithopterEvent: 'uncompleted-flight',
});
assert.ok(emperorHomeworldMoveActions(f.view).length);
f.view.homeworldMove!.blocked = 'The original prevention response is pending.';
assert.equal(emperorHomeworldMoveAction(f.view, order), null);
}
});
void test('missing held physical card blocked mode wrong owner and native modal guards refuse new flight declarations', () => {
const missing = fixture().view; missing.players[0].hand = [];
assert.equal(emperorHomeworldMoveAction(missing, order, 'twoGroups'), null);
assert.ok(emperorHomeworldMoveAction(missing, order));
const mode = fixture().view; mode.ornithopter!.modes[0].blocked = 'Card is committed.';
assert.equal(emperorHomeworldMoveAction(mode, order, 'range3'), null);
assert.ok(emperorHomeworldMoveAction(mode, order, 'twoGroups'));
const basic = fixture().view; basic.advanced = false;
const foreign = fixture().view; foreign.active = 'a';
const closed = fixture().view; closed.homeworldMove!.blocked = 'Finish the pending native interaction.';
for (const view of [basic, foreign, closed]) {
assert.equal(emperorHomeworldMoveAction(view, order, 'twoGroups'), null);
assert.deepEqual(emperorHomeworldMoveActions(view), []);
}
});