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 = (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(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)); });