Allow concealed No-Fields in free Discovery entry

This commit is contained in:
Quintine committed 2026-10-27 13:22:14 +11:00
1 parent 11e50e7f49
commit 7f999f4573
14 files changed
+830 -38

No files matched your search

@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { GameView } from '../game/engine';
import type { DiscoveryEntryOffer } from '../game/discovery-entry';
import {
discoveryEntryBotActions, discoveryEntryDeclineAction, discoveryEntryMoveAction,
} from '../game/discovery-entry-options';
import { DiscoveryEntryDecision } from '../components/discovery-entry';
/** Controlled public projection only. These cases establish rendering and exact
* action construction; they do not establish a native reveal/entry chronology
* or claim to execute the stateful chooser's React event handlers. */
function view(physical = 0, marker = true): GameView {
const offer: DiscoveryEntryOffer = { event: 'entry-round:0', token: 'discovery-token-1', owner: 'r',
parent: 'meridian', destination: 'jacurutu-sietch', sector: 0,
sources: physical ? [{ source: 'meridian:2', normal: physical, elite: 0 }] : [],
...(marker ? { noField: { tokenId: 'private-marker-id', event: 'private-marker-event', source: 'meridian:2' } } : {}),
blocked: null, arrival: null };
return { status: 'playing', phase: 0, turn: 3, me: 'r',
decision: { kind: 'discoveryEntry', player: 'r', event: offer.event }, discoveryEntry: offer,
players: [{ id: 'r', name: 'Richese', faction: 'richese', autopilot: null }] } as unknown as GameView;
}
function html(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(DiscoveryEntryDecision, { game, busy, act() {} }));
}
void test('marker-only entry defaults to a selected independent checkbox and explains concealed free relocation without a physical counter', () => {
const game = view(), markup = html(game);
assert.match(markup, /type="checkbox"[^>]*checked=""/);
assert.match(markup, /aria-label="Move concealed No-Field inside"/);
assert.match(markup, /Selected: 0 physical forces · 1 concealed marker/);
assert.match(markup, /Move 0 physical forces and 1 concealed marker inside/);
assert.match(markup, /Provisional concealed No-Field entry/);
assert.match(markup, /without revealing its value or materializing reserves/);
assert.match(markup, /existing shipment history stays unchanged/);
assert.match(markup, /costs no spice and uses no shipment or normal movement/);
assert.match(markup, /Select all forces and marker/);
assert.match(markup, /Clear selection/);
assert.match(markup, /Leave forces and marker outside/);
assert.doesNotMatch(markup, /type="number"|private-marker-id|private-marker-event/);
});
void test('marker-only and combined adapters bind both current events while legacy physical packets stay unchanged', () => {
for (const physical of [0, 2]) {
const game = view(physical), offer = game.discoveryEntry!;
const expected = { type: 'decision', event: offer.event, accept: true, groups: offer.sources,
noField: offer.noField!.tokenId, noFieldEvent: offer.noField!.event };
assert.deepEqual(discoveryEntryMoveAction(game, offer.sources, offer.noField), expected);
assert.deepEqual(discoveryEntryBotActions(game), [expected,
{ type: 'decision', event: offer.event, accept: false }]);
assert.match(html(game), new RegExp(`Selected: ${physical} physical forces · 1 concealed marker`));
}
const game = view(2), offer = game.discoveryEntry!;
assert.deepEqual(discoveryEntryMoveAction(game, offer.sources), {
type: 'decision', event: offer.event, accept: true, groups: offer.sources,
});
assert.equal(discoveryEntryMoveAction(game, []), null);
assert.deepEqual(discoveryEntryDeclineAction(game), { type: 'decision', event: offer.event, accept: false });
const legacy = html(view(2, false));
assert.match(legacy, /Move 2 forces inside/);
assert.match(legacy, /Select all forces/);
assert.doesNotMatch(legacy, /checkbox|concealed marker|Provisional/);
});
void test('stale token marker-event parent-source and Discovery-event selections cannot construct an entry', () => {
const game = view(), marker = game.discoveryEntry!.noField!;
for (const stale of [
{ ...marker, tokenId: 'old-marker' }, { ...marker, event: 'old-marker-event' },
{ ...marker, source: 'meridian:3' }, { ...marker, source: 'imperial_basin:10' },
]) assert.equal(discoveryEntryMoveAction(game, [], stale), null);
const changed = structuredClone(game);
changed.discoveryEntry!.noField!.event = 'new-marker-event';
assert.equal(discoveryEntryMoveAction(changed, [], marker), null);
const wrongDecision = structuredClone(game);
wrongDecision.discoveryEntry!.event = 'old-entry-round';
assert.equal(discoveryEntryMoveAction(wrongDecision, [], marker), null);
assert.equal(html(wrongDecision), '');
const observer = structuredClone(game); observer.me = 'a';
assert.equal(discoveryEntryMoveAction(observer, [], marker), null);
assert.equal(html(observer), '');
});
void test('busy autopilot and blocked marker-only panels cannot offer an enabled move or selection control', () => {
const busy = html(view(), true);
assert.equal((busy.match(/<button[^>]*disabled=""/g) ?? []).length, 4);
assert.match(busy, /<input[^>]*type="checkbox"[^>]*disabled=""/);
const automatic = view(); automatic.players[0].autopilot = 'Hard';
assert.equal((html(automatic).match(/<button[^>]*disabled=""/g) ?? []).length, 4);
const blocked = view(); blocked.discoveryEntry!.blocked = 'The location is full.';
assert.equal(discoveryEntryMoveAction(blocked, [], blocked.discoveryEntry!.noField), null);
assert.deepEqual(discoveryEntryBotActions(blocked), []);
assert.match(html(blocked), /The location is full/);
assert.equal((html(blocked).match(/<button[^>]*disabled=""/g) ?? []).length, 4);
});
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
import { actDiscoveryNoFieldEntry as act, createDiscoveryNoFieldEntryFixture as fixture,
discoveryNoFieldEntryInventory as inventory, discoveryNoFieldEntryPlayer as p,
discoveryNoFieldEntryReload as reload, finishDiscoveryNoFieldEntry as finish } from './fixture-discovery-no-field-entry';
function reject(game: Game, actor: string, action: Action) {
const before = reload(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function allowances(game: Game) {
return game.players.map(player => ({ id: player.id, shipped: player.shipped, moved: player.moved,
movesAllowed: viewGame(game, player.id).players.find(own => own.id === player.id)!.movesAllowed }));
}
void test('public Basic and Advanced sources earn original location placement paid marker inspection and next-turn offer', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
const optIn = f.sourceActions.findIndex(step => step.action.type === 'moduleOption' && step.action.module === 'discoveries');
const ready = f.sourceActions.findIndex(step => step.action.type === 'ready');
assert.ok(optIn >= 0 && optIn < ready);
assert.equal(f.sourceCounts.setup, f.setupActions.length); assert.equal(f.sourceCounts.source, f.sourceActions.length);
assert.equal(f.sourceCounts.placement, f.placement.steps.length); assert.equal(f.sourceCounts.markerShipment, f.markerShipment.steps.length);
assert.equal(f.sourceCounts.revelation, f.revelation.steps.length);
const originalDraw = f.placement.before.spiceDeck[0];
assert.ok('territory' in originalDraw && originalDraw.discovery === 'discovery-hagga-basin');
assert.equal(f.placement.after.spiceWindow?.territory, 'hagga_basin');
assert.ok(f.placement.after.discoveries!.tokens.some(token => token.id === f.discoveryToken && token.face === f.face && token.status === 'placed'));
assert.equal(p(f.markerShipment.before, f.owner).spice - p(f.markerShipment.after, f.owner).spice, 2);
assert.deepEqual(f.revelation.steps.map(step => [step.action.type, step.action.reveal]), [['discovery', false], ['discovery', true]]);
assert.equal(f.beforeReveal.phase, 7); assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 0);
assert.equal(f.game.decision?.kind, 'discoveryEntry'); assert.equal(f.game.decision?.player, f.owner);
assert.deepEqual(f.offer.noField, { tokenId: f.markerToken, event: f.noFieldEvent, source: f.source });
assert.deepEqual(f.action.groups, []); assert.deepEqual(inventory(f.game), f.original);
}
});
void test('marker-only original zero three and five enter for free concealed in both modes without materializing', () => {
for (const advanced of [false, true]) for (const value of [0, 3, 5] as const) {
const f = fixture({ advanced, value }), before = p(f.game, f.owner), turns = allowances(f.game);
let game = act(reload(f.game), f.owner, f.action); game = finish(game);
const after = p(game, f.owner);
assert.equal(after.spice, before.spice); assert.equal(after.reserves, before.reserves); assert.equal(after.tanks, before.tanks);
assert.deepEqual(after.forces, before.forces); assert.deepEqual(allowances(game), turns);
assert.deepEqual(after.noField!.tokens, f.marker!.tokens); assert.equal(after.noField!.lastShipped, f.markerToken);
assert.deepEqual(after.noField!.deployed!.location, { territory: f.destination, sector: 0 });
assert.notEqual(after.noFieldEvent, f.noFieldEvent); assert.equal(game.discoveryEntry ?? null, null);
assert.equal(game.phase, 0); assert.equal(game.stormPending, advanced ? 1 : null);
assert.deepEqual(inventory(game), f.original); reject(game, f.owner, f.action);
}
});
void test('actual earlier physical companion and later marker enter as separate native custody in one free group', () => {
const f = fixture({ companions: true, value: 5, face: 'shrine' }); assert.ok(f.physicalShipment);
assert.equal(f.physicalShipment.before.turn, 1); assert.equal(f.markerShipment.before.turn, 2);
assert.equal(f.beforeReveal.turn, 2); assert.equal(f.game.turn, 3);
assert.equal(p(f.physicalShipment.before, f.owner).spice - p(f.physicalShipment.after, f.owner).spice, 2);
const before = p(f.game, f.owner), turns = allowances(f.game);
assert.equal(before.forces[f.source], 1); assert.deepEqual(f.action.groups, [{ source: f.source, normal: 1, elite: 0 }]);
const game = finish(act(reload(f.game), f.owner, f.action)), after = p(game, f.owner);
assert.equal(after.forces[f.source] ?? 0, 0); assert.equal(after.forces[`${f.destination}:0`], 1);
assert.deepEqual(after.noField!.deployed!.location, { territory: f.destination, sector: 0 });
assert.equal(after.reserves, before.reserves); assert.equal(after.tanks, before.tanks); assert.equal(after.spice, before.spice);
assert.deepEqual(allowances(game), turns); assert.deepEqual(after.noField!.tokens, f.marker!.tokens);
assert.equal(after.noField!.lastShipped, f.markerToken); assert.deepEqual(inventory(game), f.original);
});
void test('declining the original owned offer retains the marker event at its parent and resumes the normal Storm', () => {
const f = fixture({ value: 3 }), before = p(f.game, f.owner), turns = allowances(f.game);
const game = finish(act(reload(f.game), f.owner, f.decline)), after = p(game, f.owner);
assert.deepEqual(after.noField, f.marker); assert.equal(after.noFieldEvent, f.noFieldEvent);
assert.equal(after.spice, before.spice); assert.equal(after.reserves, before.reserves); assert.equal(after.tanks, before.tanks);
assert.deepEqual(after.forces, before.forces); assert.deepEqual(allowances(game), turns);
assert.equal(game.discoveryEntry ?? null, null); assert.equal(game.phase, 0); assert.equal(game.stormPending, 1);
assert.deepEqual(inventory(game), f.original);
});
void test('stale marker and offer events foreign actors and forged physical sources reject before free-entry custody changes', () => {
const f = fixture({ value: 5 });
reject(f.game, f.other, f.action); reject(f.game, f.owner, { ...f.action, event: 'old-discovery-entry' });
reject(f.game, f.owner, { ...f.action, noFieldEvent: 'old-marker-event' });
reject(f.game, f.owner, { ...f.action, noField: 'foreign-marker' });
const unused = p(f.game, f.owner).noField!.tokens.find(token => token.id !== f.markerToken)!;
reject(f.game, f.owner, { ...f.action, noField: unused.id });
reject(f.game, f.owner, { ...f.action, groups: [{ source: 'arrakeen:10', normal: 1, elite: 0 }] });
reject(f.game, f.owner, { ...f.action, groups: [{ source: f.source, normal: 1, elite: 0 }] });
reject(f.game, f.owner, { type: 'decision', event: f.event, accept: true, groups: [] });
assert.deepEqual(p(f.game, f.owner).noField, f.marker); assert.deepEqual(inventory(f.game), f.original);
});
void test('a real mixed source can send only its physical companion and leave the concealed marker at the parent', () => {
const f = fixture({ companions: true, value: 3 }), before = p(f.game, f.owner);
const game = finish(act(reload(f.game), f.owner, { type: 'decision', event: f.event, accept: true, groups: f.action.groups }));
const after = p(game, f.owner);
assert.equal(after.forces[f.source] ?? 0, 0); assert.equal(after.forces[`${f.destination}:0`], 1);
assert.deepEqual(after.noField, f.marker); assert.equal(after.noFieldEvent, f.noFieldEvent);
assert.equal(after.spice, before.spice); assert.equal(after.reserves, before.reserves); assert.equal(after.tanks, before.tanks);
assert.deepEqual(inventory(game), f.original);
});
void test('all four minimal profiles propose the current owned marker entry and complete its original free arrival legally', () => {
const f = fixture({ value: 0 });
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
const view = viewGame(f.game, f.owner); view.players.find(player => player.id === f.owner)!.bot = difficulty;
const choice = botActions(view).find(action => action.type === 'decision' && action.accept === true && action.noField === f.markerToken);
assert.ok(choice, `${difficulty} supplies the owned native marker option`);
const game = finish(act(reload(f.game), f.owner, choice));
assert.deepEqual(p(game, f.owner).noField!.deployed!.location, { territory: f.destination, sector: 0 });
assert.equal(p(game, f.owner).reserves, p(f.game, f.owner).reserves); assert.equal(p(game, f.owner).tanks, 0);
assert.deepEqual(inventory(game), f.original);
}
});
@@ -0,0 +1,131 @@
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);
});
+178
View File
@@ -0,0 +1,178 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries';
import type { NoFieldValue } from '../game/richese-no-field';
import { discoveryNoFieldBlowPlayer, discoveryNoFieldBlowReload, discoveryNoFieldBlowInventory,
discoveryNoFieldBlowClean, nextDiscoveryNoFieldBlow, type DiscoveryNoFieldBlowStep } from './fixture-discovery-no-field-blow';
export type DiscoveryNoFieldEntryStep = DiscoveryNoFieldBlowStep;
export const discoveryNoFieldEntryPlayer = discoveryNoFieldBlowPlayer;
export const discoveryNoFieldEntryReload = discoveryNoFieldBlowReload;
export const discoveryNoFieldEntryInventory = discoveryNoFieldBlowInventory;
export const discoveryNoFieldEntryClean = discoveryNoFieldBlowClean;
export const nextDiscoveryNoFieldEntry = nextDiscoveryNoFieldBlow;
/** Only original scalar lotteries are chosen. UUIDs and non-scalar native byte
* entropy remain untouched. A selected Discovery face is sampled from its real
* current supply at the actual printed Spice draw, never installed afterward.
*/
function originalLottery<T>(game: Game, face: DiscoveryTokenFace | undefined, operation: () => T): T {
let scalar = 0xffffffff;
if (face) {
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
const index = eligible.findIndex(token => token.face === face); assert.ok(index >= 0);
scalar = Math.floor((index + 0.5) / eligible.length * 0x100000000);
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'), native = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = scalar;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function actDiscoveryNoFieldEntry(game: Game, actor: string, action: Action,
actions: DiscoveryNoFieldEntryStep[] = [], face?: DiscoveryTokenFace): Game {
const next = originalLottery(game, face, () => applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) }); return next;
}
export function advanceDiscoveryNoFieldEntry(game: Game, until: (state: Game) => boolean,
actions: DiscoveryNoFieldEntryStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Discovery entry chronology ended before its requested boundary');
const next = nextDiscoveryNoFieldEntry(game); assert.ok(next, 'Complete the actual native pending control');
game = actDiscoveryNoFieldEntry(game, next.actor, next.action, actions);
}
throw Error('The original Discovery/No-Field entry chronology did not reach its finite boundary');
}
export function settleDiscoveryNoFieldEntry(game: Game, actions: DiscoveryNoFieldEntryStep[] = []): Game {
return advanceDiscoveryNoFieldEntry(game, discoveryNoFieldEntryClean, actions);
}
export function finishDiscoveryNoFieldEntry(game: Game, actions: DiscoveryNoFieldEntryStep[] = []): Game {
return advanceDiscoveryNoFieldEntry(game, state => !state.discoveryEntry && discoveryNoFieldEntryClean(state), actions);
}
function selectLand(game: Game, position: number, parent: string) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card &&
!card.discovery && card.territory !== parent && card.sector !== game.storm);
assert.ok(from >= position); const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
}
/** First original Discovery blow, native supply placement, paid parent shipment
* and actual Collection inspection/reveal. Mixed physical1 is paid a full turn
* before its marker. Both then enter the original NEXT-turn free-entry offer.
* No audit initializer or played component/state installation is used.
*/
export function createDiscoveryNoFieldEntryFixture(options: {
advanced?: boolean; value?: NoFieldValue; companions?: boolean; face?: 'cistern' | 'shrine';
} = {}) {
const advanced = options.advanced ?? true, value = options.value ?? 0, companions = options.companions ?? false;
const face = options.face ?? 'cistern', owner = 'richese', observer = 'atreides', other = 'emperor';
const printed = 'discovery-hagga-basin', parent = DISCOVERY_CARD_PLACEMENTS[printed];
let game = createGame('DISCOVERYNFENTRY', newPlayer(owner, 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer(observer, 'Atreides', 'atreides')); joinGame(game, newPlayer(other, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: DiscoveryNoFieldEntryStep[] = [];
const act = (actor: string, action: Action, lotteryFace?: DiscoveryTokenFace) => {
game = actDiscoveryNoFieldEntry(game, actor, action, actions, lotteryFace);
};
const reach = (until: (state: Game) => boolean) => { game = advanceDiscoveryNoFieldEntry(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
act(game.host, { type: 'moduleOption', module: 'discoveries', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => !player.hand.length));
const ordinary = ['projectile', 'shield', 'snooper'].map(kind => game.deck.find(card => card.kind === kind));
const dealOrder = ordinary.map((card, position) => {
assert.ok(card); const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), setupActions = structuredClone(actions);
const original = discoveryNoFieldEntryInventory(game);
const from = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed); assert.ok(from >= 0);
const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.unshift(card);
const blowSelection = { position: 0, from, card: structuredClone(card) };
const followingSpiceOrder = advanced ? [selectLand(game, 1, parent.territory)] : [];
reach(state => state.phase === 1 && discoveryNoFieldEntryClean(state) && !state.spiceWindow && !state.spiceSequence && !state.spiceResolution);
const placement = transition(() => {
assert.deepEqual(game.ready, []);
for (const player of game.players.slice(0, -1)) act(player.id, { type: 'ready' });
act(game.players.at(-1)!.id, { type: 'ready' }, face);
});
const discovery = game.discoveries!.tokens.find(token => token.face === face && token.status === 'placed'); assert.ok(discovery);
assert.equal(discovery.territory, parent.territory); assert.equal(discovery.sector, parent.sector);
const discoveryToken = discovery.id;
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && discoveryNoFieldEntryClean(state));
let markerShipment: { before: Game; steps: DiscoveryNoFieldEntryStep[]; after: Game } | null = null;
let physicalShipment: { before: Game; steps: DiscoveryNoFieldEntryStep[]; after: Game } | null = null;
let markerToken = '';
const shipMarker = () => {
const own = discoveryNoFieldEntryPlayer(game, owner), selected = own.noField!.tokens.find(token => token.value === value);
assert.ok(selected && own.noFieldEvent); markerToken = selected.id;
markerShipment = transition(() => {
act(owner, { type: 'ship', noField: markerToken, event: own.noFieldEvent, territory: parent.territory, sector: parent.sector });
reach(discoveryNoFieldEntryClean);
});
assert.equal(discoveryNoFieldEntryPlayer(markerShipment.before, owner).spice - discoveryNoFieldEntryPlayer(game, owner).spice, 2);
assert.equal(discoveryNoFieldEntryPlayer(markerShipment.before, owner).reserves, discoveryNoFieldEntryPlayer(game, owner).reserves);
};
let secondSpiceOrder: ReturnType<typeof selectLand>[] = [];
if (companions) {
physicalShipment = transition(() => {
act(owner, { type: 'ship', territory: parent.territory, sector: parent.sector, amount: 1, elite: 0, allyPayment: 0 });
reach(discoveryNoFieldEntryClean);
});
reach(state => state.turn === 2 && state.phase === 0 && discoveryNoFieldEntryClean(state));
secondSpiceOrder = [selectLand(game, 0, parent.territory), ...(advanced ? [selectLand(game, 1, parent.territory)] : [])];
reach(state => state.turn === 2 && state.phase === 5 && state.active === owner && discoveryNoFieldEntryClean(state));
}
shipMarker(); assert.ok(markerShipment);
const shipment = markerShipment as { before: Game; steps: DiscoveryNoFieldEntryStep[]; after: Game };
reach(state => state.phase === 7 && discoveryNoFieldEntryClean(state));
const beforeReveal = structuredClone(game), beforeRevealActions = structuredClone(actions);
assert.ok(viewGame(game, owner).discoveries!.canInspect.includes(discoveryToken));
const revelation = transition(() => {
act(owner, { type: 'discovery', token: discoveryToken, reveal: false });
assert.ok(viewGame(game, owner).discoveries!.canReveal.includes(discoveryToken));
act(owner, { type: 'discovery', token: discoveryToken, reveal: true });
});
const revealTurn = game.turn;
reach(state => state.turn === revealTurn + 1 && state.phase === 0 &&
state.decision?.kind === 'discoveryEntry' && state.decision.player === owner && !state.response && !state.phaseOpening);
const own = discoveryNoFieldEntryPlayer(game, owner), noFieldEvent = own.noFieldEvent; assert.ok(noFieldEvent);
assert.equal(noFieldEvent, discoveryNoFieldEntryPlayer(shipment.after, owner).noFieldEvent);
assert.equal(own.noField!.deployed!.tokenId, markerToken);
assert.deepEqual(own.noField!.deployed!.location, { territory: parent.territory, sector: parent.sector });
const offer = viewGame(game, owner).discoveryEntry; assert.ok(offer && !offer.blocked);
assert.equal(offer.token, discoveryToken); assert.equal(offer.destination, face);
const groups = companions ? [{ source: `${parent.territory}:${parent.sector}`, normal: 1, elite: 0 }] : [];
const action: Action = { type: 'decision', event: offer.event, accept: true, groups,
noField: markerToken, noFieldEvent };
const decline: Action = { type: 'decision', event: offer.event, accept: false };
assert.deepEqual(discoveryNoFieldEntryInventory(game), original);
return { game, sourceGame: structuredClone(game), initial, lobby, setup, afterSetup, advanced, value, companions,
owner, actor: owner, observer, other, face, parent: parent.territory, parentSector: parent.sector,
source: `${parent.territory}:${parent.sector}`, destination: face, discoveryToken, markerToken,
event: offer.event, noFieldEvent, marker: structuredClone(own.noField), offer: structuredClone(offer), action, decline,
placement, markerShipment: shipment, physicalShipment, beforeReveal, beforeRevealActions, revelation,
original, dealOrder, blowSelection, followingSpiceOrder, secondSpiceOrder, setupActions, actions,
sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { setup: setupActions.length, placement: placement.steps.length, markerShipment: shipment.steps.length,
physicalShipment: physicalShipment?.steps.length ?? 0, beforeReveal: beforeRevealActions.length,
revelation: revelation.steps.length, source: actions.length },
sourceDisclosure: `Actual public ${advanced ? 'Advanced preview' : 'Basic'} Richese/Atreides/Emperor +CHOAM deck and public Discovery opt-in before readiness/start. ` +
'No audit initializer or other optional module. Select only original unopened starting/Spice positions and scalar lotteries; the actual first Discovery Hagga Basin draw samples the original supply location at GaraKulon8. ' +
(companions ? 'Richese actually ships physical1parent on turn1, keeps the location hidden through Collection1, then pays2 for its original marker there on turn2. '
: 'Richese actually pays2 for its original marker at the parent on turn1. ') +
'Actual Collection inspection/reveal, Mentat, turn end and next-turn original entry round produce this offer. No hand, purse, army, phase, marker, event, token, reveal receipt or free-entry frame is installed. Counts derive from recorded arrays. Native BG arrival reactions are outside this three-seat source.' };
}
export type DiscoveryNoFieldEntryFixture = ReturnType<typeof createDiscoveryNoFieldEntryFixture>;