Files
Dune/tests/discovery-no-field-entry-quote.test.ts

132 lines
7.2 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
advanceDiscoveryEntry, beginDiscoveryEntryArrival, createDiscoveryEntryRound,
discoveryEntryArrivalSignature, discoveryEntrySignature, finishDiscoveryEntryArrival,
quoteDiscoveryEntry, validateDiscoveryEntryArrivalChild, validateDiscoveryEntryRound,
type DiscoveryEntryContext, type DiscoveryEntrySelection,
} from '../game/discovery-entry';
import { createDiscoveryState } from '../game/discoveries';
import { createRicheseNoField, deployRicheseNoField, type NoFieldValue } from '../game/richese-no-field';
/** Controlled pure-helper fixture: the last-turn reveal, current storm order,
* marker deployment and physical forces are assigned, not earned by gameplay. */
function source(value: NoFieldValue = 0, physical = 0) {
const discoveries = createDiscoveryState(() => 0);
const site = discoveries.tokens.find(token => token.face === 'jacurutu-sietch')!;
Object.assign(site, { status: 'placed', territory: 'meridian', sector: 1,
revealedTurn: 2, known: [], owner: null, acquiredTurn: null });
discoveries.newlyRevealed = [site.id];
const stock = createRicheseNoField(['private-zero-token', 'private-three-token', 'private-five-token']);
const token = stock.tokens.find(candidate => candidate.value === value)!;
const g: DiscoveryEntryContext = { turn: 3, storm: 1, order: ['r', 'a'], discoveries,
players: [
{ id: 'r', faction: 'richese', ally: null, forces: physical ? { 'meridian:2': physical } : {},
noField: deployRicheseNoField(stock, { tokenId: token.id, controller: 'r',
location: { territory: 'meridian', sector: 2 } }), noFieldEvent: 'private-marker-event' },
{ id: 'a', faction: 'atreides', ally: null, forces: {} },
] };
const frame = createDiscoveryEntryRound(g, 'public-entry-round');
const offer = quoteDiscoveryEntry(g, frame, 'r');
assert.ok(offer.noField);
return { g, frame, offer, marker: { ...offer.noField }, token: site.id };
}
void test('zero three and five markers can enter without physical sources or any reserve-based permission', () => {
for (const value of [0, 3, 5] as const) {
const f = source(value);
Object.assign(f.g.players[0], { reserves: 0 });
const before = structuredClone(f.g);
assert.deepEqual(f.offer.sources, []);
assert.equal(f.offer.blocked, null);
const selected = quoteDiscoveryEntry(f.g, f.frame, 'r', { groups: [], noField: f.marker });
assert.deepEqual(selected.arrival?.groups, []);
assert.equal(selected.arrival?.amount, 1);
assert.equal(selected.arrival?.elite, 0);
assert.deepEqual(selected.arrival?.noField, f.marker);
assert.throws(() => quoteDiscoveryEntry(f.g, f.frame, 'r', { groups: [] }), /positive/);
assert.deepEqual(f.g, before);
}
});
void test('mixed entry keeps physical groups separate and omitting the marker preserves the old physical signature bytes', () => {
const f = source(5, 3), groups = [{ source: 'meridian:2', normal: 2, elite: 0 }];
const physical = quoteDiscoveryEntry(f.g, f.frame, 'r', { groups }).arrival; assert.ok(physical);
assert.equal(physical.noField, undefined);
assert.equal(physical.signature, JSON.stringify([
'public-entry-round:0', f.token, 'r', 'meridian', 'jacurutu-sietch', 0, 0, 0,
[['meridian:2', 2, 0]], 2, 0,
]));
const mixed = quoteDiscoveryEntry(f.g, f.frame, 'r', { groups, noField: f.marker }).arrival; assert.ok(mixed);
assert.deepEqual(mixed.groups, groups);
assert.equal(mixed.amount, 3);
assert.equal(mixed.elite, 0);
assert.equal(mixed.signature, JSON.stringify([
'public-entry-round:0', f.token, 'r', 'meridian', 'jacurutu-sietch', 0, 0, 0,
[['meridian:2', 2, 0]], 3, 0, [true, 'meridian:2'],
]));
assert.ok(!mixed.signature.includes(f.marker.tokenId));
assert.ok(!mixed.signature.includes(f.marker.event));
const legacy = structuredClone(f.g); delete legacy.players[0].noField; delete legacy.players[0].noFieldEvent;
assert.deepEqual(quoteDiscoveryEntry(legacy, f.frame, 'r', { groups }).arrival, physical);
});
void test('a selected marker must keep its current owner event and canonical eligible parent source', () => {
const f = source();
for (const noField of [
{ ...f.marker, tokenId: 'old-token' },
{ ...f.marker, event: 'old-marker-event' },
{ ...f.marker, source: 'meridian:3' },
{ ...f.marker, source: 'meridian:02' },
]) assert.throws(() => quoteDiscoveryEntry(f.g, f.frame, 'r', { groups: [], noField }), /current owned No-Field/);
const extra = { ...f.marker, amount: 5 };
assert.throws(() => quoteDiscoveryEntry(f.g, f.frame, 'r', { groups: [], noField: extra }), /positive/);
assert.throws(() => quoteDiscoveryEntry(f.g, f.frame, 'a', { groups: [], noField: f.marker }), /another player/);
for (const change of ['controller', 'event', 'storm', 'parent'] as const) {
const g = structuredClone(f.g), owner = g.players[0];
if (change === 'controller') owner.noField!.deployed!.controller = 'a';
if (change === 'event') owner.noFieldEvent = '';
if (change === 'storm') g.storm = 2;
if (change === 'parent') owner.noField!.deployed!.location = { territory: 'imperial_basin', sector: 10 };
const offer = quoteDiscoveryEntry(g, f.frame, 'r');
assert.equal(offer.noField, undefined);
assert.ok(offer.blocked);
}
});
void test('a signed marker arrival is copied and remains valid after native relocation and event rotation', () => {
const f = source(3), selection: DiscoveryEntrySelection = { groups: [], noField: { ...f.marker } };
const next = beginDiscoveryEntryArrival(f.g, f.frame, 'r', selection);
assert.ok(next.arrival?.noField);
selection.noField!.event = 'changed-selection';
assert.equal(next.arrival.noField.event, f.marker.event);
assert.ok(next.signature.includes(f.marker.tokenId));
assert.ok(next.signature.includes(f.marker.event));
f.g.players[0].noField!.deployed!.location = { territory: next.arrival.destination, sector: 0 };
f.g.players[0].noFieldEvent = 'rotated-after-native-relocation';
assert.doesNotThrow(() => validateDiscoveryEntryRound(f.g, next));
assert.doesNotThrow(() => validateDiscoveryEntryArrivalChild(next, { owner: 'r', territory: next.arrival!.destination,
sector: 0, amount: 1, elite: 0, discoveryEntry: next.arrival!.signature }));
assert.equal(finishDiscoveryEntryArrival(f.g, next).cursor, 1);
const wrongSource = structuredClone(next);
wrongSource.arrival!.noField!.source = 'imperial_basin:10';
wrongSource.arrival!.signature = discoveryEntryArrivalSignature(wrongSource.arrival!);
wrongSource.signature = discoveryEntrySignature(wrongSource);
assert.throws(() => validateDiscoveryEntryRound(f.g, wrongSource), /revealed destination/);
const fakeElite = structuredClone(next);
fakeElite.arrival!.elite = 1;
fakeElite.arrival!.signature = discoveryEntryArrivalSignature(fakeElite.arrival!);
fakeElite.signature = discoveryEntrySignature(fakeElite);
assert.throws(() => validateDiscoveryEntryRound(f.g, fakeElite), /exact signed arrival/);
});
void test('declining an offered marker advances only the optional entry cursor', () => {
const f = source(), before = structuredClone(f.g);
const next = advanceDiscoveryEntry(f.g, f.frame, 'r');
assert.equal(next.cursor, 1);
assert.equal(next.arrival, null);
assert.equal(next.stage, 'choose');
assert.deepEqual(f.g, before);
});