Files
Dune/tests/discovery-flight.test.ts
T

237 lines
14 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
createDiscoveryState, placeDiscovery, projectDiscoveryState, revealDiscoveryToken,
type DiscoveryOpaqueTokenId,
} from '../game/discoveries';
import {
completeDiscoveryFlight, discoveryFlightOffer, DiscoveryFlightError, quoteDiscoveryFlight,
validateDiscoveryFlight,
type DiscoveryFlightContext, type DiscoveryFlightMovement, type DiscoveryFlightReceipt,
} from '../game/discovery-flight';
function fixture() {
const supplied = createDiscoveryState(() => 0.999);
const token = supplied.tokens.find(token => token.face === 'ornithopter')!.id;
const placed = placeDiscovery(supplied, 'discovery-funeral-plain', () => 0.999);
const discoveries = revealDiscoveryToken(placed, token, 2, 'p').state;
const context: DiscoveryFlightContext = {
status: 'playing', phase: 5, active: 'p', turn: 3, discoveries,
players: [{ id: 'p', moved: 0 }, { id: 'q', moved: 0 }],
};
const move: DiscoveryFlightMovement = {
player: 'p', origin: 'false_wall_west',
group: [['false_wall_west:16', 2], ['false_wall_west:17', 1]],
eliteGroup: { 'false_wall_west:16': 1, 'false_wall_west:17': 0 },
elite: 1, total: 3, to: 'the_great_flat', sector: 15,
advisors: false, wantsFighters: false,
};
return { context, token, move };
}
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value));
void test('ordinary Discovery receipt signatures retain their legacy bytes with no Planetologist suffix', () => {
const { context, token, move } = fixture();
assert.equal(quoteDiscoveryFlight(context, token, move).selection,
'["p","false_wall_west",[["false_wall_west:16",2,1],["false_wall_west:17",1,0]],1,3,"the_great_flat",15,false,false,null,null]');
});
void test('Discovery Planetologist receipts bind both normalized origins and the exact skill choice', () => {
const { context, token, move } = fixture();
const gather: DiscoveryFlightMovement = { ...move,
origins: ['false_wall_west', 'wind_pass'], planetologist: { leader: 'p-leader', mode: 'gather' },
group: [['false_wall_west:16', 2], ['wind_pass:14', 1]],
eliteGroup: { 'false_wall_west:16': 1, 'wind_pass:14': 0 } };
const receipt = quoteDiscoveryFlight(context, token, gather);
assert.doesNotThrow(() => validateDiscoveryFlight(context, receipt, gather));
for (const changed of [
{ ...gather, planetologist: { leader: 'other-leader', mode: 'gather' as const } },
{ ...gather, origins: ['false_wall_west', 'sihaya_ridge'] },
{ ...gather, planetologist: { leader: 'p-leader', mode: 'range' as const } },
]) assert.throws(() => validateDiscoveryFlight(context, receipt, changed), DiscoveryFlightError);
const range = { ...move, planetologist: { leader: 'p-leader', mode: 'range' as const } };
assert.notEqual(quoteDiscoveryFlight(context, token, range).selection,
quoteDiscoveryFlight(context, token, move).selection);
});
void test('Discovery gathering rejects unused duplicate third or untrained origins before spending the token', () => {
const { context, token, move } = fixture(), before = structuredClone(context);
const gather: DiscoveryFlightMovement = { ...move,
origins: ['false_wall_west', 'wind_pass'], planetologist: { leader: 'p-leader', mode: 'gather' } };
for (const changed of [gather, { ...gather, origins: ['false_wall_west', 'false_wall_west'] },
{ ...gather, origins: ['wind_pass', 'false_wall_west'] },
{ ...gather, origins: ['false_wall_west', 'wind_pass', 'sihaya_ridge'] },
{ ...gather, planetologist: undefined }, { ...gather, origins: undefined }])
assert.throws(() => quoteDiscoveryFlight(context, token, changed), DiscoveryFlightError);
assert.deepEqual(context, before);
});
function rejectedUnchanged<T>(input: T, work: () => unknown) {
const before = structuredClone(input);
assert.throws(work, DiscoveryFlightError);
assert.deepEqual(input, before);
}
void test('Discovery flight quote binds one typed group without spending custody or changing the source state', () => {
const { context, token, move } = fixture(), before = structuredClone({ context, move });
const receipt = quoteDiscoveryFlight(context, token, move);
assert.equal(receipt.token, token);
assert.equal(receipt.player, 'p');
assert.equal(receipt.turn, 3);
assert.equal(receipt.acquiredTurn, 2);
assert.equal(receipt.move, 0);
assert.doesNotThrow(() => validateDiscoveryFlight(context, receipt, move));
assert.equal(context.discoveries!.tokens.find(candidate => candidate.id === token)!.status, 'carried');
assert.deepEqual({ context, move }, before);
rejectedUnchanged(context, () => completeDiscoveryFlight(context, receipt, move));
});
void test('turn of acquisition, earlier turn, foreign owner and unavailable physical tokens cannot create flight receipts', () => {
const { context, token, move } = fixture();
for (const turn of [1, 2]) {
const sameTurn = { ...context, turn };
rejectedUnchanged(sameTurn, () => quoteDiscoveryFlight(sameTurn, token, move));
}
for (const wrong of [undefined, '', 'discovery-token-99',
context.discoveries!.tokens.find(candidate => candidate.face === 'cistern')!.id])
rejectedUnchanged(context, () => quoteDiscoveryFlight(context, wrong, move));
const foreign = { ...context, active: 'q' };
rejectedUnchanged(foreign, () => quoteDiscoveryFlight(foreign, token, { ...move, player: 'q' }));
for (const changes of [{ status: 'finished' }, { phase: 7 }, { active: 'q' }, { turn: 0 }]) {
const unavailable = { ...context, ...changes };
rejectedUnchanged(unavailable, () => quoteDiscoveryFlight(unavailable, token, move));
}
});
void test('pending owned movement responses and JSON restore retain the exact group without consuming the token', () => {
const { context, token, move } = fixture(), receipt = quoteDiscoveryFlight(context, token, move);
const order = { ...move, discoveryFlight: receipt };
const pending = { ...context, response: { kind: 'choamPower', owner: 'q' }, pendingChoamMove: order };
const saved = reload(pending), before = structuredClone(saved);
assert.doesNotThrow(() => validateDiscoveryFlight(saved, saved.pendingChoamMove.discoveryFlight, saved.pendingChoamMove));
assert.deepEqual(quoteDiscoveryFlight(saved, token, saved.pendingChoamMove), receipt);
assert.deepEqual(saved, before);
// Abandoning an invalidated route drops the order, not its unspent token.
const abandoned = { ...saved, response: null, pendingChoamMove: null };
assert.equal(discoveryFlightOffer(abandoned, 'p', { movesAllowed: 1 })!.blocked, null);
assert.deepEqual(abandoned.discoveries, context.discoveries);
});
void test('completed flight removes only the carried token after one committed movement and preserves its acquisition history', () => {
const { context, token, move } = fixture(), receipt = quoteDiscoveryFlight(context, token, move);
const committed = reload(context);
committed.players[0].moved++;
const before = structuredClone(committed), next = completeDiscoveryFlight(committed, reload(receipt), reload(move));
const originalToken = context.discoveries!.tokens.find(candidate => candidate.id === token)!;
assert.deepEqual(next.tokens.find(candidate => candidate.id === token), {
...originalToken, status: 'removed', owner: null,
});
assert.deepEqual(next.tokens.filter(candidate => candidate.id !== token),
context.discoveries!.tokens.filter(candidate => candidate.id !== token));
assert.deepEqual(next.newlyRevealed, context.discoveries!.newlyRevealed);
assert.deepEqual(committed, before);
assert.notEqual(next, committed.discoveries);
const spent = { ...committed, discoveries: next };
rejectedUnchanged(spent, () => completeDiscoveryFlight(spent, receipt, move));
rejectedUnchanged(spent, () => quoteDiscoveryFlight(spent, token, move));
assert.equal(discoveryFlightOffer(spent, 'p'), null);
});
void test('saved receipts reject replay onto another move, changed groups or destinations, and altered token acquisition', () => {
const { context, token, move } = fixture(), receipt = quoteDiscoveryFlight(context, token, move);
for (const alter of [
(order: DiscoveryFlightMovement) => { order.to = 'hagga_basin'; },
(order: DiscoveryFlightMovement) => { order.sector = 14; },
(order: DiscoveryFlightMovement) => { order.group = [['false_wall_west:16', 1], ['false_wall_west:17', 2]]; },
(order: DiscoveryFlightMovement) => { order.eliteGroup = { 'false_wall_west:16': 0, 'false_wall_west:17': 1 }; },
(order: DiscoveryFlightMovement) => { order.advisors = true; },
(order: DiscoveryFlightMovement) => { order.lockedTurn = 3; },
]) {
const changed = reload(move); alter(changed);
rejectedUnchanged(context, () => validateDiscoveryFlight(context, receipt, changed));
}
for (const changes of [{ turn: 4 }, { players: [{ id: 'p', moved: 1 }, { id: 'q', moved: 0 }] }]) {
const changed = { ...context, ...changes };
rejectedUnchanged(changed, () => validateDiscoveryFlight(changed, receipt, move));
}
const laterMove = reload(context); laterMove.players[0].moved = 2;
rejectedUnchanged(laterMove, () => completeDiscoveryFlight(laterMove, receipt, move));
const reacquired = reload(context), held = reacquired.discoveries!.tokens.find(candidate => candidate.id === token)!;
held.acquiredTurn = 1; held.revealedTurn = 1;
rejectedUnchanged(reacquired, () => validateDiscoveryFlight(reacquired, receipt, move));
for (const damage of [
{ ...receipt, player: 'q' }, { ...receipt, move: -1 }, { ...receipt, selection: 'changed' },
{ ...receipt, turn: 2 }, { ...receipt, extra: true },
]) rejectedUnchanged(context, () => validateDiscoveryFlight(context, damage, move));
});
void test('receipt signature ignores property order and independent Treachery flight fields while binding a selected marker', () => {
const { context, token, move } = fixture();
const marked = { ...move, total: 4, noField: { tokenId: 'field-one', event: 'marker-event', from: 'false_wall_west:17' } };
const receipt = quoteDiscoveryFlight(context, token, marked);
const equivalent = {
...marked, group: [...marked.group].reverse(),
eliteGroup: { 'false_wall_west:17': 0, 'false_wall_west:16': 1 },
ornithopterEvent: 'physical-treachery-card-event', ornithopterRange: false,
discoveryFlight: receipt,
};
assert.doesNotThrow(() => validateDiscoveryFlight(context, receipt, equivalent));
assert.deepEqual(quoteDiscoveryFlight(context, token, equivalent), receipt);
for (const changes of [{ tokenId: 'other-field' }, { event: 'other-event' }, { from: 'false_wall_west:16' }])
rejectedUnchanged(context, () => validateDiscoveryFlight(context, receipt,
{ ...marked, noField: { ...marked.noField, ...changes } }));
const withCard = { ...context, ornithopter: { event: 'physical-treachery-card-event', completed: 0, mode: 'twoGroups' } };
const before = structuredClone(withCard);
assert.deepEqual(quoteDiscoveryFlight(withCard, token, equivalent), receipt);
assert.deepEqual(withCard, before);
});
void test('malformed or motionless canonical groups cannot reserve the Discovery token', () => {
const { context, token, move } = fixture();
const malformed: DiscoveryFlightMovement[] = [
{ ...move, source: 'ambassador' },
{ ...move, total: 0, elite: 0, group: [], eliteGroup: {} },
{ ...move, total: 4 },
{ ...move, group: [['false_wall_west:16', 3], ['false_wall_west:16', 1]] },
{ ...move, group: [['false_wall_west:16', 0], ['false_wall_west:17', 3]] },
{ ...move, group: [['arrakeen:10', 2], ['false_wall_west:17', 1]] },
{ ...move, eliteGroup: { 'false_wall_west:16': 3 }, elite: 3 },
{ ...move, eliteGroup: { 'false_wall_west:16': 1, 'not-selected:0': 0 } },
{ ...move, to: 'false_wall_west', sector: 16 },
{ ...move, wantsFighters: true },
];
for (const invalid of malformed) rejectedUnchanged(context, () => quoteDiscoveryFlight(context, token, invalid));
});
void test('private projected offers show a later-turn fixed range, ordinary allowance and response locks without consuming custody', () => {
const { context, token } = fixture();
const projected = { ...context, discoveries: projectDiscoveryState(context.discoveries!, 'atreides') };
const before = structuredClone(projected);
assert.deepEqual(discoveryFlightOffer(projected, 'p', { movesAllowed: 1 }), {
token, acquiredTurn: 2, range: 3, blocked: null,
});
assert.equal(discoveryFlightOffer(projected, 'q'), null);
assert.match(discoveryFlightOffer({ ...projected, turn: 2 }, 'p')!.blocked!, /turn after/);
assert.match(discoveryFlightOffer({ ...projected, phase: 7 }, 'p')!.blocked!, /own Shipment/);
assert.match(discoveryFlightOffer(projected, 'p', { movesAllowed: 0 })!.blocked!, /No ordinary movement/);
assert.equal(discoveryFlightOffer(projected, 'p', { blocked: 'Finish the current response.' })!.blocked, 'Finish the current response.');
// It replaces one action's movement range; neither Kulon nor a card adds to it.
const otherBenefits = { ...projected, choamMovement: { turn: 3, bonus: 1 }, ornithopter: { mode: 'range3' } };
assert.equal(discoveryFlightOffer(otherBenefits, 'p')!.range, 3);
assert.deepEqual(projected, before);
const badView = reload(projected);
badView.discoveries.tokens[0].face = null;
assert.equal(discoveryFlightOffer(badView, 'p'), null);
});
void test('missing or duplicated physical custody cannot authenticate a saved flight', () => {
const { context, token, move } = fixture(), receipt = quoteDiscoveryFlight(context, token, move);
const missing = { ...context, discoveries: undefined };
rejectedUnchanged(missing, () => quoteDiscoveryFlight(missing, token, move));
const duplicated = reload(context);
duplicated.discoveries!.tokens[0].id = token as DiscoveryOpaqueTokenId;
rejectedUnchanged(duplicated, () => validateDiscoveryFlight(duplicated, receipt, move));
const wrongReceipt = { ...receipt, token: 'invalid' } as unknown as DiscoveryFlightReceipt;
rejectedUnchanged(context, () => validateDiscoveryFlight(context, wrongReceipt, move));
});