Implement Richese Nexus Secret Ally shipment and recovery
This commit is contained in:
1 parent
ae66668e2e
commit
6ba1050a92
21 files changed
+2286
-103
No files matched your search
@@ -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;
|
||||
@@ -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)),
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
Reference in new issue
Block a user