Files
Dune/tests/ornithopter-hajr-engine.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

173 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { location } from '../game/board';
import {
HAJR_ORNITHOPTER, actOrnithopterHajr, advanceOrnithopterHajr, completeOrnithopterHajr,
createOrnithopterHajrFixture, ornithopterHajrClean, ornithopterHajrMove,
ornithopterHajrPlayer as player, playOrnithopterHajr, type OrnithopterHajrFixture,
} from './fixture-ornithopter-hajr';
const ownView = (game: Game) => viewGame(game, 'emperor').players.find(player => player.id === 'emperor')!;
function source(f: OrnithopterHajrFixture) {
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'bid' && step.action.amount === 1));
assert.ok(player(f.game).hand.some(card => card.id === HAJR_ORNITHOPTER));
assert.ok(player(f.game).hand.some(card => card.id === f.hajr));
const shipment = f.shipments.find(shipment => shipment.step.actor === f.owner)!;
assert.equal(player(shipment.before).spice - player(shipment.after).spice, Number(shipment.step.action.amount) * 2,
'The real source is an originally funded desert shipment, not a staged board group');
assert.equal(player(f.game).forces[f.source], Number(shipment.step.action.amount));
assert.equal(ownView(f.game).moved, 0);
assert.equal(ownView(f.game).movesAllowed, 1);
assert.equal(ownView(f.game).hajrUsed, false);
assert.equal(ownView(f.game).hajrAvailable, !f.game.advanced);
}
function settled(f: OrnithopterHajrFixture, completed: Game, moves: number) {
assert.equal(player(completed).moved, moves);
assert.equal(ownView(completed).movesAllowed, moves);
assert.equal(ownView(completed).hajrUsed, true);
assert.equal(ownView(completed).hajrAvailable, false);
assert.equal(completed.ornithopter ?? null, null);
assert.equal(completed.discard.filter(card => card.id === HAJR_ORNITHOPTER).length, 1);
assert.equal(completed.discard.filter(card => card.id === f.hajr).length, 1);
assert.equal(player(completed).reserves, player(f.game).reserves);
assert.equal(player(completed).spice, player(f.game).spice);
assert.equal(player(completed).tanks, player(f.game).tanks);
assert.equal(Object.values(player(completed).forces).reduce((sum, count) => sum + count, 0),
Object.values(player(f.game).forces).reduce((sum, count) => sum + count, 0));
assert.throws(() => actOrnithopterHajr(completed, f.owner, { type: 'card', card: f.hajr }),
'The consumed physical Hajr cannot be replayed to add a fourth move');
}
void test('genuine Basic paid Ornithopter composes with Hajr before, during or after two distinct card groups', () => {
for (const timing of ['before', 'during', 'after'] as const) {
const f = createOrnithopterHajrFixture();
source(f);
const result = completeOrnithopterHajr(f, { timing });
assert.equal(result.moves.length, 3);
assert.equal(player(result.first).moved, 1);
assert.equal(result.first.ornithopter!.completed, 1);
assert.equal(player(result.flightComplete).moved, 2);
assert.equal(result.flightComplete.ornithopter ?? null, null);
assert.equal(ownView(result.beforeExtra).movesAllowed, 3);
assert.equal(result.moves[2].step.action.from, result.firstDestination,
'The independent Hajr move may reuse the first card group after the flight ends');
assert.equal(result.moves[2].step.action.ornithopterEvent, undefined);
assert.notEqual(result.game.active, f.owner);
settled(f, result.completed, 3);
}
});
void test('genuine Advanced Hajr follows a completed typed card group and retains its later ordinary move', () => {
const f = createOrnithopterHajrFixture({ advanced: true });
source(f);
assert.throws(() => actOrnithopterHajr(f.game, f.owner, { type: 'card', card: f.hajr }), /Force Movement|move|movement|immediately/i);
const result = completeOrnithopterHajr(f, { timing: 'during' });
assert.equal(ownView(result.first).hajrAvailable, true);
assert.equal(result.moves[0].step.action.elite, 1);
assert.equal(result.moves[1].step.action.elite, 1);
assert.equal(result.moves[2].step.action.elite, 1);
assert.equal(Object.values(player(result.completed).elites!.forces).reduce((sum, count) => sum + count, 0), 2);
settled(f, result.completed, 3);
});
void test('a real ordinary move followed by Hajr grants a fresh two-group flight without retroactively counting that move', () => {
for (const advanced of [false, true]) {
const f = createOrnithopterHajrFixture({ advanced });
source(f);
const result = completeOrnithopterHajr(f, { timing: 'during', priorNormal: true });
assert.equal(player(result.prior!).moved, 1);
assert.equal(result.prior!.ornithopter ?? null, null);
assert.equal(ownView(result.beforeFlight).movesAllowed, 2);
assert.equal(result.first.ornithopter!.startingMove, 1);
assert.equal(result.first.ornithopter!.completed, 1);
assert.equal(player(result.first).moved, 2);
assert.equal(result.moves[1].step.action.from,
location(String(result.moves[0].step.action.territory), Number(result.moves[0].step.action.sector)),
'Units moved by the earlier ordinary entitlement may join a new flight');
assert.equal(result.moves.length, 3);
settled(f, result.completed, 3);
}
});
void test('a genuine range-three card flight replaces one entitlement while same-flight repeated groups remain illegal', () => {
const f = createOrnithopterHajrFixture();
source(f);
const ranged = completeOrnithopterHajr(f, { mode: 'range3', timing: 'after' });
assert.equal(ranged.moves.length, 2);
assert.equal(player(ranged.flightComplete).moved, 1);
settled(f, ranged.completed, 2);
const firstMove = ornithopterHajrMove(f.game, f.source, { mode: 'twoGroups' });
let game = actOrnithopterHajr(structuredClone(f.game), f.owner, firstMove);
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
const firstDestination = location(String(firstMove.territory), Number(firstMove.sector));
const repeated = ornithopterHajrMove(game, firstDestination);
assert.throws(() => actOrnithopterHajr(game, f.owner, repeated), /cohort|unmoved|original|force/i);
game = playOrnithopterHajr(game, f);
game = actOrnithopterHajr(game, f.owner, ornithopterHajrMove(game, f.source));
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
assert.equal(game.ornithopter ?? null, null);
assert.equal(ownView(game).movesAllowed, 3);
game = actOrnithopterHajr(game, f.owner, ornithopterHajrMove(game, firstDestination));
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
settled(f, game, 3);
});
void test('a real Advanced Baliset pause preserves Hajr timing until an owner action closes it and the next group reopens it', () => {
const f = createOrnithopterHajrFixture({ advanced: true, interruption: true, amount: 3 });
source(f);
assert.equal(player(f.game, 'choam').forces['oh_gap:9'], 1);
assert.ok(player(f.game).spice >= 1, 'The smaller real shipment leaves its original bribe funding');
const firstMove = ornithopterHajrMove(f.game, f.source, { mode: 'twoGroups', to: 'oh_gap:9' });
let game = actOrnithopterHajr(structuredClone(f.game), f.owner, firstMove);
assert.equal(game.decision?.kind, 'choamMovement');
assert.equal(game.decision?.player, 'choam');
assert.equal(player(game).moved, 0);
assert.equal(game.ornithopter!.completed, 0);
assert.throws(() => actOrnithopterHajr(game, f.owner, { type: 'card', card: f.hajr }));
game = actOrnithopterHajr(game, 'choam', { type: 'decision', decline: true });
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
assert.equal(player(game).moved, 1);
assert.equal(ownView(game).hajrAvailable, true);
game = actOrnithopterHajr(game, f.owner, { type: 'bribe', target: 'guild', amount: 1 });
assert.equal(ownView(game).hajrAvailable, false);
assert.throws(() => actOrnithopterHajr(game, f.owner, { type: 'card', card: f.hajr }), /Force Movement|move|movement|immediately/i);
game = actOrnithopterHajr(game, f.owner, ornithopterHajrMove(game, f.source));
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
assert.equal(player(game).moved, 2);
assert.equal(ownView(game).hajrAvailable, true);
game = playOrnithopterHajr(game, f);
game = actOrnithopterHajr(game, f.owner, ornithopterHajrMove(game, 'oh_gap:9'));
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
assert.equal(player(game).moved, 3);
assert.equal(ownView(game).movesAllowed, 3);
assert.equal(player(game).spice, player(f.game).spice - 1);
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
assert.equal(game.discard.filter(card => card.id === HAJR_ORNITHOPTER).length, 1);
});
void test('all four minimal policies retain legal second-flight and earned Hajr movement candidates from original pending views', () => {
const f = createOrnithopterHajrFixture();
let first = actOrnithopterHajr(structuredClone(f.game), f.owner,
ornithopterHajrMove(f.game, f.source, { mode: 'twoGroups' }));
first = advanceOrnithopterHajr(first, ornithopterHajrClean);
first = playOrnithopterHajr(first, f);
const complete = actOrnithopterHajr(first, f.owner, ornithopterHajrMove(first, f.source));
const ready = advanceOrnithopterHajr(complete, ornithopterHajrClean);
assert.equal(ownView(ready).movesAllowed, 3);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
for (const game of [first, ready]) {
const view = viewGame(game, f.owner);
view.players.find(player => player.id === f.owner)!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'move');
assert.ok(action, `${difficulty} must keep an owned legal ${game.ornithopter ? 'second flight' : 'Hajr'} movement`);
assert.equal(typeof action.ornithopterEvent === 'string', !!game.ornithopter);
const moved = actOrnithopterHajr(structuredClone(game), f.owner, action);
assert.ok(player(moved).moved === player(game).moved + 1 || moved.response || moved.decision,
'Each real policy candidate completes its group or enters its original reaction');
}
}
});