Files
Dune/tests/discovery-flight-engine.test.ts
Quintine ab0ab52849 Prototype Discovery free entry, Cistern income and carried flight
Connect next-turn optional entry before storm/HMS movement, with typed
multi-sector groups, normal source-storm/occupancy checks, legal bot and human
choices, signed saved arrivals and Bene Gesserit reaction continuation.
Cistern pays its sole occupant two bank spice through canonical Collection.
A carried Ornithopter grants fixed range three on a later-turn actual movement,
preserves pending responses and independent Treachery/No-Field custody, and is
removed only after the selected group moves.

Independent reviews bind pending entry destination baselines, typed custody and
exact reaction continuations. Browser QA caught and fixed an incorrect decision
heading. Orgiz remains disabled pending the stacked-Blow ruling; Jacurutu battle
income, Ecological Testing Station and Shrine remain missing. Mode and publication
gates stay closed; this checkpoint does not certify full Discovery compliance.

Verification: types/lint and 4,313 offline tests passed (49 added), production
build passed, and 40 HTTP tests passed. Final stable run durations: check
139145ms, build 6530ms, integration 24421ms. An earlier run found one stale
Discovery notice assertion, now updated; the next run was interrupted by a
user-confirmed power outage. No interrupted result is claimed as success.

Targeted browser room CA782FQE used genuine human Atreides and two-AI setup,
then physical QA positioning for partial free entry and a three-territory
Ornithopter move. Refresh preserved its private hand, forces, spice and token
removal. The power outage stopped both server and checks; the absent server was
restarted after a private backup. The same browser seat restored correctly and
all 157 pre-restart rooms were unchanged. Final preservation: all 156 opening
rooms unchanged, 31 added QA/integration rooms, total 187. No database reset.

Source fingerprint:
7b639d7f6559a88b00f3cbf913cfa7e6c400bfabb8c392e7b732e8ea77f6dcbc
Private report:
/home/quintine/.local/share/dune/checkpoints/20260913-discovery-entry-flight/verification-recovered/report.json
Aggregate goal counter 61,819,586 -> 63,110,032 at completed verification:
1,290,446 tokens before final Git overhead for three connected functions,
including helper coordination, reviews, browser QA and interrupted checks.
2026-09-13 14:13:40 +10:00

