Allow concealed No-Fields in free Discovery entry
This commit is contained in:
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);
|
||||
});
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user