Implement Richese Nexus Secret Ally shipment and recovery

This commit is contained in:
Quintine committed 2026-09-10 13:03:53 +10:00
1 parent ae66668e2e
commit 6ba1050a92
21 files changed
+2286 -103

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeNexusGameForAudit,
joinGame,
newPlayer,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { botActions } from '../game/bots';
import type { FactionId } from '../game/catalog';
import { drawNexusCard } from '../game/nexus-cards';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { nexusTraitorInventory } from './fixture-nexus-traitors';
import {
enterNexusSpice,
finishNexusSpice,
orderNexusSpice,
nexusAllow,
nexusReload,
} from './fixture-nexus-cards';
export type NexusRicheseOwner =
| 'atreides'
| 'emperor'
| 'fremen'
| 'guild'
| 'beneGesserit'
| 'ixians'
| 'harkonnen';
export type NexusRicheseFixtureOptions = {
seatIds?: [string, string, string];
ownerFaction?: NexusRicheseOwner;
opponentFaction?:
| 'guild'
| 'harkonnen'
| 'moritani'
| 'beneGesserit'
| 'ixians';
guild?: boolean;
advanced?: boolean;
homeworlds?: boolean;
spice?: number;
karama?: boolean;
};
export type NexusRicheseFixture = {
g: Game;
owner: string;
target: string;
observer: string;
karama?: string;
};
/** Final roster, module choices and production seat IDs precede genuine setup.
* Conserved reserves are staged only after a real completed Nexus phase. */
export function nexusRicheseFixture(
options: NexusRicheseFixtureOptions = {},
): NexusRicheseFixture {
const [owner, target, observer] = options.seatIds ?? ['p', 'q', 'r'];
const ownerFaction = options.ownerFaction ?? 'atreides';
const opponentFaction =
options.opponentFaction ??
((options.guild && ownerFaction !== 'guild') || ownerFaction === 'harkonnen'
? 'guild'
: 'harkonnen');
assert.notEqual(ownerFaction, opponentFaction);
const observerFaction = (
['fremen', 'atreides', 'emperor'] as FactionId[]
).find((f) => f !== ownerFaction && f !== opponentFaction)!;
let g = createGame(
'RICHESENEXUS',
newPlayer(owner, ownerFaction, ownerFaction),
options.advanced ?? false,
ownerFaction === 'ixians' || opponentFaction === 'ixians' ? ['ix'] : [],
);
// The unfinished E3 public deck gate is separate from native Moritani audit
// setup. Fix the final faction before readiness and genuine initialization.
joinGame(
g,
newPlayer(
target,
opponentFaction,
opponentFaction === 'moritani' ? 'harkonnen' : opponentFaction,
),
);
if (opponentFaction === 'moritani')
g.players[1] = newPlayer(target, opponentFaction, opponentFaction);
joinGame(g, newPlayer(observer, observerFaction, observerFaction));
if (options.homeworlds)
g = applyAction(g, owner, { type: 'homeworlds', enabled: true });
g.nexusCards = { cards: null, phase: null };
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeNexusGameForAudit(g);
for (let step = 0; g.status === 'setup' && step < 100; step++) {
let next: Game | undefined;
for (const p of g.players) {
const v = viewGame(g, p.id);
v.players.find((s) => s.id === p.id)!.bot = 'Easy';
const a = botActions(v)[0];
if (a) {
next = applyAction(g, p.id, a);
break;
}
}
assert.ok(
next,
`Richese Nexus setup stalled at ${g.setupStage}/${g.decision?.kind}`,
);
g = next;
}
assert.equal(g.status, 'playing');
for (const p of g.players) g.deck.push(...p.hand.splice(0));
if (g.expansions.includes('ix')) {
for (let step = 0; g.phase === 0 && step < 60; step++) g = fixtureStep(g);
assert.equal(g.phase, 1);
orderNexusSpice(g, ['land', 'land']);
for (let step = 0; g.phase === 1 && step < 60; step++) g = fixtureStep(g);
assert.equal(g.phase, 2);
} else {
g = enterNexusSpice(g);
orderNexusSpice(g, ['land', 'land']);
g = finishNexusSpice(g);
}
for (const p of g.players) {
const eliteTotal = p.elites
? p.elites.reserves +
p.elites.tanks +
Object.values(p.elites.forces).reduce((s, n) => s + n, 0)
: 0;
Object.assign(p, {
forces: {},
reserves: 20,
tanks: 0,
spice: 20,
shipped: false,
moved: 0,
});
if (p.advisors) p.advisors = {};
if (p.elites)
Object.assign(p.elites, { forces: {}, reserves: eliteTotal, tanks: 0 });
}
const cards = g.nexusCards!.cards!;
cards.deck = ['richese', ...cards.deck.filter((c) => c !== 'richese')];
g.nexusCards!.cards = drawNexusCard(cards, owner, g.players, () => 0);
const karama = options.karama
? holdNexusRicheseCard(g, target, 'karama').id
: undefined;
g.players[0].spice = options.spice ?? 20;
Object.assign(g, {
phase: 5,
active: owner,
order: [owner, target, observer],
movementRemaining: [owner, target, observer],
ready: [],
response: null,
decision: null,
phaseOpening: null,
spice: {},
storm: 18,
hajr: [],
});
nexusRicheseInventory(g);
return { g, owner, target, observer, karama };
}
export function holdNexusRicheseCard(g: Game, owner: string, kind: string) {
const i = g.deck.findIndex((c) => c.kind === kind || c.effect === kind);
assert.ok(i >= 0);
const card = g.deck.splice(i, 1)[0];
g.players.find((p) => p.id === owner)!.hand.push(card);
return card;
}
function fixtureStep(g: Game): Game {
for (const p of g.players) {
const v = viewGame(g, p.id);
v.players.find((s) => s.id === p.id)!.bot = 'Easy';
const action = botActions(v)[0];
if (action) return applyAction(g, p.id, action);
}
throw new Error(
`Richese fixture progression stalled at ${g.phase}/${g.decision?.kind}`,
);
}
export function nexusRicheseRequest(
f: NexusRicheseFixture,
amount = 5,
territory = 'arrakeen',
sector = 10,
g = f.g,
): Action {
return {
type: 'ship',
territory,
sector,
amount,
nexus: viewGame(g, f.owner).nexusRichese!.event,
};
}
export function nexusRicheseInventory(g: Game) {
nexusTraitorInventory(g);
if (g.homeworlds) homeworldGameIntegrity(g);
}
export const nexusRicheseAllow = nexusAllow;
export const nexusRicheseReload = nexusReload;
+76
View File
@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { botHomeworldShipmentPaymentAllowed } from '../game/homeworld-payment-options';
import {
nexusRicheseFixture,
nexusRicheseAllow,
nexusRicheseInventory,
} from './fixture-nexus-richese';
void test('all profiles generate an affordable five-force Richese shipment when ordinary five-force shipping is unaffordable', () => {
for (const advanced of [false, true])
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const f = nexusRicheseFixture({ advanced, spice: 1 });
const v = viewGame(f.g, f.owner);
v.players.find((p) => p.id === f.owner)!.bot = profile;
const before = structuredClone(v);
const action = botActions(v).find(
(a) =>
a.type === 'ship' &&
a.nexus === v.nexusRichese!.event &&
a.amount === 5,
);
assert.ok(action, profile);
assert.ok(botHomeworldShipmentPaymentAllowed(v, action));
const next = nexusRicheseAllow(applyAction(f.g, f.owner, action));
const me = next.players.find((p) => p.id === f.owner)!;
assert.equal(me.reserves, 15);
assert.equal(me.spice, 0);
assert.equal(me.shipped, true);
nexusRicheseInventory(next);
assert.deepEqual(v, before);
}
});
void test('Homeworld payment filter quotes the Nexus source and bots preserve ordinary fallback without wasting Fremen free shipping', () => {
const f = nexusRicheseFixture({
advanced: true,
homeworlds: true,
guild: true,
spice: 1,
});
const v = viewGame(f.g, f.owner);
v.players[0].bot = 'Hard';
// Projection-only low-Junction seam exercises the payment consumer; engine custody stays untouched.
const junction = v.homeworlds!.worlds!.find((w) => w.card === 'junction')!;
junction.side = 'low';
const action = {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 5,
nexus: v.nexusRichese!.event,
};
assert.equal(botHomeworldShipmentPaymentAllowed(v, action), true);
assert.equal(
botHomeworldShipmentPaymentAllowed(v, { ...action, nexus: 'stale' }),
false,
);
assert.equal(
botHomeworldShipmentPaymentAllowed(v, { ...action, amount: 6 }),
false,
);
v.nexusRichese!.blocked = 'Unavailable';
assert.ok(botActions(v).every((a) => a.nexus === undefined));
const fremen = nexusRicheseFixture({ ownerFaction: 'fremen', spice: 0 });
const fv = viewGame(fremen.g, fremen.owner);
fv.players[0].bot = 'Hard';
const ordinary = botActions(fv).find((a) => a.type === 'ship');
assert.ok(ordinary);
assert.equal(ordinary.nexus, undefined);
nexusRicheseInventory(
nexusRicheseAllow(applyAction(fremen.g, fremen.owner, ordinary)),
);
});
+240
View File
@@ -0,0 +1,240 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type GameView } from '../game/engine';
import { MOBILE_STRONGHOLD, gameTerritories } from '../game/board';
import { ShipmentQuote } from '../components/shipment-quote';
import {
nexusRicheseAction,
nexusRicheseCanAct,
nexusRicheseQuote,
} from '../game/nexus-richese-options';
import {
nexusRicheseFixture,
nexusRicheseInventory,
nexusRicheseAllow,
} from './fixture-nexus-richese';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(
specifier === 'next/image'
? 'vinext/shims/image'
: specifier === 'next/link'
? 'vinext/shims/link'
: specifier,
context,
);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries(
[...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((m) => [m[1], m[1]]),
);
return {
format: 'module',
source: `export default ${JSON.stringify(classes)}`,
shortCircuit: true,
};
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
const html = (game: GameView, busy = false) =>
renderToStaticMarkup(
createElement(GameTable, {
game,
busy,
send: async () => {},
onExit() {},
}),
);
const shipment = {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 5,
elite: 0,
};
void test('actual Basic and Advanced shipment panel defaults to ordinary shipping; authorized five-force quote and action preserve physical custody', () => {
for (const advanced of [false, true]) {
const f = nexusRicheseFixture({ advanced, spice: 1 });
const v = viewGame(f.g, f.owner),
before = structuredClone(v);
const markup = html(v);
assert.match(markup, /Use Richese Nexus Secret Ally/);
const checkbox = markup.match(
/<input[^>]*type="checkbox"[^>]*>[\s\S]{0,150}Use Richese Nexus Secret Ally/,
)?.[0];
assert.ok(checkbox);
assert.doesNotMatch(checkbox, /checked=""/);
assert.match(markup, /This uses your ordinary shipment/);
assert.match(
html(v, true).match(
/<input[^>]*type="checkbox"[^>]*>[\s\S]{0,150}Use Richese Nexus Secret Ally/,
)![0],
/disabled=""/,
);
const quote = nexusRicheseQuote(v, 'arrakeen', 5)!;
assert.equal(quote.cost, 1);
assert.equal(quote.physicalAmount, 5);
assert.equal(quote.pricedAmount, 1);
const price = renderToStaticMarkup(
createElement(ShipmentQuote, {
physicalForces: quote.physicalAmount,
quote: {
cost: quote.cost,
normalCost: 5,
ownPayment: 1,
pledgedPayment: 0,
},
funding: { ownSpice: 1, pledgedSpice: 0 },
}),
);
assert.match(price, /Shipment cost: 1 spice/);
assert.match(price, /Normal cost: 5 spice/);
assert.match(price, /Physical forces selected<\/dt><dd[^>]*>5<\/dd>/);
const action = nexusRicheseAction(v, v.nexusRichese!.event, shipment)!;
assert.deepEqual(action, { ...shipment, nexus: v.nexusRichese!.event });
const settled = nexusRicheseAllow(applyAction(f.g, f.owner, action));
const me = settled.players.find((p) => p.id === f.owner)!;
assert.equal(me.reserves, 15);
assert.equal(me.forces['arrakeen:10'], 5);
assert.equal(me.spice, 0);
assert.equal(me.shipped, true);
assert.equal(me.moved, 0);
assert.equal(me.noField, undefined);
nexusRicheseInventory(settled);
assert.deepEqual(v, before);
}
});
void test('shared Richese source fences reject stale, unavailable, oversized and marker routes and keep faction pricing', () => {
const f = nexusRicheseFixture(),
v = viewGame(f.g, f.owner),
event = v.nexusRichese!.event;
assert.equal(nexusRicheseAction(v, 'stale', shipment), null);
for (const change of [
{ amount: 0 },
{ amount: 6 },
{ amount: 1.5 },
{ noField: 'token' },
{ territory: 'homeworld:harkonnen' },
{ type: 'guildShip' },
{ nexus: event },
])
assert.equal(
nexusRicheseAction(v, event, { ...shipment, ...change }),
null,
);
for (const mutate of [
(g: GameView) => {
g.turn++;
},
(g: GameView) => {
g.phase = 6;
},
(g: GameView) => {
g.active = f.target;
},
(g: GameView) => {
g.players[0].shipped = true;
},
(g: GameView) => {
g.players[0].ally = f.target;
},
(g: GameView) => {
g.nexusCards!.card = null;
},
(g: GameView) => {
g.automaticContinuationPending = true;
},
(g: GameView) => {
g.nexusRichese!.blocked = 'Finish the current interaction.';
},
]) {
const changed = structuredClone(v);
mutate(changed);
assert.equal(nexusRicheseAction(changed, event, shipment), null);
}
v.nexusRichese!.blocked = 'Finish the current interaction.';
assert.match(html(v), /Finish the current interaction/);
for (const faction of ['guild', 'fremen'] as const) {
const own = nexusRicheseFixture({ ownerFaction: faction, spice: 1 });
const view = viewGame(own.g, own.owner);
assert.equal(
nexusRicheseQuote(view, 'polar_sink', 5)!.cost,
faction === 'guild' ? 1 : 0,
);
}
});
void test('Richese offer and helper never expose or inspect another player card or balance', () => {
const f = nexusRicheseFixture();
for (const id of [f.target, f.observer]) {
const v = viewGame(f.g, id);
assert.equal(v.nexusRichese, null);
assert.doesNotMatch(html(v), /Use Richese Nexus Secret Ally/);
Object.defineProperty(v, 'nexusRichese', {
get() {
throw new Error('Private offer read');
},
});
assert.equal(nexusRicheseCanAct(v), false);
assert.equal(nexusRicheseAction(v, 'unknown', shipment), null);
}
const v = viewGame(f.g, f.owner);
for (const p of v.players.filter((p) => p.id !== f.owner))
for (const field of ['hand', 'spice', 'traitors'])
Object.defineProperty(p, field, {
get() {
throw new Error('Private opponent read');
},
});
assert.equal(nexusRicheseQuote(v, 'arrakeen', 5)!.cost, 1);
});
void test('Richese price preserves a native Ixian shipment into the present mobile stronghold without granting another faction entry', () => {
for (const advanced of [false, true]) {
const f = nexusRicheseFixture({
ownerFaction: 'ixians',
advanced,
spice: 1,
});
const v = viewGame(f.g, f.owner);
const hms = gameTerritories(v).find((t) => t.id === MOBILE_STRONGHOLD)!;
assert.ok(hms);
const quote = nexusRicheseQuote(v, MOBILE_STRONGHOLD, 5)!;
assert.equal(quote.cost, 1);
const action = nexusRicheseAction(v, v.nexusRichese!.event, {
type: 'ship',
territory: MOBILE_STRONGHOLD,
sector: hms.sectors[0],
amount: 5,
elite: 2,
})!;
assert.ok(action);
const next = nexusRicheseAllow(applyAction(f.g, f.owner, action));
const me = next.players.find((p) => p.id === f.owner)!;
assert.equal(me.reserves, 15);
assert.equal(me.spice, 0);
assert.equal(me.forces[`${MOBILE_STRONGHOLD}:${hms.sectors[0]}`], 5);
assert.equal(
me.elites!.forces[`${MOBILE_STRONGHOLD}:${hms.sectors[0]}`],
2,
);
nexusRicheseInventory(next);
const outsider = nexusRicheseFixture({
opponentFaction: 'ixians',
advanced,
});
const other = viewGame(outsider.g, outsider.owner);
assert.ok(gameTerritories(other).some((t) => t.id === MOBILE_STRONGHOLD));
assert.equal(nexusRicheseQuote(other, MOBILE_STRONGHOLD, 5), null);
}
});
+475
View File
@@ -0,0 +1,475 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { botActions } from '../game/bots';
import { orderNexusSpice } from './fixture-nexus-cards';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import {
nexusRicheseFixture,
nexusRicheseRequest,
nexusRicheseAllow,
nexusRicheseReload,
nexusRicheseInventory,
holdNexusRicheseCard,
} from './fixture-nexus-richese';
function reject(g: Game, id: string, action: Action) {
const before = nexusRicheseReload(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
function stable(g: Game) {
nexusRicheseInventory(g);
for (const p of g.players)
assert.deepEqual(viewGame(nexusRicheseReload(g), p.id), viewGame(g, p.id));
assert.deepEqual(
normalizeAutomaticGame(nexusRicheseReload(g)),
nexusRicheseReload(g),
);
}
void test('Richese Secret Ally ships five actual counters at one-counter cost for ordinary, Guild and Fremen factions', () => {
for (const ownerFaction of ['atreides', 'guild', 'fremen'] as const)
for (const amount of [1, 5]) {
const f = nexusRicheseFixture({ ownerFaction, spice: 2 });
const territory =
ownerFaction === 'fremen' ? 'the_great_flat' : 'red_chasm';
const sector = ownerFaction === 'fremen' ? 15 : 7;
const action = nexusRicheseRequest(f, amount, territory, sector);
const g = nexusRicheseAllow(applyAction(f.g, f.owner, action));
const p = g.players[0];
assert.equal(
p.spice,
ownerFaction === 'fremen' ? 2 : ownerFaction === 'guild' ? 1 : 0,
);
assert.equal(p.reserves, 20 - amount);
assert.equal(p.forces[`${territory}:${sector}`], amount);
assert.equal(p.shipped, true);
assert.equal(p.moved, 0);
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'richese').length,
1,
);
reject(g, f.owner, action);
stable(g);
}
});
void test('Richese declaration rejects excess forces, stale events, foreign seats, wrong ranges and insufficient payment immutably', () => {
const f = nexusRicheseFixture({ spice: 1 });
const action = nexusRicheseRequest(f);
for (const bad of [
{ ...action, amount: 0 },
{ ...action, amount: 6 },
{ ...action, nexus: 'old' },
{ ...action, territory: 'red_chasm', sector: 7 },
{ ...action, territory: 'arrakeen', sector: 9 },
{ ...action, homeworldSources: { unknown: { normal: 5, elite: 0 } } },
])
reject(f.g, f.owner, bad);
reject(f.g, f.target, action);
for (const type of ['guildShip', 'homeworldShip', 'guildHomeworldShip'])
reject(f.g, f.owner, { ...action, type });
const fremen = nexusRicheseFixture({ ownerFaction: 'fremen' });
reject(
fremen.g,
fremen.owner,
nexusRicheseRequest(fremen, 5, 'arrakeen', 10),
);
const storm = nexusRicheseReload(f.g);
storm.storm = 10;
reject(storm, f.owner, action);
});
void test('five typed Emperor forces withdraw from Kaitain and Salusa once while their single-force price stays one', () => {
const f = nexusRicheseFixture({
ownerFaction: 'emperor',
advanced: true,
homeworlds: true,
spice: 1,
});
const action = {
...nexusRicheseRequest(f),
elite: 2,
homeworldSources: {
'homeworld:emperor': { normal: 3, elite: 0 },
'homeworld:emperor:salusa': { normal: 0, elite: 2 },
},
};
const g = applyAction(f.g, f.owner, action);
assert.equal(g.players[0].spice, 0);
assert.equal(g.players[0].forces['arrakeen:10'], 5);
assert.equal(g.players[0].elites!.forces['arrakeen:10'], 2);
assert.equal(g.players[0].reserves, 15);
assert.equal(g.homeworlds!.custody!.salusa!.elite, 3);
stable(g);
});
void test('Guild interception preserves the complete five-force declaration and spends Nexus on either allowance or stop', () => {
for (const stop of [false, true]) {
const f = nexusRicheseFixture({
advanced: true,
guild: true,
karama: true,
spice: 1,
});
const action = nexusRicheseRequest(f);
let g = applyAction(f.g, f.owner, action);
assert.equal(g.decision?.kind, 'guildShipment');
assert.equal(g.pendingShipment!.amount, 5);
assert.equal(g.pendingShipment!.cost, 1);
assert.equal(g.players[0].reserves, 20);
assert.equal(g.players[0].spice, 1);
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
stable(g);
g = applyAction(
nexusRicheseReload(g),
f.target,
stop
? { type: 'card', mode: 'special', card: f.karama }
: { type: 'decision', allow: true },
);
g = nexusRicheseAllow(g);
assert.equal(g.players[0].reserves, stop ? 20 : 15);
assert.equal(g.players[0].spice, stop ? 1 : 0);
assert.equal(g.players[1].spice, stop ? 20 : 21);
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'richese').length,
1,
);
reject(g, f.owner, action);
stable(g);
}
});
void test('independent Karama discounts the one-force price, routes payment to bank and preserves actual force count', () => {
const f = nexusRicheseFixture({ guild: true, spice: 1 });
const card = holdNexusRicheseCard(f.g, f.owner, 'karama');
let g = applyAction(f.g, f.owner, {
type: 'card',
card: card.id,
mode: 'shipment',
target: f.owner,
});
g = applyAction(g, f.owner, nexusRicheseRequest(f, 5, 'red_chasm', 7, g));
assert.equal(g.players[0].spice, 0);
assert.equal(g.players[0].forces['red_chasm:7'], 5);
assert.equal(g.players[1].spice, 20);
assert.equal(g.response, null);
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
stable(g);
});
void test('the real shipment opens BG accompaniment with all five arrivals and leaves normal movement available afterward', () => {
const f = nexusRicheseFixture({
advanced: true,
opponentFaction: 'beneGesserit',
});
let g = applyAction(f.g, f.owner, nexusRicheseRequest(f));
assert.equal(g.decision?.kind, 'advisor');
assert.equal(g.players[0].forces['arrakeen:10'], 5);
g = applyAction(nexusRicheseReload(g), f.target, {
type: 'decision',
accept: true,
accompany: true,
});
g = nexusRicheseAllow(g);
assert.equal(g.players[1].forces['arrakeen:10'], 1);
assert.equal(g.players[1].reserves, 19);
assert.equal(g.players[0].moved, 0);
assert.equal(g.active, f.owner);
stable(g);
});
void test('Richese held-card availability and original shipment evidence remain private across other seats and reload', () => {
const f = nexusRicheseFixture({ advanced: true, guild: true });
for (const id of [f.target, f.observer])
assert.equal(viewGame(f.g, id).nexusRichese, null);
let g = applyAction(f.g, f.owner, nexusRicheseRequest(f));
for (const id of [f.target, f.observer]) {
const view = viewGame(g, id);
assert.equal(Object.hasOwn(view, 'nexusRicheseHistory'), false);
assert.equal(view.nexusRichese, null);
assert.equal(view.decision?.kind, 'guildShipment');
if (view.decision?.kind === 'guildShipment')
assert.equal(view.decision.amount, 5);
}
g = applyAction(nexusRicheseReload(g), f.target, {
type: 'decision',
allow: true,
});
g = nexusRicheseAllow(g);
stable(g);
});
void test('Richese pending source rejects event/history deletion, original decision edits and valid-looking typed reallocations', () => {
const f = nexusRicheseFixture({
ownerFaction: 'emperor',
advanced: true,
homeworlds: true,
guild: true,
});
const pending = applyAction(f.g, f.owner, {
...nexusRicheseRequest(f),
elite: 2,
homeworldSources: {
'homeworld:emperor': { normal: 3, elite: 0 },
'homeworld:emperor:salusa': { normal: 0, elite: 2 },
},
});
for (const edit of [
(g: Game) => {
delete g.pendingShipment!.nexusEvent;
},
(g: Game) => {
delete g.nexusRicheseHistory;
},
(g: Game) => {
delete g.nexusRicheseLast;
},
(g: Game) => {
g.nexusRicheseHistory![0].signature = 'changed';
},
(g: Game) => {
g.nexusRicheseHistory![0].frame = '{}';
},
(g: Game) => {
g.pendingShipment!.elite = 1;
g.pendingShipment!.homeworldSources!['homeworld:emperor'].normal = 4;
g.pendingShipment!.homeworldSources!['homeworld:emperor:salusa'].elite =
1;
},
(g: Game) => {
if (g.decision?.kind === 'guildShipment') g.decision.amount = 4;
},
(g: Game) => {
if (g.decision?.kind === 'guildShipment') g.decision.shipper = f.observer;
},
(g: Game) => {
g.nexusRicheseLast!.stage = 'shipped';
},
]) {
const bad = nexusRicheseReload(pending);
edit(bad);
const before = nexusRicheseReload(bad);
for (const p of bad.players) assert.throws(() => viewGame(bad, p.id));
assert.throws(() => normalizeAutomaticGame(bad));
assert.throws(() =>
applyAction(bad, f.target, { type: 'decision', allow: true }),
);
assert.deepEqual(bad, before);
}
});
void test('a genuine Truthtrance interruption preserves Richese pending shipment, paid quote and Guild interception', () => {
const f = nexusRicheseFixture({ advanced: true, guild: true, spice: 1 });
const truth = holdNexusRicheseCard(f.g, f.observer, 'truthtrance');
let g = applyAction(f.g, f.owner, nexusRicheseRequest(f));
const shipment = structuredClone(g.pendingShipment),
decision = structuredClone(g.decision),
history = structuredClone(g.nexusRicheseHistory);
g = applyAction(g, f.observer, { type: 'card', card: truth.id });
while (g.truthtrance?.stage === 'priority')
g = applyAction(
g,
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
{ type: 'truthPass' },
);
g = applyAction(g, f.observer, {
type: 'truthAsk',
question: {
kind: 'fact',
target: f.owner,
fact: { kind: 'hand', name: 'Shield' },
},
});
g = applyAction(nexusRicheseReload(g), f.owner, {
type: 'truthAnswer',
answer: 'no',
});
assert.deepEqual(g.pendingShipment, shipment);
assert.deepEqual(g.decision, decision);
assert.deepEqual(g.nexusRicheseHistory, history);
g = applyAction(g, f.target, { type: 'decision', allow: true });
g = nexusRicheseAllow(g);
assert.equal(g.players[0].reserves, 15);
assert.equal(g.players[0].spice, 0);
assert.equal(g.discard.filter((c) => c.id === truth.id).length, 1);
stable(g);
});
void test('completed Richese history survives ordinary movement, genuine next-turn phases and a later full-price shipment', () => {
const f = nexusRicheseFixture();
let g = applyAction(f.g, f.owner, nexusRicheseRequest(f));
const history = structuredClone(g.nexusRicheseHistory);
g = applyAction(g, f.owner, {
type: 'move',
from: 'arrakeen:10',
territory: 'hagga_basin',
sector: 12,
amount: 5,
});
assert.equal(g.players[0].forces['hagga_basin:12'], 5);
while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
let ordered = false;
for (
let step = 0;
!(
g.turn === 2 &&
g.phase === 5 &&
!g.response &&
!g.decision &&
!g.phaseOpening
) && step < 180;
step++
) {
if (g.turn === 2 && g.phase === 1 && !ordered) {
orderNexusSpice(g, ['land', 'land']);
ordered = true;
}
let next: Game | undefined;
for (const p of g.players) {
const v = viewGame(g, p.id);
v.players.find((s) => s.id === p.id)!.bot = 'Easy';
const action = botActions(v)[0];
if (action) {
next = applyAction(g, p.id, action);
break;
}
}
assert.ok(
next,
`Next turn stalled at ${g.turn}/${g.phase}/${g.decision?.kind}`,
);
g = next;
}
assert.equal(g.turn, 2);
assert.equal(g.phase, 5);
while (g.active !== f.owner)
g = applyAction(g, g.active!, { type: 'endMovement' });
assert.deepEqual(g.nexusRicheseHistory, history);
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
const before = g.players[0].spice,
reserves = g.players[0].reserves;
g = applyAction(g, f.owner, {
type: 'ship',
territory: 'red_chasm',
sector: 7,
amount: 1,
});
assert.equal(g.players[0].spice, before - 2);
assert.equal(g.players[0].reserves, reserves - 1);
assert.deepEqual(g.nexusRicheseHistory, history);
stable(g);
});
void test('native Ixians may ship five typed counters into their real mobile stronghold but another faction cannot', () => {
for (const advanced of [false, true]) {
const f = nexusRicheseFixture({
ownerFaction: 'ixians',
advanced,
spice: 1,
});
assert.ok(f.g.mobileStronghold?.location);
const g = applyAction(f.g, f.owner, {
...nexusRicheseRequest(f, 5, 'hidden_mobile_stronghold', 0),
elite: 2,
});
assert.equal(g.players[0].forces['hidden_mobile_stronghold:0'], 5);
assert.equal(g.players[0].elites!.forces['hidden_mobile_stronghold:0'], 2);
assert.equal(g.players[0].spice, 0);
stable(g);
}
const other = nexusRicheseFixture({ opponentFaction: 'ixians' });
assert.ok(other.g.mobileStronghold?.location);
reject(
other.g,
other.owner,
nexusRicheseRequest(other, 5, 'hidden_mobile_stronghold', 0),
);
});
void test('a genuinely placed Terror token sees five arriving Richese-discounted counters under low Grumman', () => {
const f = nexusRicheseFixture({
opponentFaction: 'moritani',
homeworlds: true,
});
let g = f.g;
g.players[1].reserves = 7;
g.players[1].forces = { 'polar_sink:0': 13 };
while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
assert.equal(g.phase, 7);
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'moritaniPlacement');
const token = g.moritaniTerror!.tokens.find((t) => t.kind === 'robbery')!;
g = nexusRicheseAllow(
applyAction(g, f.target, {
type: 'decision',
token: token.id,
territory: 'arrakeen',
}),
);
let ordered = false;
for (
let step = 0;
!(
g.turn === 2 &&
g.phase === 5 &&
!g.response &&
!g.decision &&
!g.phaseOpening
) && step < 180;
step++
) {
if (g.turn === 2 && g.phase === 1 && !ordered) {
orderNexusSpice(g, ['land', 'land']);
ordered = true;
}
let next: Game | undefined;
for (const p of g.players) {
const v = viewGame(g, p.id);
v.players.find((s) => s.id === p.id)!.bot = 'Easy';
const action = botActions(v)[0];
if (action) {
next = applyAction(g, p.id, action);
break;
}
}
assert.ok(
next,
`Terror progression stalled at ${g.turn}/${g.phase}/${g.decision?.kind}`,
);
g = next;
}
assert.equal(g.turn, 2);
assert.equal(g.phase, 5);
while (g.active !== f.owner)
g = applyAction(g, g.active!, { type: 'endMovement' });
assert.equal(g.players[1].reserves, 7);
const spice = g.players[0].spice;
g = applyAction(g, f.owner, nexusRicheseRequest(f, 5, 'arrakeen', 10, g));
assert.equal(g.players[0].spice, spice - 1);
assert.equal(g.pendingTerrorEntry?.amount, 5);
assert.equal(g.players[0].forces['arrakeen:10'], 5);
const beforeRobbery = g.players[0].spice;
g = applyAction(nexusRicheseReload(g), f.target, {
type: 'decision',
reveal: true,
});
g = applyAction(g, f.target, { type: 'decision', choice: 'spice' });
assert.equal(
g.players[0].spice,
beforeRobbery - Math.ceil(beforeRobbery / 2),
);
assert.equal(
g.moritaniTerror!.tokens.find((t) => t.id === token.id)!.status,
'removed',
);
stable(g);
});
+33
View File
@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { nexusRicheseFixture, holdNexusRicheseCard, nexusRicheseRequest, nexusRicheseInventory } from './fixture-nexus-richese';
void test('a binding five-force Yes uses the held Richese discount as a private completion witness', () => {
const f = nexusRicheseFixture({spice:1});
const card = holdNexusRicheseCard(f.g,f.target,'truthtrance');
let g = applyAction(f.g,f.target,{type:'card',card:card.id});
while (g.truthtrance?.stage === 'priority') {
const seat = g.players.find(p => !g.truthtrance!.passed.includes(p.id))!;
g = applyAction(g,seat.id,{type:'truthPass'});
}
g = applyAction(g,f.target,{type:'truthAsk',question:{kind:'shipment',target:f.owner,territory:'arrakeen',minimum:5}});
assert.deepEqual(viewGame(g,f.owner).truthShipmentAnswers,['yes','no']);
g = applyAction(g,f.owner,{type:'truthAnswer',answer:'yes'});
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
const v = viewGame(g,f.owner);
v.players.find(p => p.id === f.owner)!.bot = profile;
const action = botActions(v)[0];
assert.equal(action.type,'ship');
assert.equal(action.amount,5);
assert.equal(action.nexus,JSON.stringify(['nexusRichese',g.turn,f.owner]));
const done = applyAction(g,f.owner,action);
assert.equal(done.players[0].reserves,15);
assert.equal(done.players[0].spice,0);
assert.equal(done.shipmentPromises?.[0].fulfilled,true);
nexusRicheseInventory(done);
}
assert.throws(() => applyAction(g,f.owner,{type:'endMovement'}));
assert.throws(() => applyAction(g,f.owner,nexusRicheseRequest(f,4,'arrakeen',10,g)));
});
+345
View File
@@ -0,0 +1,345 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { unitStore } from './fixture-nexus-room-store';
import {
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import {
nexusRicheseFixture,
nexusRicheseRequest,
nexusRicheseInventory,
type NexusRicheseFixtureOptions,
} from './fixture-nexus-richese';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
async function fixture(options: NexusRicheseFixtureOptions = {}) {
const store = unitStore();
const made = await store.rooms.createRoom(
'Richese SQL',
options.ownerFaction ?? 'atreides',
options.advanced ?? true,
[],
);
const code = made.view.code,
tokens = [made.token];
for (const faction of ['guild', 'fremen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
);
const ids = auths.map((a) => a.playerId) as [string, string, string];
const f = nexusRicheseFixture({
guild: true,
advanced: true,
karama: true,
...options,
seatIds: ids,
});
f.g.code = code;
f.g.version = (await store.rooms.readRoom(code)).version;
const save = (g: Game) =>
store.sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code);
save(f.g);
store.writes.length = 0;
return { ...store, ...f, code, tokens, auths, ids, save };
}
type Fixture = Awaited<ReturnType<typeof fixture>>;
const row = (f: Fixture) =>
f.sqlite
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.get(f.code);
async function act(f: Fixture, seat: number, action: Action) {
const g = await f.restart().readRoom(f.code);
await f.restart().act(f.code, f.auths[seat], g.version, action, clock);
return f.restart().readRoom(f.code);
}
async function restored(f: Fixture, g: Game) {
const before = row(f),
rooms = f.restart();
for (const [i, token] of f.tokens.entries()) {
const auth = await rooms.authenticate(f.code, token);
const v = await rooms.readSeatView(f.code, auth);
assert.deepEqual(v, viewGame(g, f.ids[i]));
assert.equal(Object.hasOwn(v, 'nexusRicheseHistory'), false);
if (i !== 0) assert.equal(v.nexusRichese, null);
}
await rooms.continueRoomAutomatic(f.code, clock);
assert.deepEqual(row(f), before);
nexusRicheseInventory(g);
}
async function compete(f: Fixture, version: number, actions: Action[]) {
let arrivals = 0,
release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
const timer = setTimeout(release, 2000);
const results = await Promise.allSettled(
actions.map((action) =>
f.restart().act(f.code, f.auths[0], version, action, clock),
),
);
clearTimeout(timer);
delete f.hooks.beforeWrite;
assert.equal(arrivals, 2);
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
assert.deepEqual(
f.writes.map((w) => w.changes).sort((a, b) => a - b),
[0, 1],
);
}
void test('SQLite competing Richese declarations spend one Nexus and restore one actual pending shipment after a lost acknowledgment', async () => {
const f = await fixture({ spice: 1 });
try {
const unrelated = await f.rooms.createRoom(
'Unrelated saved table',
'atreides',
false,
[],
);
const otherBefore = f.sqlite
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.get(unrelated.view.code);
await compete(f, f.g.version, [
nexusRicheseRequest(f, 5),
nexusRicheseRequest(f, 4),
]);
const pending = await f.restart().readRoom(f.code),
amount = pending.pendingShipment!.amount;
assert.ok(amount === 4 || amount === 5);
assert.equal(pending.pendingShipment!.cost, 1);
assert.equal(pending.players[0].reserves, 20);
assert.equal(pending.players[0].spice, 1);
assert.equal(
pending.nexusCards!.cards!.discard.filter((c) => c === 'richese').length,
1,
);
await restored(f, pending);
const done = await act(f, 1, { type: 'decision', allow: true });
assert.equal(done.players[0].reserves, 20 - amount);
assert.equal(done.players[0].forces['arrakeen:10'], amount);
assert.equal(done.players[0].spice, 0);
assert.equal(done.players[1].spice, 21);
await restored(f, done);
const before = row(f);
await assert.rejects(
f
.restart()
.act(f.code, f.auths[0], done.version, nexusRicheseRequest(f), clock),
);
assert.deepEqual(row(f), before);
assert.deepEqual(
f.sqlite
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.get(unrelated.view.code),
otherBefore,
);
} finally {
f.sqlite.close();
}
});
void test('SQLite Guild special stop preserves physical reserves and payment while keeping Richese Nexus spent across reload', async () => {
const f = await fixture({ spice: 1 });
try {
await act(f, 0, nexusRicheseRequest(f));
const stopped = await act(f, 1, {
type: 'card',
mode: 'special',
card: f.karama,
});
assert.equal(stopped.players[0].reserves, 20);
assert.equal(stopped.players[0].spice, 1);
assert.equal(stopped.players[0].forces['arrakeen:10'], undefined);
assert.equal(stopped.nexusCards!.cards!.hands[f.owner], null);
assert.equal(stopped.discard.filter((c) => c.id === f.karama).length, 1);
await restored(f, stopped);
const before = row(f);
await assert.rejects(
f
.restart()
.act(
f.code,
f.auths[1],
stopped.version,
{ type: 'card', mode: 'special', card: f.karama },
clock,
),
);
assert.deepEqual(row(f), before);
} finally {
f.sqlite.close();
}
});
void test('SQLite rejects changed Richese paid intent and missing saved source without projection, normalization or action writes', async () => {
const f = await fixture({ spice: 1 });
try {
const pending = await act(f, 0, nexusRicheseRequest(f));
for (const edit of [
(g: Game) => {
delete g.nexusRicheseHistory;
},
(g: Game) => {
delete g.pendingShipment!.nexusEvent;
},
(g: Game) => {
delete g.nexusRicheseLast;
},
(g: Game) => {
g.nexusRicheseHistory![0].signature = 'changed';
},
(g: Game) => {
if (g.decision?.kind === 'guildShipment') g.decision.amount = 4;
},
(g: Game) => {
if (g.decision?.kind === 'guildShipment')
g.decision.player = f.observer;
},
(g: Game) => {
g.pendingShipment!.amount = 4;
},
(g: Game) => {
g.pendingShipment!.cost = 0;
},
(g: Game) => {
g.pendingShipment!.territory = 'carthag';
g.pendingShipment!.sector = 11;
},
(g: Game) => {
g.decision = null;
},
]) {
const bad = structuredClone(pending);
edit(bad);
f.save(bad);
f.writes.length = 0;
const before = row(f),
rooms = f.restart();
for (const auth of f.auths)
await assert.rejects(rooms.readSeatView(f.code, auth));
assert.throws(() => normalizeAutomaticGame(bad));
// A waiting human shipment does not require automatic room recovery; the
// room adapter may return before dispatching the rejecting normalizer.
await Promise.allSettled([rooms.continueRoomAutomatic(f.code, clock)]);
await assert.rejects(
rooms.act(
f.code,
f.auths[1],
bad.version,
{ type: 'decision', allow: true },
clock,
),
);
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, 0);
}
} finally {
f.sqlite.close();
}
});
void test('SQLite typed Kaitain and Salusa shipment settles exactly once after a saved Guild interception', async () => {
const f = await fixture({
ownerFaction: 'emperor',
homeworlds: true,
spice: 1,
});
try {
const request = {
...nexusRicheseRequest(f),
elite: 2,
homeworldSources: {
'homeworld:emperor': { normal: 3, elite: 0 },
'homeworld:emperor:salusa': { normal: 0, elite: 2 },
},
};
const pending = await act(f, 0, request);
await restored(f, pending);
const changed = structuredClone(pending);
changed.pendingShipment!.elite = 1;
changed.pendingShipment!.homeworldSources!['homeworld:emperor'].normal = 4;
changed.pendingShipment!.homeworldSources![
'homeworld:emperor:salusa'
].elite = 1;
f.save(changed);
f.writes.length = 0;
const corruptRow = row(f);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, auth));
await assert.rejects(
f
.restart()
.act(
f.code,
f.auths[1],
changed.version,
{ type: 'decision', allow: true },
clock,
),
);
assert.deepEqual(row(f), corruptRow);
assert.equal(f.writes.length, 0);
f.save(pending);
const done = await act(f, 1, { type: 'decision', allow: true });
assert.equal(done.players[0].reserves, 15);
assert.equal(done.players[0].elites!.forces['arrakeen:10'], 2);
assert.equal(done.homeworlds!.custody!.salusa!.elite, 3);
assert.equal(done.players[0].spice, 0);
await restored(f, done);
assert.equal(
done.nexusCards!.cards!.discard.filter((c) => c === 'richese').length,
1,
);
} finally {
f.sqlite.close();
}
});
void test('SQLite restores a committed five-force shipment awaiting BG accompaniment without replaying its payment or arrival', async () => {
const f = await fixture({ opponentFaction: 'beneGesserit', spice: 1 });
try {
const arrived = await act(f, 0, nexusRicheseRequest(f));
assert.equal(arrived.decision?.kind, 'advisor');
assert.equal(arrived.players[0].spice, 0);
assert.equal(arrived.players[0].forces['arrakeen:10'], 5);
await restored(f, arrived);
const done = await act(f, 1, {
type: 'decision',
accept: true,
accompany: true,
});
assert.equal(done.players[0].reserves, 15);
assert.equal(done.players[0].forces['arrakeen:10'], 5);
assert.equal(done.players[0].spice, 0);
assert.equal(done.players[1].forces['arrakeen:10'], 1);
assert.equal(done.players[1].reserves, 19);
await restored(f, done);
const before = row(f);
await assert.rejects(
f
.restart()
.act(
f.code,
f.auths[1],
done.version,
{ type: 'decision', accept: true },
clock,
),
);
assert.deepEqual(row(f), before);
} finally {
f.sqlite.close();
}
});
+267
View File
@@ -0,0 +1,267 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { FACTIONS, type FactionId } from '../game/catalog';
import {
quoteNexusRicheseShipment,
createNexusRichese,
validateNexusRichese,
type NexusRicheseContext,
type NexusRicheseReceipt,
} from '../game/nexus-richese';
const context: NexusRicheseContext = {
turn: 3,
players: [
{ id: 'p', faction: 'atreides' },
{ id: 'q', faction: 'guild' },
{ id: 'r', faction: 'fremen' },
],
};
const clone = <T>(x: T): T => JSON.parse(JSON.stringify(x));
void test('Richese Secret Ally prices one while retaining one through five actual physical forces for every supported faction and terrain', () => {
for (const faction of FACTIONS.map((f) => f.id).filter(
(f) => 'richese' !== f,
))
for (const type of ['stronghold', 'sand', 'rock', 'polar'])
for (let amount = 1; amount <= 5; amount++) {
const player = { faction, halfRate: faction === 'guild' },
before = clone(player);
const q = quoteNexusRicheseShipment(player, type, amount);
assert.deepEqual(q, {
physicalAmount: amount,
pricedAmount: 1,
cost:
faction === 'fremen'
? 0
: faction === 'guild' || type === 'stronghold'
? 1
: 2,
source: 'nexusRichese',
});
assert.deepEqual(player, before);
}
});
void test('an existing Karama half rate uses the one-force basis; suppressed Guild half rate and native Fremen free transport remain distinct', () => {
assert.equal(
quoteNexusRicheseShipment(
{ faction: 'emperor', halfRate: false },
'sand',
5,
).cost,
2,
);
assert.equal(
quoteNexusRicheseShipment({ faction: 'emperor', halfRate: true }, 'sand', 5)
.cost,
1,
);
assert.equal(
quoteNexusRicheseShipment({ faction: 'guild', halfRate: false }, 'rock', 5)
.cost,
2,
);
assert.equal(
quoteNexusRicheseShipment(
{ faction: 'guild', halfRate: true },
'stronghold',
5,
).cost,
1,
);
for (const halfRate of [false, true])
assert.equal(
quoteNexusRicheseShipment({ faction: 'fremen', halfRate }, 'sand', 5)
.cost,
0,
);
});
void test('quote refuses empty, fractional, excess or malformed physical groups and non-Arrakis price domains without mutating inputs', () => {
const player = Object.freeze({
faction: 'emperor' as const,
halfRate: false,
});
for (const amount of [
0,
-1,
6,
1.5,
NaN,
Infinity,
Number.MAX_SAFE_INTEGER,
'3',
null,
undefined,
])
assert.throws(() =>
quoteNexusRicheseShipment(player, 'sand', amount as number),
);
for (const type of ['homeworld', 'reserves', 'unknown', ''])
assert.throws(() => quoteNexusRicheseShipment(player, type, 3));
assert.throws(() =>
quoteNexusRicheseShipment(
{ faction: 'richese', halfRate: false },
'sand',
3,
),
);
assert.throws(() =>
quoteNexusRicheseShipment(
{ faction: 'unknown' as FactionId, halfRate: false },
'sand',
3,
),
);
assert.throws(() =>
quoteNexusRicheseShipment(
{ faction: 'emperor', halfRate: 'yes' as unknown as boolean },
'sand',
3,
),
);
});
void test('Richese receipt is bound to its real absent-faction owner and turn while canonicalizing public seat order', () => {
for (const p of context.players) {
const r = createNexusRichese(context, p.id);
assert.equal(r.event, JSON.stringify(['nexusRichese', 3, p.id]));
assert.equal(r.phase, 5);
assert.equal(r.mode, 'secretAlly');
validateNexusRichese(context, clone(r));
assert.deepEqual(
createNexusRichese(
{ ...context, players: [...context.players].reverse() },
p.id,
),
r,
);
assert.notEqual(
createNexusRichese({ ...context, turn: 4 }, p.id).event,
r.event,
);
}
const copied = clone(context),
r = createNexusRichese(copied, 'p');
copied.players[0].id = 'changed';
assert.equal(r.roster[0].id, 'p');
assert.throws(() => createNexusRichese(context, 'foreign'));
assert.throws(() =>
createNexusRichese(
{
...context,
players: [...context.players, { id: 's', faction: 'richese' }],
},
'p',
),
);
});
void test('historical Richese receipt survives later turns and alliances without reading hands, funds, No-Field values or other secrets', () => {
const r = createNexusRichese(context, 'p'),
before = clone(r);
const fail = () => {
throw new Error('Private field read');
};
const players = context.players.map((p) =>
Object.defineProperties(
{ ...p },
{
hand: { enumerable: true, get: fail },
spice: { enumerable: true, get: fail },
ally: { enumerable: true, get: fail },
noField: { enumerable: true, get: fail },
traitors: { enumerable: true, get: fail },
},
),
);
const later = Object.defineProperties(
{ turn: 5, players },
{ nexusCards: { get: fail }, response: { get: fail } },
);
validateNexusRichese(later, r);
createNexusRichese(later, 'q');
assert.deepEqual(r, before);
const pricePlayer = Object.defineProperties(
{ faction: 'fremen' as const, halfRate: false },
{ spice: { get: fail }, reserves: { get: fail }, noField: { get: fail } },
);
assert.equal(
quoteNexusRicheseShipment(pricePlayer, 'sand', 5).physicalAmount,
5,
);
});
void test('Richese historical validation rejects changed evidence, deleted fields and malformed roster without rewriting it', () => {
const original = createNexusRichese(context, 'p');
const edits: ((r: NexusRicheseReceipt) => void)[] = [
(r) => {
r.owner = 'q';
},
(r) => {
r.turn++;
},
(r) => {
r.event = 'old';
},
(r) => {
r.phase = 4 as 5;
},
(r) => {
r.mode = 'cunning' as 'secretAlly';
},
(r) => {
r.signature = 'changed';
},
(r) => {
r.roster.reverse();
},
(r) => {
r.roster[0].faction = 'emperor';
},
(r) => {
r.roster[0].id = 'other';
},
(r) => {
r.roster.pop();
},
(r) => {
Object.assign(r, { hiddenHand: [] });
},
(r) => {
Reflect.deleteProperty(r, 'version');
},
(r) => {
Reflect.deleteProperty(r, 'signature');
},
(r) => {
Object.assign(r.roster[0], { extra: true });
},
];
for (const edit of edits) {
const r = clone(original);
edit(r);
const before = clone(r);
assert.throws(() => validateNexusRichese(context, r));
assert.deepEqual(r, before);
}
assert.throws(() => validateNexusRichese({ ...context, turn: 2 }, original));
for (const id of ['__proto__', 'prototype', 'constructor', ''])
assert.throws(() =>
createNexusRichese({ turn: 1, players: [{ id, faction: 'guild' }] }, id),
);
for (const invalid of [
{ ...context, turn: 0 },
{ ...context, turn: 1.5 },
{ ...context, players: [] },
{ ...context, players: [context.players[0], context.players[0]] },
{
...context,
players: [
{ id: 'p', faction: 'atreides' },
{ id: 'q', faction: 'atreides' },
],
},
] as NexusRicheseContext[])
assert.throws(() => createNexusRichese(invalid, 'p'));
const proto = Object.assign(
Object.create({ extra: true }) as NexusRicheseReceipt,
original,
);
assert.throws(() => validateNexusRichese(context, proto));
});