261 lines
14 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { gameDistance, gameTerritories, splitLocation } from '../game/board';
import { discoveryFlightMove, discoveryFlightBotActions } from '../game/discovery-flight-options';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { richeseCards } from '../game/richese-cards';
import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
import { discoveryFlightFixture, flightDestination, flightOrigin } from './fixture-discovery-flight';
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value));
const held = (game: Game) => game.discoveries!.tokens.find(token => token.face === 'ornithopter')!;
const declaration = (game: Game, extra: Partial<Action> = {}): Action => ({
type: 'move', from: flightOrigin, amount: 2, ...splitLocation(flightDestination(game)),
discoveryOrnithopter: held(game).id, ...extra,
});
function rejectsUnchanged(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function choamFixture() {
const fixture = discoveryFlightFixture(true, 'f');
const choam = newPlayer('c', 'CHOAM', 'choam');
fixture.game.players.push(choam);
fixture.game.order.push(choam.id);
const destination = flightDestination(fixture.game);
choam.forces = { [destination]: 1 };
choam.reserves--;
const baliset = fixture.game.deck.findIndex(card => card.name === 'Baliset');
assert.ok(baliset >= 0);
choam.hand.push(fixture.game.deck.splice(baliset, 1)[0]);
const pilot = fixture.game.players.find(player => player.id === 'f')!;
pilot.elites!.reserves -= 2;
pilot.elites!.forces[flightOrigin] = 2;
return { ...fixture, destination };
}
void test('real Collection acquisition grants one later-turn three-territory movement in Basic and Advanced', () => {
for (const advanced of [false, true]) {
const { game, owner, acquiredTurn } = discoveryFlightFixture(advanced);
const action = declaration(game), destination = `${String(action.territory)}:${Number(action.sector)}`;
const before = structuredClone(game), ordinary = { ...action };
delete ordinary.discoveryOrnithopter;
rejectsUnchanged(game, owner, ordinary);
const view = viewGame(game, owner);
assert.equal(view.discoveryOrnithopter!.blocked, null);
assert.equal(viewGame(game, 'g').discoveryOrnithopter, null);
assert.ok(discoveryFlightMove(view, ordinary).action);
const done = applyAction(game, owner, action);
assert.deepEqual(game, before);
assert.equal(done.players[0].forces[destination], 2);
assert.equal(done.players[0].moved, 1);
assert.equal(done.players[0].reserves, game.players[0].reserves);
assert.equal(done.players[0].spice, game.players[0].spice);
assert.equal(held(done).status, 'removed');
assert.equal(held(done).owner, null);
assert.equal(held(done).acquiredTurn, acquiredTurn);
assert.equal(viewGame(done, owner).discoveryOrnithopter, null);
assert.deepEqual(normalizeAutomaticGame(reload(done)), reload(done));
}
});
void test('same-turn use, nonowner, false token, invalid group, storm, overrange and receipt injection reject immutably', () => {
const { game, owner } = discoveryFlightFixture();
const sameTurn = reload(game); sameTurn.turn = held(game).acquiredTurn!;
assert.match(viewGame(sameTurn, owner).discoveryOrnithopter!.blocked!, /after/);
rejectsUnchanged(sameTurn, owner, declaration(sameTurn));
for (const extra of [
{ discoveryOrnithopter: 'discovery-token-99' },
{ discoveryOrnithopter: game.discoveries!.tokens.find(token => token.face === 'cistern')!.id },
{ amount: 6 }, { amount: 0 }, { elite: 1 },
{ ...splitLocation(flightDestination(game, 4)) },
{ discoveryFlight: { token: held(game).id } },
]) rejectsUnchanged(game, owner, declaration(game, extra));
const storm = reload(game), action = declaration(storm);
storm.storm = Number(action.sector);
rejectsUnchanged(storm, owner, action);
const foreign = reload(game); foreign.active = 'g';
foreign.players[1].forces[flightOrigin] = 2; foreign.players[1].reserves -= 2;
rejectsUnchanged(foreign, 'g', declaration(foreign));
const used = applyAction(game, owner, declaration(game));
used.hajr = [owner];
rejectsUnchanged(used, owner, declaration(used));
});
void test('one selected multi-sector typed group moves at fixed range without a native Fremen speed response', () => {
const { game, owner } = discoveryFlightFixture(true, 'f');
const pilot = game.players.find(player => player.id === owner)!;
const source = gameTerritories(game).find(t => t.sectors.length >= 2 && !t.sectors.includes(game.storm))!;
const [first, second] = source.sectors.map(sector => `${source.id}:${sector}`);
pilot.forces = { [first]: 3, [second]: 2 };
pilot.elites!.reserves -= 2; pilot.elites!.forces = { [first]: 1, [second]: 1 };
const to = gameTerritories(game).flatMap(t => t.sectors.map(sector => `${t.id}:${sector}`))
.find(key => [first, second].every(from => gameDistance(game, from, key,
key => splitLocation(key).sector === game.storm) === 3))!;
assert.ok(to);
const move: Action = { type: 'move', forces: { [first]: 2, [second]: 1 },
eliteForces: { [first]: 1, [second]: 1 }, ...splitLocation(to) };
const quote = discoveryFlightMove(viewGame(game, owner), move);
assert.equal(quote.blocked, null);
const done = applyAction(game, owner, quote.action!);
assert.equal(done.response, null);
const next = done.players.find(player => player.id === owner)!;
assert.equal(next.forces[to], 3); assert.equal(next.elites!.forces[to], 2);
assert.equal(next.moved, 1); assert.equal(held(done).status, 'removed');
});
void test('pending CHOAM decision preserves the exact owned token and typed group across JSON; allowing resumes once', () => {
const { game, owner, destination } = choamFixture();
const pending = applyAction(game, owner, declaration(game, { elite: 1 }));
assert.equal(pending.decision?.kind, 'choamMovement');
assert.equal(held(pending).status, 'carried');
assert.equal(pending.players.find(p => p.id === owner)!.moved, 0);
assert.ok(pending.pendingChoamMove!.discoveryFlight);
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
assert.match(viewGame(pending, owner).discoveryOrnithopter!.blocked!, /response or decision/);
assert.equal(JSON.stringify(viewGame(pending, 'c')).includes('discoveryFlight'), false);
const done = applyAction(reload(pending), 'c', { type: 'decision', decline: true });
const pilot = done.players.find(p => p.id === owner)!;
assert.equal(pilot.forces[destination], 2); assert.equal(pilot.elites!.forces[destination], 1);
assert.equal(pilot.moved, 1); assert.equal(held(done).status, 'removed');
assert.equal(done.pendingChoamMove, null);
rejectsUnchanged(done, 'c', { type: 'decision', decline: true });
});
void test('altered pending movement receipt fails closed on normalize, view and answer', () => {
const { game, owner } = choamFixture();
const pending = applyAction(game, owner, declaration(game, { elite: 1 }));
for (const alter of [
(g: Game) => { g.pendingChoamMove!.to = 'hagga_basin'; },
(g: Game) => { g.pendingChoamMove!.eliteGroup[flightOrigin] = 0; },
(g: Game) => { g.pendingChoamMove!.discoveryFlight!.move++; },
(g: Game) => { g.turn++; },
(g: Game) => { held(g).owner = 'g'; },
(g: Game) => { g.pendingIxMove = reload(g.pendingChoamMove); },
]) {
const changed = reload(pending); alter(changed);
const before = structuredClone(changed);
assert.throws(() => normalizeAutomaticGame(changed));
assert.throws(() => viewGame(changed, owner));
rejectsUnchanged(changed, 'c', { type: 'decision', decline: true });
assert.deepEqual(changed, before);
}
});
void test('Baliset prevents the pending move without spending its token; a different route can still use it', () => {
const { game, owner, destination } = choamFixture();
let pending = applyAction(game, owner, declaration(game, { elite: 1 }));
const choam = pending.players.find(player => player.id === 'c')!;
pending = applyAction(reload(pending), 'c', { type: 'card', mode: 'choam',
card: choam.hand.find(card => card.name === 'Baliset')!.id, target: owner,
territory: splitLocation(destination).territory });
assert.equal(pending.pendingChoamMove, null);
assert.equal(held(pending).status, 'carried');
assert.equal(pending.players.find(player => player.id === owner)!.moved, 0);
const alternate = discoveryFlightBotActions(viewGame(pending, owner))[0];
assert.ok(alternate);
assert.equal(held(applyAction(pending, owner, alternate)).status, 'removed');
});
void test('actual Baliset cancellation response restores the same flight and either allows or abandons its one movement', () => {
for (const cancel of [false, true]) {
const { game, owner, destination } = choamFixture();
const cardIndex = game.deck.findIndex(card => card.effect === 'karama');
const karama = game.deck.splice(cardIndex, 1)[0];
game.players.find(player => player.id === owner)!.hand.push(karama);
let pending = applyAction(game, owner, declaration(game, { elite: 1 }));
const choam = pending.players.find(player => player.id === 'c')!;
pending = applyAction(pending, 'c', { type: 'card', mode: 'choam',
card: choam.hand.find(card => card.name === 'Baliset')!.id, target: owner,
territory: splitLocation(destination).territory });
assert.equal(pending.response?.kind, 'choamWorthless');
assert.equal(held(pending).status, 'carried');
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
const done = applyAction(reload(pending), owner, cancel
? { type: 'card', card: karama.id, mode: 'cancel' } : { type: 'passResponse' });
assert.equal(done.pendingChoamMove, null);
assert.equal(done.players.find(player => player.id === owner)!.moved, Number(cancel));
assert.equal(held(done).status, cancel ? 'removed' : 'carried');
}
});
void test('Discovery flight replaces a Kulon-enhanced normal range with exactly three', () => {
const { game, owner } = discoveryFlightFixture();
game.players[0].faction = 'choam';
game.players[0].forces['arrakeen:10'] = 1; game.players[0].reserves--;
game.choamMovement = { turn: game.turn, bonus: 1 };
const four = declaration(game, splitLocation(flightDestination(game, 4)));
rejectsUnchanged(game, owner, four);
assert.equal(discoveryFlightMove(viewGame(game, owner), four).action, null);
const ordinary = { ...four }; delete ordinary.discoveryOrnithopter;
assert.equal(held(applyAction(game, owner, ordinary)).status, 'carried');
assert.equal(held(applyAction(game, owner, declaration(game))).status, 'removed');
});
void test('a selected concealed marker flies with physical companions and retains its private denomination', () => {
const { game, owner } = discoveryFlightFixture();
const pilot = game.players[0]; pilot.faction = 'richese';
const ids = ['flight-zero', 'flight-three', 'flight-five'];
pilot.noField = deployRicheseNoField(createRicheseNoField(ids),
{ tokenId: ids[1], controller: owner, location: splitLocation(flightOrigin) });
pilot.noFieldEvent = 'flight-marker';
const action: Action = { type: 'move', forces: { [flightOrigin]: 2 }, noField: ids[1],
event: pilot.noFieldEvent, ...splitLocation(flightDestination(game)) };
const quote = discoveryFlightMove(viewGame(game, owner), action);
assert.equal(quote.blocked, null);
const done = applyAction(game, owner, quote.action!);
assert.deepEqual(done.players[0].noField!.deployed!.location, splitLocation(flightDestination(game)));
assert.equal(done.players[0].noField!.deployed!.tokenId, ids[1]);
assert.equal(done.players[0].reserves, pilot.reserves);
assert.equal(held(done).status, 'removed');
assert.equal(JSON.stringify(viewGame(done, 'g')).includes(ids[1]), false);
});
void test('Discovery flight can serve one group of an active Treachery card without spending its second original group', () => {
const { game, owner } = discoveryFlightFixture();
game.richeseCache = richeseCards();
const index = game.richeseCache.findIndex(card => card.id === 'richese-ornithopter');
const card = game.richeseCache.splice(index, 1)[0]; game.players[0].hand.push(card);
const first = applyAction(game, owner, declaration(game,
{ movementCard: card.id, ornithopter: 'twoGroups' }));
assert.equal(held(first).status, 'removed');
assert.equal(first.ornithopter!.completed, 1);
assert.equal(first.ornithopter!.cohort!.forces[flightOrigin], 3);
assert.equal(first.discard.some(candidate => candidate.id === card.id), false);
const done = applyAction(reload(first), owner, { type: 'move', from: flightOrigin, amount: 2,
...splitLocation(flightDestination(first, 1)), ornithopterEvent: first.ornithopter!.event });
assert.equal(done.players[0].moved, 2);
assert.equal(done.ornithopter, null);
assert.equal(done.discard.filter(candidate => candidate.id === card.id).length, 1);
});
void test('all AI profiles quote usable owned flight routes and conserve tokens when normal range already suffices', () => {
const { game, owner } = discoveryFlightFixture();
for (const difficulty of DIFFICULTIES) {
const g = reload(game), pilot = g.players.find(player => player.id === owner)!;
pilot.bot = difficulty;
const view = viewGame(g, owner), actions = botActions(view);
assert.ok(actions.length);
assert.equal(actions[0].discoveryOrnithopter, held(g).id);
assert.equal(held(applyAction(g, owner, actions[0])).status, 'removed');
pilot.forces['arrakeen:10'] = 1; pilot.reserves--;
assert.deepEqual(discoveryFlightBotActions(viewGame(g, owner)), []);
}
});
void test('a physical Treachery Ornithopter remains independently owned after spending the Discovery token', () => {
const { game, owner } = discoveryFlightFixture();
game.richeseCache = richeseCards();
const index = game.richeseCache.findIndex(card => card.id === 'richese-ornithopter');
const card = game.richeseCache.splice(index, 1)[0];
game.players[0].hand.push(card);
const done = applyAction(game, owner, declaration(game));
assert.deepEqual(done.players[0].hand, [card]);
assert.equal(done.ornithopter, game.ornithopter);
assert.equal(done.discard.some(candidate => candidate.id === card.id), false);
assert.equal(held(done).status, 'removed');
});