Integrate exact Ixian Nexus purchased-card replacement

This commit is contained in:
Quintine committed 2026-10-01 17:12:46 +10:00
1 parent c43ab64872
commit a2b04dbfba
26 files changed
+4588 -34

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import type { GameView } from '../game/engine';
import { botActions, runBots } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
import { createIxianNexusReplacementFixture, settleIxianReplacementFixture } from './fixture-nexus-ixian-replacement';
void test('all four profiles replace the authorized purchased worthless card and preserve the funded native sale', () => {
for (const profile of DIFFICULTIES) for (const advanced of [false, true]) for (const karama of [false, true]) {
const fixture = createIxianNexusReplacementFixture({ advanced, karama, purchasedKind: 'worthless' });
const own = viewGame(fixture.game, fixture.buyer);
own.players.find(player => player.id === fixture.buyer)!.bot = profile;
const actions = botActions(own);
assert.equal(actions.length, 1);
assert.equal(actions[0].type, 'nexusIxianReplacementUse');
const result = settleIxianReplacementFixture(applyAction(fixture.game, fixture.buyer, actions[0]));
const kept = settleIxianReplacementFixture(applyAction(fixture.game, fixture.buyer, { type: 'nexusIxianReplacementPass', event: fixture.event }));
assert.equal(viewGame(result, fixture.buyer).nexusIxianReplacement, null);
assert.equal(result.nexusCards?.cards?.hands[fixture.buyer], null);
assert.equal(result.players.find(player => player.id === fixture.buyer)!.hand.length,
fixture.game.players.find(player => player.id === fixture.buyer)!.hand.length);
const nextCard = fixture.nextCard;
assert.ok(nextCard);
assert.ok(result.players.find(player => player.id === fixture.buyer)!.hand.some(card => card.id === nextCard.id));
assert.deepEqual(result.players.map(player => ({ id: player.id, spice: player.spice })),
kept.players.map(player => ({ id: player.id, spice: player.spice })));
assert.equal(result.auction?.index, kept.auction?.index);
assert.equal(result.phase, kept.phase);
}
});
void test('all profiles prioritize neutral wrong-face Pass over native bids and retain both owned cards', () => {
for (const profile of DIFFICULTIES) for (const endingAuction of [false, true]) {
const fixture = createIxianNexusReplacementFixture({ face: 'richese', endingAuction });
const own = viewGame(fixture.game, fixture.buyer);
own.players.find(player => player.id === fixture.buyer)!.bot = profile;
const actions = botActions(own);
assert.equal(actions.length, 1);
assert.equal(actions[0].type, 'nexusIxianReplacementPass');
const result = settleIxianReplacementFixture(applyAction(fixture.game, fixture.buyer, actions[0]));
assert.equal(viewGame(result, fixture.buyer).nexusIxianReplacement, null);
assert.equal(result.nexusCards?.cards?.hands[fixture.buyer], 'richese');
assert.ok(result.players.find(player => player.id === fixture.buyer)!.hand.some(card => card.id === fixture.purchased.id));
if (endingAuction) assert.equal(result.auction, null);
const before = JSON.stringify(result);
assert.throws(() => applyAction(result, fixture.buyer, actions[0]));
assert.equal(JSON.stringify(result), before);
}
});
void test('private eligibility and sole buyer ownership override held-face hints for every profile', () => {
for (const profile of DIFFICULTIES) {
const fixture = createIxianNexusReplacementFixture();
const own = viewGame(fixture.game, fixture.buyer);
own.players.find(player => player.id === fixture.buyer)!.bot = profile;
for (const restriction of ['canUse', 'blocked'] as const) {
const blocked = structuredClone(own);
if (restriction === 'canUse') blocked.nexusIxianReplacement!.canUse = false;
else blocked.nexusIxianReplacement!.blocked = 'The current source is reserved.';
const actions = botActions(blocked);
assert.equal(actions.length, 1);
assert.equal(actions[0].type, 'nexusIxianReplacementPass');
const result = applyAction(fixture.game, fixture.buyer, actions[0]);
assert.equal(result.nexusCards?.cards?.hands[fixture.buyer], 'ixians');
assert.ok(result.players.find(player => player.id === fixture.buyer)!.hand.some(card => card.id === fixture.purchased.id));
}
own.nexusIxianReplacement!.canPass = false;
assert.deepEqual(botActions(own), []);
for (const rival of fixture.game.players.filter(player => player.id !== fixture.buyer)) {
const view = viewGame(fixture.game, rival.id);
view.players.find(player => player.id === rival.id)!.bot = profile;
assert.deepEqual(botActions(view), []);
}
}
});
void test('human ownership, room locks and delegated buyers never cause a pending-source bot wait loop', () => {
for (const profile of DIFFICULTIES) {
const fixture = createIxianNexusReplacementFixture({ endingAuction: true });
const own: GameView = viewGame(fixture.game, fixture.buyer);
const seat = own.players.find(player => player.id === fixture.buyer)!;
delete seat.bot;
delete seat.autopilot;
assert.deepEqual(botActions(own), []);
seat.autopilot = profile;
for (const restriction of ['paused', 'closed'] as const) {
const locked = structuredClone(own);
locked.roomControl = { ...DEFAULT_ROOM_CONTROL, [restriction]: true };
assert.deepEqual(botActions(locked), []);
}
const delegated = structuredClone(fixture.game);
for (const player of delegated.players) player.bot = profile;
const result = settleIxianReplacementFixture(runBots(delegated, 1));
assert.equal(viewGame(result, fixture.buyer).nexusIxianReplacement, null);
assert.equal(result.nexusCards?.cards?.hands[fixture.buyer], null);
assert.equal(result.auction, null);
}
});
+205
View File
@@ -0,0 +1,205 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeIxianNexusReplacementGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import type { Card } from '../game/cards';
import { advanceToNextStorm } from './fixture-advanced-source';
export const IXIAN_REPLACEMENT_FACTIONS = ['atreides', 'emperor', 'guild'] as const;
export type IxianReplacementFixtureOptions = {
initial?: Game;
advanced?: boolean;
karama?: boolean;
face?: 'ixians' | 'richese';
seatIds?: string[];
purchasedKind?: 'worthless' | 'shield';
endingAuction?: boolean;
fullHand?: boolean;
otherDraw?: {player: string; face: 'ixians' | 'richese'};
};
export type IxianReplacementFixture = {
beforePayment: Game;
game: Game;
buyer: string;
event: string;
purchased: Card;
paymentAction: Action;
beforeSpice: number;
paidSpice: number;
emperor: string | null;
beforeEmperorSpice: number | null;
nextCard: Card | null;
auctionIndex: number;
};
export function settleIxianReplacementFixture(state: Game): Game {
let game = state;
for (let step = 0; step < 100; step++) {
if (game.pendingTreacheryDiscard) game = applyAction(game, game.players[0].id, {type:'advanceBots'});
else if (game.response) game = applyAction(game,
game.players.find(p => !game.response!.passed.includes(p.id))!.id, {type:'passResponse'});
else return game;
}
throw new Error('The original native auction response did not settle.');
}
function stepNative(game: Game): Game {
if (game.response || game.pendingTreacheryDiscard) return settleIxianReplacementFixture(game);
if (game.phaseOpening) return applyAction(game,
game.players.find(p => !game.phaseOpening!.passed.includes(p.id))!.id, {type:'ready'});
if (game.status === 'playing' && !game.decision) {
if (game.phase === 0 && game.stormPending === null) {
const id = game.stormDialers.find(id => game.stormDials[id] === undefined);
assert.ok(id, 'The native Storm source must supply the next actual dialer.');
return applyAction(game,id,{type:'stormDial',amount:game.turn === 1 ? 0 : 1});
}
if (game.phase === 3 && game.auction) return applyAction(game,game.auction.active,{type:'passBid'});
if (game.phase === 5) return applyAction(game,game.active!,{type:'endMovement'});
}
for (const p of game.players) {
const view = viewGame(game,p.id);
view.players.find(seat => seat.id === p.id)!.bot = 'Easy';
const actions = botActions(view);
const action = actions.find(a => a.type === 'ready') ?? actions[0];
if (action) return applyAction(game,p.id,action);
}
throw new Error(`Native Ixian fixture stalled at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
}
/** Conserves the original undealt inventory, putting the buyer's printed Karama
* into the genuine starting deal rather than manufacturing an owned card. */
function orderStartingCards(game: Game,buyer: string): void {
const pool = [...game.deck];
const front: Card[] = [];
for (const p of game.players) {
const quantity = p.faction === 'harkonnen' ? 2 : 1;
for (let i = 0; i < quantity; i++) {
const index = pool.findIndex(c => p.id === buyer && i === 0 ? c.effect === 'karama' : c.effect !== 'karama');
assert.ok(index >= 0);
front.push(pool.splice(index,1)[0]);
}
}
game.deck = [...front,...pool];
}
/** Genuine fresh setup, native first turn, printed Nexus drawing and normal
* winner declaration. The returned parent is before the original payment. */
export function createIxianNexusReplacementFixture(options: IxianReplacementFixtureOptions = {}): IxianReplacementFixture {
let game: Game;
if (options.initial) {
game = structuredClone(options.initial);
assert.ok(game.status === 'lobby' || (game.status === 'setup' &&
game.nexusIxianReplacementPreview === true && game.turn === 1 && game.phase === 0 &&
game.players.every(player => player.hand.length === 0)),
'Continue only an admitted fresh lobby or its undealt native prototype setup.');
if (options.advanced !== undefined) assert.equal(game.advanced,options.advanced);
} else {
const ids = options.seatIds ?? ['p0','p1','p2'];
assert.equal(ids.length,3);
game = createGame('IXIANREPLACE',newPlayer(ids[0],'Atreides','atreides'),options.advanced ?? false,[]);
joinGame(game,newPlayer(ids[1],'Emperor','emperor'));
joinGame(game,newPlayer(ids[2],'Guild','guild'));
}
const buyer = game.players.find(p => p.faction === 'atreides')?.id ??
game.players.find(p => p.faction !== 'harkonnen')!.id;
const face = options.face ?? 'ixians';
if (options.otherDraw) {
assert.ok(game.players.some(p => p.id === options.otherDraw!.player));
assert.notEqual(options.otherDraw.player,buyer);
assert.notEqual(options.otherDraw.face,face);
}
const partners = game.players.filter(p => p.id !== buyer && p.id !== options.otherDraw?.player);
assert.ok(partners.length >= 2,
'Native Nexus draws require an alliance between two seats other than the requested recipients.');
if (game.status === 'lobby') {
for (const p of game.players) if (!p.ready) game = applyAction(game,p.id,{type:'ready'});
game.nexusCards ??= {cards:null,phase:null};
game = initializeIxianNexusReplacementGameForAudit(game);
}
orderStartingCards(game,buyer);
// Prepare the conserved faces before any native Nexus acquisition. Setup
// initializes an empty Nexus hand; only a qualifying closing Nexus draws.
const nexus = game.nexusCards!.cards!;
const faces: typeof nexus.deck = [face,...(options.otherDraw ? [options.otherDraw.face] : [])];
assert.ok(faces.every(card => nexus.deck.includes(card)));
nexus.deck = [...faces,...nexus.deck.filter(card => !faces.includes(card))];
for (let step = 0; game.status === 'setup' && step < 100; step++) game = stepNative(game);
assert.equal(game.status,'playing');
// A complete native first turn supplies the next-turn source; no phase/turn
// assignment or Basic-to-Advanced flag flip is used.
for (let position = 0; position < 2; position++) {
const land = game.spiceDeck.findIndex((c,index) => index >= position && 'territory' in c);
assert.ok(land >= position);
game.spiceDeck.splice(position,0,game.spiceDeck.splice(land,1)[0]);
}
if (options.fullHand) {
for (let step = 0; step < 1200; step++) {
if (game.turn === 2 && game.phase === 0 && !game.phaseOpening && !game.response && !game.decision) break;
if (game.decision?.kind === 'auctionPayment')
game = applyAction(game,game.decision.player,{type:'decision',karama:false});
else if (!game.phaseOpening && !game.response && !game.decision && game.phase === 3 &&
game.auction?.active === buyer && game.auction.bid === 0 &&
game.players.find(p => p.id === buyer)!.hand.length < 3)
game = applyAction(game,buyer,{type:'bid',amount:2});
else game = stepNative(game);
}
assert.equal(game.players.find(p => p.id === buyer)!.hand.length,3);
} else game = advanceToNextStorm(game);
assert.equal(game.turn,2);
for (let step = 0; game.phase !== 1 && step < 200; step++) game = stepNative(game);
const worm = game.spiceDeck.findIndex(c => 'worm' in c && !c.greatMaker && !c.suppressed);
assert.ok(worm >= 0);
game.spiceDeck.splice(0,0,game.spiceDeck.splice(worm,1)[0]);
for (let step = 0; !(game.nexus && !game.spiceWindow && !game.spiceResolution &&
!game.phaseOpening && !game.response && !game.decision) && step < 200; step++) game = stepNative(game);
assert.equal(game.phase,1);
assert.equal(game.nexus,true);
// The publisher lifecycle grants no card without an alliance. Negotiate it
// through the actual native actions, leaving every requested recipient free.
game = applyAction(game,partners[0].id,{type:'alliance',target:partners[1].id});
game = applyAction(game,partners[1].id,{type:'alliance',target:partners[0].id});
for (let step = 0; game.nexusCards?.phase?.stage !== 'drawing' && step < 200; step++) game = stepNative(game);
assert.equal(game.nexusCards?.phase?.stage,'drawing');
game = applyAction(game,buyer,{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
assert.equal(game.nexusCards!.cards!.hands[buyer],face);
for (const id of game.nexusCards!.phase!.eligible) {
if (game.nexusCards!.phase!.done.includes(id)) continue;
const other: IxianReplacementFixtureOptions['otherDraw'] | null =
options.otherDraw?.player === id ? options.otherDraw : null;
game = applyAction(game,id,{type:'nexusCardChoice',turn:game.turn,card:null,choice:other ? 'draw' : 'keep',ownRedraws:0});
if (other) assert.equal(game.nexusCards!.cards!.hands[id],other.face);
}
// Reorder only the unplayed deck, before native auction allocation.
const kind = options.purchasedKind ?? 'worthless';
const wanted = game.deck.findIndex(c => c.kind === kind);
assert.ok(wanted >= 0);
const auctionOffset = options.endingAuction ? game.players.filter(p => p.hand.length < (p.faction === 'harkonnen' ? 8 : 4)).length - 1 : 0;
game.deck.splice(auctionOffset,0,game.deck.splice(wanted,1)[0]);
for (let step = 0; !(game.decision?.kind === 'auctionPayment' && game.decision.player === buyer) && step < 300; step++) {
if (game.decision?.kind === 'auctionPayment')
game = applyAction(game,game.decision.player,{type:'decision',karama:false});
else if (game.phaseOpening || game.response || game.decision || !game.auction) game = stepNative(game);
else if (options.endingAuction && game.auction.index < game.auction.cards.length - 1) {
const active = game.players.find(p => p.id === game.auction!.active)!;
game = applyAction(game,active.id,active.id !== buyer && active.faction !== 'harkonnen' &&
game.auction.bid === 0 ? {type:'bid',amount:1} : {type:'passBid'});
}
else if (game.auction.active === buyer && game.auction.bid === 0)
game = applyAction(game,buyer,{type:'bid',amount:2});
else game = applyAction(game,game.auction.active,{type:'passBid'});
}
assert.deepEqual(game.decision,{kind:'auctionPayment',player:buyer});
assert.equal(game.auction!.bidder,buyer);
assert.equal(game.auction!.bid,2);
assert.equal(game.auction!.allyPayment ?? 0,0);
const beforePayment = structuredClone(game);
const owner = game.players.find(p => p.id === buyer)!;
const emperor = game.players.find(p => p.faction === 'emperor')?.id ?? null;
const beforeEmperorSpice = emperor ? game.players.find(p => p.id === emperor)!.spice : null;
const beforeSpice = owner.spice;
const purchased = structuredClone(game.auction!.cards[game.auction!.index]);
const paymentAction: Action = options.karama
? {type:'decision',karama:true,card:owner.hand.find(c => c.effect === 'karama')!.id}
: {type:'decision',karama:false};
game = applyAction(game,buyer,paymentAction);
assert.ok(game.pendingNexusIxianReplacement);
const event = game.pendingNexusIxianReplacement.event;
return {beforePayment,game,buyer,event,purchased,paymentAction,beforeSpice,
paidSpice:game.players.find(p => p.id === buyer)!.spice,emperor,beforeEmperorSpice,
nextCard:game.deck[0] ? structuredClone(game.deck[0]) : null,auctionIndex:beforePayment.auction!.index};
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { Children, createElement, isValidElement } from 'react';
import type { ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame } from '../game/engine';
import type { Action, GameView } from '../game/engine';
import { tableActionOwner } from '../game/table-turn';
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
import { createIxianNexusReplacementFixture, settleIxianReplacementFixture } from './fixture-nexus-ixian-replacement';
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(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
// UI modules need the Next/CSS test-loader boundary registered above;
// static imports would resolve those modules before their platform shims.
const { NexusIxianReplacement } = await import('../components/nexus-ixian-replacement');
const { GameTable } = await import('../components/game-table');
function click(node: ReactNode, selector: string): boolean {
for (const child of Children.toArray(node)) {
if (!isValidElement<{ children?: ReactNode; onClick?: () => void; 'data-testid'?: string }>(child)) continue;
if (child.props['data-testid'] === selector && child.props.onClick) {
child.props.onClick();
return true;
}
if (click(child.props.children, selector)) return true;
}
return false;
}
function select(game: GameView, choice: 'use' | 'pass', busy = false): Action | undefined {
let action: Action | undefined;
click(NexusIxianReplacement({ game, busy, act(value) { action = value; } }), `nexus-ixian-replacement-${choice}`);
return action;
}
function markup(game: GameView): string {
return renderToStaticMarkup(createElement(NexusIxianReplacement, { game, busy: false, act() {} }));
}
function table(game: GameView, busy = false): string {
return renderToStaticMarkup(createElement(GameTable, { game, busy, send: async () => {}, onExit() {} }));
}
void test('only the buyer sees the purchased face and eligibility; wrong-face buyers can keep without an Ixian inspector', () => {
const fixture = createIxianNexusReplacementFixture();
const own = viewGame(fixture.game, fixture.buyer);
assert.match(markup(own), /aria-label="Your purchased Treachery Card"/);
assert.match(markup(own), /aria-label="Inspect Ixians Nexus card"/);
const use = select(own, 'use');
assert.ok(use);
const used = applyAction(fixture.game, fixture.buyer, use);
assert.equal(viewGame(used, fixture.buyer).nexusIxianReplacement, null);
assert.equal(used.nexusCards?.cards?.hands[fixture.buyer], null);
assert.equal(used.players.find(player => player.id === fixture.buyer)!.hand.length,
fixture.game.players.find(player => player.id === fixture.buyer)!.hand.length);
for (const rival of fixture.game.players.filter(player => player.id !== fixture.buyer)) {
const view = viewGame(fixture.game, rival.id);
assert.equal(view.nexusIxianReplacement!.purchased, null);
assert.equal(view.nexusIxianReplacement!.blocked, null);
assert.equal(select(view, 'use'), undefined);
assert.equal(select(view, 'pass'), undefined);
assert.doesNotMatch(markup(view), /aria-label="Your purchased Treachery Card"/);
if (view.nexusCards?.card !== 'ixians') assert.doesNotMatch(markup(view), /aria-label="Inspect Ixians Nexus card"/);
assert.equal(tableActionOwner(view), fixture.buyer);
}
const irrelevant = createIxianNexusReplacementFixture({ face: 'richese' });
const neutral = viewGame(irrelevant.game, irrelevant.buyer);
assert.equal(select(neutral, 'use'), undefined);
assert.doesNotMatch(markup(neutral), /aria-label="Inspect Ixians Nexus card"/);
const keep = select(neutral, 'pass');
assert.ok(keep);
const kept = applyAction(irrelevant.game, irrelevant.buyer, keep);
assert.ok(kept.players.find(player => player.id === irrelevant.buyer)!.hand.some(card => card.id === irrelevant.purchased.id));
assert.equal(kept.nexusCards?.cards?.hands[irrelevant.buyer], 'richese');
});
void test('paid and printed Karama human choices resume the native sale once; saved events cannot buy or draw again', () => {
for (const advanced of [false, true]) for (const karama of [false, true]) for (const choice of ['use', 'pass'] as const) {
const fixture = createIxianNexusReplacementFixture({ advanced, karama, endingAuction: true });
const action = select(viewGame(fixture.game, fixture.buyer), choice);
assert.ok(action);
const result = settleIxianReplacementFixture(applyAction(fixture.game, fixture.buyer, action));
const pass = select(viewGame(fixture.game, fixture.buyer), 'pass');
assert.ok(pass);
const original = settleIxianReplacementFixture(applyAction(fixture.game, fixture.buyer, pass));
assert.equal(viewGame(result, fixture.buyer).nexusIxianReplacement, null);
assert.deepEqual(result.players.map(player => ({ id: player.id, spice: player.spice })),
original.players.map(player => ({ id: player.id, spice: player.spice })));
assert.equal(result.phase, original.phase);
assert.equal(result.auction?.index, original.auction?.index);
assert.equal(result.auction, null);
if (choice === 'pass') assert.ok(result.players.find(player => player.id === fixture.buyer)!.hand.some(card => card.id === fixture.purchased.id));
const saved = JSON.parse(JSON.stringify(result));
const before = JSON.stringify(saved);
assert.throws(() => applyAction(saved, fixture.buyer, action));
assert.equal(JSON.stringify(saved), before);
assert.equal(select(viewGame(saved, fixture.buyer), choice), undefined);
assert.doesNotMatch(table(viewGame(saved, fixture.buyer)), /data-testid="nexus-ixian-replacement"/);
}
});
void test('pending purchase reserves actual table controls and respects busy, room and delegation locks', () => {
const fixture = createIxianNexusReplacementFixture();
const own: GameView = viewGame(fixture.game, fixture.buyer);
assert.equal(tableActionOwner(own), fixture.buyer);
const html = table(own);
assert.match(html, /data-testid="nexus-ixian-replacement"/);
assert.doesNotMatch(html, /<button[^>]*>[^<]*(?:Bid |Place bid|Play Karama|Make a bribe|Pledge|Search)/i);
assert.match(html, /aria-label="Inspect Ixians Nexus card"/);
for (const restriction of ['paused', 'closed', 'autopilot', 'busy'] as const) {
const restricted = structuredClone(own);
if (restriction === 'autopilot') restricted.players.find(player => player.id === own.me)!.autopilot = 'Easy';
else if (restriction !== 'busy') restricted.roomControl = { ...DEFAULT_ROOM_CONTROL, [restriction]: true };
for (const choice of ['use', 'pass'] as const) {
assert.equal(select(restricted, choice, restriction === 'busy'), undefined);
}
}
const blocked = structuredClone(own);
blocked.nexusIxianReplacement!.blocked = 'The purchased card is no longer reserved.';
assert.equal(select(blocked, 'use'), undefined);
assert.ok(select(blocked, 'pass'));
const stopped = structuredClone(own);
stopped.status = 'finished';
assert.equal(select(stopped, 'use'), undefined);
assert.equal(select(stopped, 'pass'), undefined);
});
aliases.deregister();
@@ -0,0 +1,345 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { applyAction, createGame, initializeIxianNexusReplacementGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { createIxianNexusReplacementFixture, settleIxianReplacementFixture } from './fixture-nexus-ixian-replacement';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
const owner = (game: Game,id: string) => game.players.find(p => p.id === id)!;
function inventory(game: Game): void {
const physical = [...game.players.flatMap(p => p.hand),...game.deck,...game.discard,
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])];
assert.deepEqual(physical.map(c => c.id).sort(),baseDeck().map(c => c.id).sort());
}
function reject(game: Game,id: string,action: Action): void {
const before = reload(game);
assert.throws(() => applyAction(game,id,action));
assert.deepEqual(game,before,'A rejected declaration must spend no Nexus, draw or spice.');
}
/** Captured from genuine six-seat native play: seeded setup, paid auctions,
* legal battle plans and winner discards. No row inflation or card deletion. */
function depletedNativeParent(): Game {
const game = normalizeAutomaticGame(JSON.parse(readFileSync(
new URL('./fixtures/nexus-ixian-native-depleted.json', import.meta.url), 'utf8')));
assert.equal(game.deck.length, 0);
assert.equal(game.decision?.kind, 'auctionPayment');
inventory(game);
return game;
}
for (const advanced of [false,true]) for (const karama of [false,true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} ${karama ? 'printed Karama' : 'paid'} exact purchased replacement keeps the original ledger and native suffix once`,() => {
const fixture = createIxianNexusReplacementFixture({advanced,karama});
const {game,beforePayment,buyer,event,purchased} = fixture;
assert.equal(owner(game,buyer).spice,fixture.beforeSpice - (karama ? 0 : 2));
assert.equal(owner(game,fixture.emperor!).spice,fixture.beforeEmperorSpice);
assert.deepEqual(game.currentAuctionSale,{winner:buyer,amount:2,free:karama,origin:'normal',seller:null});
assert.deepEqual(owner(game,buyer).hand,[...owner(beforePayment,buyer).hand.filter(c => !karama || c.effect !== 'karama'),purchased]);
assert.deepEqual(normalizeAutomaticGame(reload(game)),game,'Normalization cannot bypass the neutral owner decision.');
const pass = settleIxianReplacementFixture(applyAction(reload(game),buyer,{type:'nexusIxianReplacementPass',event}));
const use = settleIxianReplacementFixture(applyAction(reload(game),buyer,{type:'nexusIxianReplacementUse',event}));
assert.ok(fixture.nextCard);
assert.deepEqual(owner(use,buyer).hand,[...owner(beforePayment,buyer).hand.filter(c => !karama || c.effect !== 'karama'),fixture.nextCard]);
assert.equal(owner(use,buyer).hand.some(c => c.id === purchased.id),false);
assert.equal(use.discard.some(c => c.id === purchased.id),true);
assert.equal(use.nexusCards!.cards!.hands[buyer],null);
assert.equal(use.nexusCards!.cards!.discard.filter(face => face === 'ixians').length,1);
assert.equal(pass.nexusCards!.cards!.hands[buyer],'ixians');
assert.equal(owner(pass,buyer).hand.some(c => c.id === purchased.id),true);
assert.equal(owner(pass,buyer).spice,fixture.paidSpice);
assert.equal(owner(use,buyer).spice,fixture.paidSpice);
assert.equal(owner(use,fixture.emperor!).spice,fixture.beforeEmperorSpice! + (karama ? 0 : 2));
assert.equal(owner(pass,fixture.emperor!).spice,owner(use,fixture.emperor!).spice);
assert.equal(use.auction?.index,fixture.auctionIndex + 1);
assert.equal(pass.auction?.index,use.auction?.index);
assert.equal(use.currentAuctionSale,null);
assert.equal(use.pendingNexusIxianReplacement,null);
assert.equal(use.nexusIxianReplacementHistory!.length,1);
assert.equal(use.nexusIxianReplacementHistory![0].source.card.id,purchased.id);
assert.deepEqual(use.nexusIxianReplacementHistory![0].replacement,fixture.nextCard);
const unsold = game.auction!.cards.slice(game.auction!.index + 1);
assert.ok(unsold.every(c => c.id !== fixture.nextCard!.id),'Replacement comes from the draw deck, never the unsold auction row.');
inventory(use); inventory(pass);
const restored = reload(use);
assert.deepEqual(normalizeAutomaticGame(restored),use);
reject(restored,buyer,{type:'nexusIxianReplacementUse',event});
reject(restored,buyer,{type:'nexusIxianReplacementPass',event});
});
}
void test('normal purchased replacement at a genuine full hand is discard-first and net zero',() => {
const f = createIxianNexusReplacementFixture({fullHand:true});
assert.equal(owner(f.beforePayment,f.buyer).hand.length,3);
assert.equal(owner(f.game,f.buyer).hand.length,4);
const use = applyAction(f.game,f.buyer,{type:'nexusIxianReplacementUse',event:f.event});
assert.equal(owner(use,f.buyer).hand.length,4);
assert.deepEqual(owner(use,f.buyer).hand,[...owner(f.beforePayment,f.buyer).hand,f.nextCard]);
inventory(use);
});
void test('native depleted purchase can redraw its exact discarded physical card', t => {
const parent = depletedNativeParent();
const buyer = parent.decision!.player;
const purchased = parent.auction!.cards[parent.auction!.index];
const originalPool = structuredClone(parent.auction!.cards);
const paid = applyAction(parent, buyer, {type:'decision', karama:false});
const event = paid.pendingNexusIxianReplacement!.event;
let first = true;
t.mock.method(crypto, 'getRandomValues', (values: Uint32Array) => {
assert.ok(values instanceof Uint32Array);
values.fill(first ? 0 : 0xffffffff);
first = false;
return values;
});
const use = applyAction(reload(paid), buyer, {type:'nexusIxianReplacementUse', event});
assert.deepEqual(owner(use, buyer).hand, owner(paid, buyer).hand);
assert.equal(use.nexusIxianReplacementHistory!.at(-1)!.replacement?.id, purchased.id);
assert.equal(use.nexusCards!.cards!.hands[buyer], null);
assert.equal(use.discard.length, 0);
assert.equal(use.deck.length, paid.discard.length);
assert.deepEqual(use.auction!.cards, originalPool, 'native reserved lots stay intact');
inventory(use);
});
void test('native depleted purchase draws from canonical discard without taking an unsold lot', t => {
const parent = depletedNativeParent();
const buyer = parent.decision!.player;
const expected = parent.discard[0];
const unsold = parent.auction!.cards.slice(parent.auction!.index + 1).map(card => card.id);
const paid = applyAction(parent, buyer, {type:'decision', karama:false});
t.mock.method(crypto, 'getRandomValues', (values: Uint32Array) => {
assert.ok(values instanceof Uint32Array);
values.fill(0xffffffff);
return values;
});
const use = applyAction(paid, buyer, {type:'nexusIxianReplacementUse', event:paid.pendingNexusIxianReplacement!.event});
const drawn = use.nexusIxianReplacementHistory!.at(-1)!.replacement!;
assert.equal(drawn.id, expected.id);
assert.equal(unsold.includes(drawn.id), false);
assert.equal(use.discard.length, 0);
assert.deepEqual(use.deck.map(card => card.id).sort(),
[...paid.discard.map(card => card.id), paid.pendingNexusIxianReplacement!.card.id]
.filter(id => id !== drawn.id).sort());
inventory(use);
});
void test('Use/Pass accepts only the owned live event, never a held-card selector, replay or priority bypass',() => {
const f = createIxianNexusReplacementFixture();
const rival = f.game.players.find(p => p.id !== f.buyer)!.id;
for (const action of [
{type:'nexusIxianReplacementUse',event:f.event,card:owner(f.beforePayment,f.buyer).hand[0].id},
{type:'nexusIxianReplacementPass',event:f.event,accept:true},
{type:'nexusIxianReplacementUse',event:'old-event'},
{type:'decision',accept:true}, {type:'ready'}, {type:'passBid'},
{type:'card',card:owner(f.beforePayment,f.buyer).hand[0].id,mode:'purchase'},
]) reject(f.game,f.buyer,action);
reject(f.game,rival,{type:'nexusIxianReplacementUse',event:f.event});
reject(f.game,rival,{type:'nexusIxianReplacementPass',event:f.event});
assert.deepEqual(applyAction(f.game,f.buyer,{type:'advanceBots'}),f.game);
});
void test('first payment, reload and precommit all require the same canonical physical original purchased source',() => {
const f = createIxianNexusReplacementFixture();
const wrongDescriptor = reload(f.beforePayment);
wrongDescriptor.auction!.cards[wrongDescriptor.auction!.index].name = 'Forged purchase';
reject(wrongDescriptor,f.buyer,f.paymentAction);
const corruptions: ((g: Game) => void)[] = [
g => { owner(g,f.buyer).hand = owner(g,f.buyer).hand.filter(c => c.id !== f.purchased.id); },
g => { g.deck.push({...f.purchased}); },
g => { owner(g,f.buyer).hand.find(c => c.id === f.purchased.id)!.kind = 'lasgun'; },
g => { g.currentAuctionSale!.amount++; },
g => { g.currentAuctionSale!.free = !g.currentAuctionSale!.free; },
g => { g.currentAuctionSale!.origin = 'cache'; },
g => { g.auction!.cards[g.auction!.index] = {...g.deck[0]}; },
g => { g.auction!.index++; },
g => { g.deck.reverse(); },
g => { owner(g,f.buyer).spice++; },
g => { g.pendingNexusIxianReplacement!.parent += 'changed'; },
g => { g.nexusIxianReplacementCursor!.sequence++; },
g => { owner(g,f.buyer).faction = 'harkonnen'; },
];
for (const corrupt of corruptions) {
const saved = reload(f.game);
corrupt(saved);
assert.throws(() => viewGame(saved,f.buyer));
assert.throws(() => normalizeAutomaticGame(saved));
reject(saved,f.buyer,{type:'nexusIxianReplacementUse',event:f.event});
}
});
void test('public neutral offer is independent of secret held face and rivals see neither purchase nor eligibility',() => {
const ix = createIxianNexusReplacementFixture();
const irrelevant = createIxianNexusReplacementFixture({face:'richese'});
const rival = ix.game.players.find(p => p.id !== ix.buyer)!.id;
assert.deepEqual(viewGame(ix.game,rival).nexusIxianReplacement,viewGame(irrelevant.game,rival).nexusIxianReplacement);
assert.deepEqual(viewGame(ix.game,rival).nexusIxianReplacement,
{event:ix.event,buyer:ix.buyer,canPass:false,canUse:false,blocked:null,purchased:null});
assert.equal(viewGame(ix.game,ix.buyer).nexusIxianReplacement!.canUse,true);
const otherOffer = viewGame(irrelevant.game,irrelevant.buyer).nexusIxianReplacement!;
assert.equal(otherOffer.canPass,true);
assert.equal(otherOffer.canUse,false);
assert.equal(otherOffer.blocked,null);
reject(irrelevant.game,irrelevant.buyer,{type:'nexusIxianReplacementUse',event:irrelevant.event});
const pass = applyAction(irrelevant.game,irrelevant.buyer,{type:'nexusIxianReplacementPass',event:irrelevant.event});
assert.equal(pass.nexusCards!.cards!.hands[irrelevant.buyer],'richese');
assert.ok(owner(pass,irrelevant.buyer).hand.some(c => c.id === irrelevant.purchased.id));
const use = applyAction(ix.game,ix.buyer,{type:'nexusIxianReplacementUse',event:ix.event});
const rivalView = viewGame(use,rival);
assert.equal(rivalView.nexusIxianReplacement,null);
assert.equal(rivalView.players.find(p => p.id === ix.buyer)!.hand,undefined);
assert.equal(JSON.stringify(rivalView).includes(ix.nextCard!.id),false);
assert.equal(rivalView.decision?.kind === 'nexusIxianReplacement',false);
});
void test('closed histories survive later card transfers and recycling but cannot lose their independent cursor',() => {
const f = createIxianNexusReplacementFixture();
let game = settleIxianReplacementFixture(applyAction(f.game,f.buyer,{type:'nexusIxianReplacementUse',event:f.event}));
const rival = game.players.find(p => p.id !== f.buyer)!.id;
const replacement = owner(game,f.buyer).hand.find(c => c.id === f.nextCard!.id)!;
owner(game,f.buyer).hand = owner(game,f.buyer).hand.filter(c => c.id !== replacement.id);
owner(game,rival).hand.push(replacement);
game.deck.push(...game.discard.splice(0));
game = reload(game);
assert.equal(viewGame(game,f.buyer).nexusIxianReplacement,null);
inventory(game);
reject(game,f.buyer,{type:'nexusIxianReplacementUse',event:f.event});
const dropped = reload(game);
dropped.nexusIxianReplacementHistory = [];
assert.throws(() => viewGame(dropped,f.buyer));
const duplicated = reload(game);
duplicated.nexusIxianReplacementHistory!.push({...duplicated.nexusIxianReplacementHistory![0]});
assert.throws(() => normalizeAutomaticGame(duplicated));
});
void test('fresh initializer preserves admitted identities and fences classic-only modules and in-play retrofit',() => {
let lobby = createGame('SAMEID',newPlayer('host','Atreides','atreides'),true,[]);
joinGame(lobby,newPlayer('guest','Emperor','emperor'));
joinGame(lobby,newPlayer('guild','Guild','guild'));
for (const p of lobby.players) lobby = applyAction(lobby,p.id,{type:'ready'});
lobby.nexusCards = {cards:null,phase:null};
const initial = reload(lobby);
const fixture = createIxianNexusReplacementFixture({initial:lobby});
assert.equal(fixture.game.code,initial.code);
assert.equal(fixture.game.advanced,true);
assert.deepEqual(fixture.game.players.map(p => [p.id,p.name,p.faction]),initial.players.map(p => [p.id,p.name,p.faction]));
assert.deepEqual(fixture.game.playerPositions,initial.playerPositions);
assert.deepEqual(lobby,initial);
assert.throws(() => initializeIxianNexusReplacementGameForAudit(fixture.game));
const expansion = reload(lobby);
expansion.expansions = ['ix'];
assert.throws(() => initializeIxianNexusReplacementGameForAudit(expansion));
const overlay = reload(lobby);
overlay.ecazTreachery = true;
assert.throws(() => initializeIxianNexusReplacementGameForAudit(overlay));
});
for (const karama of [false,true]) void test(`last native normal auction lot ${karama ? 'Karama' : 'paid'} completes once after Use`,() => {
const f = createIxianNexusReplacementFixture({karama,endingAuction:true});
assert.equal(f.game.auction!.index,f.game.auction!.cards.length - 1);
const game = settleIxianReplacementFixture(applyAction(f.game,f.buyer,{type:'nexusIxianReplacementUse',event:f.event}));
assert.equal(game.auction,null);
assert.equal(game.currentAuctionSale,null);
assert.equal(owner(game,f.buyer).spice,f.paidSpice);
assert.equal(owner(game,f.emperor!).spice,f.beforeEmperorSpice! + (karama ? 0 : 2));
assert.equal(game.phase,4);
inventory(game);
});
void test('a later genuine purchase creates a new source while the closed prior event stays unusable',() => {
const f = createIxianNexusReplacementFixture();
let game = settleIxianReplacementFixture(applyAction(f.game,f.buyer,{type:'nexusIxianReplacementPass',event:f.event}));
const firstCard = f.purchased.id;
for (let step = 0; game.auction!.active !== f.buyer && step < 20; step++)
game = applyAction(game,game.auction!.active,{type:'passBid'});
game = applyAction(game,f.buyer,{type:'bid',amount:2});
for (let step = 0; game.decision?.kind !== 'auctionPayment' && step < 20; step++)
game = applyAction(game,game.auction!.active,{type:'passBid'});
const originalBalance = owner(game,f.buyer).spice;
const secondPurchased = game.auction!.cards[game.auction!.index].id;
game = applyAction(game,f.buyer,{type:'decision',karama:false});
const nextEvent = game.pendingNexusIxianReplacement!.event;
assert.notEqual(nextEvent,f.event);
assert.equal(game.pendingNexusIxianReplacement!.sequence,1);
reject(game,f.buyer,{type:'nexusIxianReplacementUse',event:f.event});
const use = applyAction(game,f.buyer,{type:'nexusIxianReplacementUse',event:nextEvent});
assert.equal(owner(use,f.buyer).spice,originalBalance - 2);
assert.ok(owner(use,f.buyer).hand.some(c => c.id === firstCard));
assert.equal(owner(use,f.buyer).hand.some(c => c.id === secondPurchased),false);
assert.deepEqual(use.nexusIxianReplacementHistory!.map(r => r.outcome),['pass','use']);
inventory(use);
});
void test('a genuine Harkonnen buyer holding Ixian Nexus gets native purchase/bonus only, never an unresolved prototype offer',() => {
const lobby = createGame('HARKBUYER',newPlayer('a','Atreides','atreides'),false,[]);
joinGame(lobby,newPlayer('e','Emperor','emperor'));
joinGame(lobby,newPlayer('h','Harkonnen','harkonnen'));
joinGame(lobby,newPlayer('g','Guild','guild'));
const f = createIxianNexusReplacementFixture({initial:lobby,face:'richese',otherDraw:{player:'h',face:'ixians'}});
let game = settleIxianReplacementFixture(applyAction(f.game,f.buyer,{type:'nexusIxianReplacementPass',event:f.event}));
assert.equal(game.nexusCards!.cards!.hands.h,'ixians');
for (let step = 0; game.auction!.active !== 'h' && step < 20; step++)
game = applyAction(game,game.auction!.active,{type:'passBid'});
const balance = owner(game,'h').spice;
const purchased = game.auction!.cards[game.auction!.index].id;
game = applyAction(game,'h',{type:'bid',amount:2});
for (let step = 0; game.auction?.bidder === 'h' && !game.currentAuctionSale &&
game.auction.index === f.auctionIndex + 1 && step < 30; step++) {
if (game.decision?.kind === 'auctionPayment')
game = applyAction(game,'h',{type:'decision',karama:false});
else game = applyAction(game,game.auction.active,{type:'passBid'});
}
assert.equal(game.pendingNexusIxianReplacement,null);
assert.equal(viewGame(game,'a').nexusIxianReplacement,null);
assert.equal(viewGame(game,'h').nexusIxianReplacement,null);
game = settleIxianReplacementFixture(game);
assert.equal(owner(game,'h').spice,balance - 2);
assert.ok(owner(game,'h').hand.some(c => c.id === purchased));
assert.equal(owner(game,'h').hand.length,4,'The actual native Harkonnen bonus remains active.');
assert.equal(game.nexusCards!.cards!.hands.h,'ixians');
assert.equal(game.nexusIxianReplacementHistory!.length,1);
inventory(game);
});
void test('direct printed Karama acquisition preserves the original zero bid and native free continuation',() => {
const f = createIxianNexusReplacementFixture();
let game = settleIxianReplacementFixture(applyAction(f.game,f.buyer,{type:'nexusIxianReplacementPass',event:f.event}));
assert.equal(game.auction!.bid,0);
const spice = owner(game,f.buyer).spice;
const original = {...game.auction!.cards[game.auction!.index]};
const next = {...game.deck[0]};
const karama = owner(game,f.buyer).hand.find(c => c.effect === 'karama')!;
game = applyAction(game,f.buyer,{type:'card',card:karama.id,mode:'purchase'});
const source = game.pendingNexusIxianReplacement!;
assert.equal(source.amount,0);
assert.equal(source.free,true);
assert.deepEqual(source.card,original);
const use = settleIxianReplacementFixture(applyAction(game,f.buyer,{type:'nexusIxianReplacementUse',event:source.event}));
assert.equal(owner(use,f.buyer).spice,spice);
assert.equal(owner(use,f.buyer).hand.some(c => c.id === original.id),false);
assert.ok(owner(use,f.buyer).hand.some(c => c.id === next.id));
assert.equal(use.nexusIxianReplacementHistory![1].source.amount,0);
inventory(use);
});
void test('nonprinted Bene Gesserit Karama substitution continues native sale without enabling the bounded offer',() => {
const lobby = createGame('BGCONVERT',newPlayer('b','Bene Gesserit','beneGesserit'),true,[]);
joinGame(lobby,newPlayer('e','Emperor','emperor'));
joinGame(lobby,newPlayer('g','Guild','guild'));
const f = createIxianNexusReplacementFixture({initial:lobby});
const parent = reload(f.beforePayment);
const index = parent.deck.findIndex(c => c.kind === 'worthless');
assert.ok(index >= 0);
const worthless = parent.deck.splice(index,1)[0];
owner(parent,f.buyer).hand.push(worthless);
const balance = owner(parent,f.buyer).spice;
let game = applyAction(parent,f.buyer,{type:'decision',karama:true,card:worthless.id});
game = settleIxianReplacementFixture(game);
assert.equal(game.pendingNexusIxianReplacement,null);
assert.equal(viewGame(game,f.buyer).nexusIxianReplacement,null);
assert.equal(game.nexusCards!.cards!.hands[f.buyer],'ixians');
assert.equal(owner(game,f.buyer).spice,balance);
assert.ok(owner(game,f.buyer).hand.some(c => c.id === f.purchased.id));
assert.equal(game.nexusIxianReplacementHistory!.length,0);
inventory(game);
});
@@ -0,0 +1,463 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync, SQLOutputValue } from 'node:sqlite';
import { viewGame, type Action, type Game } from '../game/engine';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import type { RoomsClock, SeatAuth } from '../db/rooms';
import type * as Rooms from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import {
createIxianNexusReplacementFixture,
type IxianReplacementFixture,
type IxianReplacementFixtureOptions as Options,
} from './fixture-nexus-ixian-replacement';
type SqlRow = Record<string, SQLOutputValue>;
interface Fixture extends IxianReplacementFixture {
sqlite: DatabaseSync;
restart(): typeof Rooms;
hooks: { beforeWrite?: () => Promise<void> };
writes: { expected: number; changes: number }[];
initial: Game;
code: string;
tokens: string[];
auths: SeatAuth[];
save(g: Game): void;
credentialRows: { seats: SqlRow[]; entries: SqlRow[]; keys: SqlRow[]; receipts: SqlRow[] };
unrelatedCode: string;
unrelatedToken: string;
unrelatedRow: SqlRow | undefined;
stock: string[];
}
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
const plain = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
const pass = (event: string): Action => ({ type: 'nexusIxianReplacementPass', event });
const use = (event: string): Action => ({ type: 'nexusIxianReplacementUse', event });
function player(g: Game, id: string) {
const found = g.players.find(p => p.id === id);
assert.ok(found);
return found;
}
function physical(g: Game) {
// A delivered normal sale's current auction descriptor is a receipt alias,
// not another owned component. Unsold later lots remain physical cards.
const ids = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand),
...(g.auction?.cards.slice(g.auction.index + Number(g.currentAuctionSale?.origin === 'normal')) ?? [])]
.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'one physical custodian per Treachery card');
return ids;
}
function credentials(sqlite: DatabaseSync) {
return {
seats: sqlite.prepare('SELECT * FROM seats ORDER BY room_code,player_id,token_hash').all(),
entries: sqlite.prepare('SELECT * FROM room_entry_receipts ORDER BY room_code,player_id,operation_hash').all(),
keys: sqlite.prepare('SELECT * FROM seat_recovery_keys ORDER BY room_code,player_id').all(),
receipts: sqlite.prepare('SELECT * FROM seat_recovery_receipts ORDER BY room_code,player_id,operation_hash').all(),
};
}
function totalChanges(sqlite: DatabaseSync) {
const value = sqlite.prepare('SELECT total_changes() AS changes').get()?.changes;
assert.equal(typeof value, 'number');
return value;
}
async function fixture(t: test.TestContext, options: Options = {}): Promise<Fixture> {
const store = unitStore();
t.after(() => store.sqlite.close());
const made = await store.rooms.createRoom('Ixian replacement recovery', 'atreides', options.advanced ?? true, []);
const code = made.view.code;
const joined = await store.rooms.joinRoom(code, 'Emperor', 'emperor');
assert.ok(joined.token);
const guild = await store.rooms.joinRoom(code, 'Guild', 'guild');
assert.ok(guild.token);
const tokens = [made.token, joined.token, guild.token];
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
const admitted = await store.rooms.readRoom(code);
const native = createIxianNexusReplacementFixture({ ...options, initial: admitted });
assert.equal(native.beforePayment.code, code);
assert.equal(native.beforePayment.host, admitted.host);
assert.equal(native.beforePayment.advanced, admitted.advanced);
assert.deepEqual(native.beforePayment.expansions, admitted.expansions);
assert.deepEqual(native.beforePayment.players.map(p => [p.id, p.name, p.faction]),
admitted.players.map(p => [p.id, p.name, p.faction]));
assert.deepEqual(native.beforePayment.playerPositions, admitted.playerPositions);
const save = (g: Game) => {
assert.equal(store.sqlite.prepare('UPDATE rooms SET state=?, version=? WHERE code=?')
.run(JSON.stringify(g), g.version, code).changes, 1);
};
save(native.beforePayment);
const unrelated = await store.rooms.createRoom('Preserved unrelated lobby', 'guild', false, []);
const credentialRows = credentials(store.sqlite);
const unrelatedRow = store.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(unrelated.view.code);
const buyerIndex = auths.findIndex(auth => auth.playerId === native.buyer);
assert.ok(buyerIndex >= 0);
await store.restart().act(code, await store.restart().authenticate(code, tokens[buyerIndex]),
native.beforePayment.version, native.paymentAction, clock);
const initial = await store.restart().readRoom(code);
const opportunity = viewGame(initial, native.buyer).nexusIxianReplacement;
assert.ok(opportunity);
assert.equal(opportunity.event, native.event);
assert.deepEqual(opportunity.purchased, native.purchased);
assert.equal(opportunity.canPass, true);
assert.equal(opportunity.canUse, true);
store.writes.length = 0;
return { ...store, ...native, initial, code, tokens, auths, save, credentialRows,
unrelatedCode: unrelated.view.code, unrelatedToken: unrelated.token, unrelatedRow,
stock: physical(initial) };
}
const row = (f: Fixture) => f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
function protectedRows(f: Fixture) {
assert.deepEqual(credentials(f.sqlite), f.credentialRows);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.unrelatedCode), f.unrelatedRow);
}
function inventory(f: Fixture, g: Game) {
assert.deepEqual(physical(g), f.stock);
assert.ok(g.nexusCards?.cards);
validateNexusCards(g.nexusCards.cards, g.players);
assert.deepEqual([...g.nexusCards.cards.deck, ...g.nexusCards.cards.discard,
...Object.values(g.nexusCards.cards.hands).filter(face => face !== null)].sort(), [...NEXUS_FACTIONS].sort());
protectedRows(f);
}
async function authenticate(f: Fixture, id: string) {
const index = f.auths.findIndex(auth => auth.playerId === id);
assert.ok(index >= 0);
const auth = await f.restart().authenticate(f.code, f.tokens[index]);
assert.equal(auth.playerId, id);
assert.equal(auth.tokenHash, f.auths[index].tokenHash);
return auth;
}
async function act(f: Fixture, g: Game, action: Action) {
await f.restart().act(f.code, await authenticate(f, f.buyer), g.version, action, clock);
return f.restart().readRoom(f.code);
}
async function closeSale(f: Fixture, state: Game) {
let game = state;
for (let step = 0; game.response && step < 20; step++) {
const responder = game.players.find(p => !game.response!.passed.includes(p.id));
assert.ok(responder);
await f.restart().act(f.code, await authenticate(f, responder.id), game.version,
{ type: 'passResponse' }, clock);
game = await f.restart().readRoom(f.code);
}
assert.equal(game.response, null, 'the original auction suffix must finish');
assert.equal(game.pendingTreacheryDiscard ?? null, null);
return game;
}
async function rejected(f: Fixture, g: Game, auth: SeatAuth, action: Action, version = g.version) {
const before = row(f), changes = totalChanges(f.sqlite);
await assert.rejects(f.restart().act(f.code, auth, version, action, clock));
assert.deepEqual(row(f), before);
assert.equal(totalChanges(f.sqlite), changes, 'invalid action writes no durable table');
protectedRows(f);
}
async function restored(f: Fixture, g: Game) {
const before = row(f), changes = totalChanges(f.sqlite);
assert.deepEqual(plain(await f.restart().readRoom(f.code)), plain(g));
for (const auth of f.auths) {
const view = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId));
assert.deepEqual(plain(view), plain(viewGame(g, auth.playerId)));
assert.equal(view.nexusIxianReplacementPreview, true);
for (const key of ['pendingNexusIxianReplacement', 'nexusIxianReplacementHistory', 'nexusIxianReplacementCursor'])
assert.equal(Object.hasOwn(view, key), false);
for (const rival of view.players.filter(p => p.id !== auth.playerId)) {
for (const key of ['hand', 'traitors', 'spice']) assert.equal(Object.hasOwn(rival, key), false);
}
if (view.nexusIxianReplacement) {
const offer = view.nexusIxianReplacement;
assert.deepEqual(Object.keys(offer).sort(), ['event', 'buyer', 'canPass', 'canUse', 'blocked', 'purchased'].sort());
assert.equal(offer.event, f.event);
assert.equal(offer.buyer, f.buyer);
assert.equal(offer.canPass, auth.playerId === f.buyer);
if (auth.playerId !== f.buyer) {
assert.equal(offer.canUse, false);
assert.equal(offer.blocked, null);
assert.equal(offer.purchased, null);
}
}
}
await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(row(f), before, 'restart/refresh cannot consume an optional response');
assert.equal(totalChanges(f.sqlite), changes);
inventory(f, g);
}
function settled(f: Fixture, g: Game, used: boolean) {
assert.equal(viewGame(g, f.buyer).nexusIxianReplacement, null);
assert.equal(player(g, f.buyer).spice, f.paidSpice, 'original funded payment is not repeated or refunded');
const emperor = g.players.find(p => p.faction === 'emperor');
assert.ok(emperor);
const paid = f.beforeSpice - f.paidSpice;
assert.equal(emperor.spice, player(f.beforePayment, emperor.id).spice + paid,
'native Emperor sale income runs exactly once');
const original = player(f.initial, f.buyer).hand.map(card => card.id);
const expected = used ? original.filter(id => id !== f.purchased.id).concat(f.initial.deck[0].id) : original;
assert.deepEqual(player(g, f.buyer).hand.map(card => card.id).sort(), expected.sort());
assert.equal(g.nexusCards!.cards!.hands[f.buyer], used ? null : 'ixians');
assert.equal(g.nexusCards!.cards!.discard.filter(face => face === 'ixians').length, Number(used));
assert.equal(g.discard.filter(card => card.id === f.purchased.id).length, Number(used));
assert.equal(g.currentAuctionSale, null);
if (g.auction) assert.equal(g.auction.index, f.initial.auction!.index + 1);
inventory(f, g);
}
for (const advanced of [false, true]) {
for (const karama of [false, true]) {
for (const used of [false, true]) {
void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} ${karama ? 'printed Karama' : 'paid'} purchase ${used ? 'Use' : 'Pass'} survives auth restart and runs its original suffix once`, async t => {
const f = await fixture(t, { advanced, karama });
assert.equal(player(f.initial, f.buyer).spice, f.paidSpice);
await restored(f, f.initial);
const closed = await act(f, f.initial, used ? use(f.event) : pass(f.event));
assert.equal(closed.version, f.initial.version + 1);
await restored(f, closed);
const done = await closeSale(f, closed);
settled(f, done, used);
await restored(f, done);
for (const action of [pass(f.event), use(f.event)])
await rejected(f, done, await authenticate(f, f.buyer), action);
});
}
}
}
void test('SQLite competing Pass/Use CAS commits one replacement outcome and one original auction suffix', async t => {
const f = await fixture(t);
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
let results: PromiseSettledResult<unknown>[];
try {
const auth = await authenticate(f, f.buyer);
results = await Promise.allSettled([pass(f.event), use(f.event)].map(action =>
f.restart().act(f.code, auth, f.initial.version, action, clock).finally(release)));
} finally {
delete f.hooks.beforeWrite;
}
assert.equal(arrivals, 2);
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
assert.deepEqual(f.writes.map(write => write.changes).sort((one, two) => one - two), [0, 1]);
const closed = await f.restart().readRoom(f.code);
assert.equal(closed.version, f.initial.version + 1);
await restored(f, closed);
const done = await closeSale(f, closed);
settled(f, done, results[1].status === 'fulfilled');
await restored(f, done);
});
void test('SQLite stale, wrong buyer, foreign seat, substituted hand card and unknown payload reject without durable writes', async t => {
const f = await fixture(t);
const buyer = await authenticate(f, f.buyer);
const other = f.auths.find(auth => auth.playerId !== f.buyer)!;
const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken);
const changes = totalChanges(f.sqlite), before = row(f);
await assert.rejects(f.restart().authenticate(f.code, f.unrelatedToken));
await assert.rejects(f.restart().readSeatView(f.code, foreign));
await assert.rejects(f.restart().readSeatView(f.code, { playerId: f.buyer, tokenHash: foreign.tokenHash }));
assert.equal(totalChanges(f.sqlite), changes);
assert.deepEqual(row(f), before);
await rejected(f, f.initial, buyer, use(f.event), f.initial.version - 1);
await rejected(f, f.initial, foreign, use(f.event));
await rejected(f, f.initial, other, pass(f.event));
await rejected(f, f.initial, other, use(f.event));
for (const action of [pass(`${f.event}:expired`), use(`${f.event}:expired`),
{ ...use(f.event), card: player(f.initial, f.buyer).hand.find(card => card.id !== f.purchased.id)!.id },
{ ...use(f.event), price: 0 }, { ...pass(f.event), recipient: f.buyer }])
await rejected(f, f.initial, buyer, action as Action);
await restored(f, f.initial);
});
void test('SQLite cached authentication revoked after restart cannot inspect or consume the purchase', async t => {
const f = await fixture(t);
const cached = await authenticate(f, f.buyer);
f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE room_code=? AND player_id=?').run(f.code, f.buyer);
f.credentialRows = credentials(f.sqlite);
const before = row(f), changes = totalChanges(f.sqlite);
const index = f.auths.findIndex(auth => auth.playerId === f.buyer);
await assert.rejects(f.restart().authenticate(f.code, f.tokens[index]));
await assert.rejects(f.restart().readSeatView(f.code, cached));
await rejected(f, f.initial, cached, use(f.event));
await rejected(f, f.initial, cached, pass(f.event));
assert.deepEqual(row(f), before);
assert.equal(totalChanges(f.sqlite), changes);
});
void test('SQLite neutral purchase response reveals no relevant-face distinction to other seats', async t => {
const f = await fixture(t);
// Vary only the owned physical face in the same genuine pre-payment
// parent, then let the original native payment authorize its own receipt.
const alternateParent = structuredClone(f.beforePayment);
const cards = alternateParent.nexusCards!.cards!;
const index = cards.deck.indexOf('richese');
assert.ok(index >= 0);
assert.equal(cards.hands[f.buyer], 'ixians');
cards.hands[f.buyer] = 'richese';
cards.deck[index] = 'ixians';
f.save(alternateParent);
await f.restart().act(f.code, await authenticate(f, f.buyer), alternateParent.version,
f.paymentAction, clock);
const irrelevant = await f.restart().readRoom(f.code);
assert.equal(irrelevant.version, f.initial.version);
assert.equal(viewGame(irrelevant, f.buyer).nexusIxianReplacement!.event, f.event);
f.writes.length = 0;
await restored(f, irrelevant);
for (const auth of f.auths) {
const original = viewGame(f.initial, auth.playerId);
const changed = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId));
if (auth.playerId !== f.buyer) assert.deepEqual(plain(changed), plain(original));
else {
assert.equal(changed.nexusIxianReplacement!.canPass, true);
assert.equal(changed.nexusIxianReplacement!.canUse, false);
assert.deepEqual(changed.nexusIxianReplacement!.purchased, original.nexusIxianReplacement!.purchased);
}
}
await rejected(f, irrelevant, await authenticate(f, f.buyer), use(f.event));
const done = await act(f, irrelevant, pass(f.event));
assert.equal(player(done, f.buyer).spice, f.paidSpice);
assert.equal(done.nexusCards!.cards!.hands[f.buyer], 'richese');
assert.equal(player(done, f.buyer).hand.some(card => card.id === f.purchased.id), true);
await restored(f, done);
});
void test('SQLite current purchase source, parent, canonical face and exact owned reservation reject corruption before disclosure or writes', async t => {
const f = await fixture(t);
const mutations: { name: string; change: (g: Game) => void }[] = [
{ name: 'missing source', change: g => { delete g.pendingNexusIxianReplacement; } },
{ name: 'missing history', change: g => { delete g.nexusIxianReplacementHistory; } },
{ name: 'missing cursor', change: g => { delete g.nexusIxianReplacementCursor; } },
{ name: 'cursor sequence', change: g => { g.nexusIxianReplacementCursor!.sequence++; } },
{ name: 'source parent', change: g => { g.pendingNexusIxianReplacement!.parent += ':forged'; } },
{ name: 'source amount', change: g => { g.pendingNexusIxianReplacement!.amount++; } },
{ name: 'source payment method', change: g => { g.pendingNexusIxianReplacement!.free = true; } },
{ name: 'source canonical descriptor', change: g => { g.pendingNexusIxianReplacement!.card.name += ' forged'; } },
{ name: 'source buyer', change: g => {
g.pendingNexusIxianReplacement!.buyer = g.players.find(p => p.id !== f.buyer)!.id;
} },
{ name: 'native amount', change: g => { g.auction!.bid++; } },
{ name: 'native sale amount', change: g => { g.currentAuctionSale!.amount++; } },
{ name: 'native source origin', change: g => { g.currentAuctionSale!.origin = 'cache'; } },
{ name: 'native reserved lot', change: g => { g.auction!.cards[g.auction!.index] = structuredClone(g.deck[0]); } },
{ name: 'held canonical face', change: g => {
player(g, f.buyer).hand.find(card => card.id === f.purchased.id)!.name += ' forged';
} },
{ name: 'lost exact purchased custody', change: g => {
const owner = player(g, f.buyer);
const index = owner.hand.findIndex(card => card.id === f.purchased.id);
assert.ok(index >= 0);
player(g, g.players.find(p => p.id !== f.buyer)!.id).hand.push(...owner.hand.splice(index, 1));
} },
{ name: 'duplicated exact purchased custody', change: g => {
g.deck.push(structuredClone(player(g, f.buyer).hand.find(card => card.id === f.purchased.id)!));
} },
];
for (const { name, change } of mutations) {
const corrupt = structuredClone(f.initial);
change(corrupt);
f.save(corrupt);
f.writes.length = 0;
const before = row(f), changes = totalChanges(f.sqlite), original = plain(corrupt);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)), name);
for (const action of [use(f.event), pass(f.event), { type: 'passBid' } as Action])
await rejected(f, corrupt, await authenticate(f, f.buyer), action);
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(row(f), before, name);
assert.equal(totalChanges(f.sqlite), changes, name);
assert.equal(f.writes.length, 0, name);
assert.deepEqual(corrupt, original, 'recovery never mutates the caller snapshot');
protectedRows(f);
}
});
for (const used of [false, true]) {
void test(`SQLite closed normal auction retains its canonical ${used ? 'replacement' : 'pass'} source history after card and Nexus recycling`, async t => {
const f = await fixture(t, { endingAuction: true });
const closed = await act(f, f.initial, used ? use(f.event) : pass(f.event));
await restored(f, closed);
const done = await closeSale(f, closed);
assert.equal(done.auction, null, 'the normal auction actually closes');
assert.equal(done.currentAuctionSale, null);
assert.equal(done.nexusIxianReplacementHistory!.length, 1);
const receipt = done.nexusIxianReplacementHistory![0];
assert.equal(receipt.outcome, used ? 'use' : 'pass');
assert.deepEqual(receipt.source, f.initial.pendingNexusIxianReplacement);
assert.deepEqual(receipt.source.card, f.purchased);
assert.equal(receipt.source.amount, f.beforePayment.auction!.bid);
assert.equal(receipt.source.free, false);
assert.equal(receipt.source.buyer, f.buyer);
assert.equal(receipt.source.auctionIndex, f.auctionIndex);
await restored(f, done);
const later = structuredClone(done);
// Conserved physical recycling models later custody, without recreating
// components or altering a historical receipt's original source.
if (used) {
const index = later.discard.findIndex(card => card.id === f.purchased.id);
assert.ok(index >= 0);
later.deck.push(...later.discard.splice(index, 1));
const nexus = later.nexusCards!.cards!;
const nexusIndex = nexus.discard.indexOf('ixians');
assert.ok(nexusIndex >= 0);
nexus.deck.push(...nexus.discard.splice(nexusIndex, 1));
} else {
const owner = player(later, f.buyer);
const index = owner.hand.findIndex(card => card.id === f.purchased.id);
assert.ok(index >= 0);
player(later, later.players.find(p => p.id !== f.buyer)!.id).hand.push(...owner.hand.splice(index, 1));
}
f.save(later);
f.writes.length = 0;
await restored(f, later);
assert.deepEqual(later.nexusIxianReplacementHistory, done.nexusIxianReplacementHistory);
for (const action of [use(f.event), pass(f.event)])
await rejected(f, later, await authenticate(f, f.buyer), action);
for (const change of [
(g: Game) => { g.nexusIxianReplacementHistory = []; },
(g: Game) => { delete g.nexusIxianReplacementCursor; },
(g: Game) => { g.nexusIxianReplacementHistory![0].source.amount++; },
(g: Game) => { g.nexusIxianReplacementHistory![0].source.card.name += ' forged'; },
(g: Game) => { g.nexusIxianReplacementHistory![0].source.parent += ':forged'; },
(g: Game) => { g.nexusIxianReplacementHistory![0].signature += ':forged'; },
]) {
const corrupt = structuredClone(later);
change(corrupt);
f.save(corrupt);
f.writes.length = 0;
const before = row(f), changes = totalChanges(f.sqlite);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)));
await rejected(f, corrupt, await authenticate(f, f.buyer), use(f.event));
assert.deepEqual(row(f), before);
assert.equal(totalChanges(f.sqlite), changes);
assert.equal(f.writes.length, 0);
}
});
}
void test('SQLite credential revocation racing the final replacement CAS leaves the purchased source and all cards untouched', async t => {
const f = await fixture(t);
const auth = await authenticate(f, f.buyer);
const before = row(f), changes = totalChanges(f.sqlite);
let revoked = false;
f.hooks.beforeWrite = async () => {
assert.equal(revoked, false);
revoked = true;
assert.equal(f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE room_code=? AND player_id=?')
.run(f.code, f.buyer).changes, 1);
f.credentialRows = credentials(f.sqlite);
};
try {
await assert.rejects(f.restart().act(f.code, auth, f.initial.version, use(f.event), clock));
} finally {
delete f.hooks.beforeWrite;
}
assert.equal(revoked, true);
assert.deepEqual(f.writes.map(write => write.changes), [0]);
assert.equal(totalChanges(f.sqlite), Number(changes) + 1, 'only the deliberate credential revocation is durable');
assert.deepEqual(row(f), before);
const recovered = await f.restart().readRoom(f.code);
assert.deepEqual(plain(recovered), plain(f.initial));
inventory(f, recovered);
await assert.rejects(f.restart().readSeatView(f.code, auth));
});
+236
View File
@@ -0,0 +1,236 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck, type Card } from '../game/cards';
import {
IxianReplacementError,
closeIxianReplacementSource,
createIxianReplacementSource,
initialIxianReplacementCursor,
ixianReplacementEvent,
validateIxianReplacementHistory,
validateIxianReplacementSource,
type IxianReplacementContext,
type IxianReplacementReceipt,
} from '../game/nexus-ixian-replacement';
const [purchased, older, top, unsold] = baseDeck();
const parent = 'original-native-paid-sale-parent';
function context(): IxianReplacementContext {
return {
status: 'playing', phase: 3, turn: 1, sequence: 0,
auction: { cards: [{ ...purchased }, { ...unsold }], index: 0, bidder: 'buyer', bid: 4 },
sale: { winner: 'buyer', amount: 4, free: false, origin: 'normal', seller: null },
players: [
{ id: 'buyer', hand: [{ ...older }, { ...purchased }], handLimit: 4 },
{ id: 'rival', hand: [], handLimit: 4 },
],
physicalCards: [{ ...older }, { ...purchased }, { ...top }, { ...unsold }],
};
}
function source(ctx: IxianReplacementContext = context()) {
return createIxianReplacementSource(
ctx, ixianReplacementEvent(ctx.turn, 'buyer', ctx.sequence, 0), parent,
);
}
void test('retained purchases keep saved receipt history linear through the game horizon', () => {
const physical = baseDeck();
const history: IxianReplacementReceipt[] = [];
let cursor = initialIxianReplacementCursor();
for (let turn = 2; turn <= 10; turn++) {
const ctx = context();
ctx.turn = turn;
ctx.sequence = cursor.sequence;
ctx.auction!.cards = [purchased, top, unsold];
ctx.players = [...ctx.players, { id: 'guild', hand: [], handLimit: 4 }];
ctx.physicalCards = physical;
const original = createIxianReplacementSource(ctx,
ixianReplacementEvent(turn, 'buyer', cursor.sequence, 0), JSON.stringify(ctx));
const closed = closeIxianReplacementSource(original, 'pass', null, cursor);
cursor = closed.cursor;
history.push(closed.receipt);
validateIxianReplacementHistory(history, cursor, physical);
}
const firstBytes = JSON.stringify(history[0]).length;
assert.ok(JSON.stringify(history.at(-1)).length <= firstBytes * 2,
'a receipt must not embed recursively escaped predecessor receipts');
assert.ok(JSON.stringify(history).length <= history.length * firstBytes * 2,
'persisted history must grow with purchases, not exponentially');
});
void test('a live purchase cannot target another held card or a card no longer held by its buyer', () => {
const original = source();
const missing = context();
missing.players = missing.players.map(player => player.id === 'buyer'
? { ...player, hand: [{ ...older }] }
: { ...player, hand: [{ ...purchased }] });
assert.throws(() => validateIxianReplacementSource(missing, original, parent), IxianReplacementError);
const substituted = { ...original, card: { ...older } };
assert.throws(() => validateIxianReplacementSource(context(), substituted, parent), IxianReplacementError);
});
void test('canonical descriptors cannot be redefined by a matching auction row and hand', () => {
for (const patch of [
{ name: 'Another face' }, { kind: 'special' }, { effect: 'fabricated' },
{ id: 'nonphysical-card' }, { extra: 'not-a-card-field' },
]) {
const ctx = context();
const corrupt = { ...purchased, ...patch } as Card;
ctx.auction!.cards = [corrupt, unsold];
ctx.players = [{ ...ctx.players[0], hand: [older, corrupt] }, ctx.players[1]];
ctx.physicalCards = [older, corrupt, top, unsold];
assert.throws(() => source(ctx), IxianReplacementError);
}
});
void test('duplicate physical cards reject even when the purchased hand itself looks legal', () => {
for (const duplicate of [purchased, older, top, unsold]) {
const ctx = context();
ctx.physicalCards = [...ctx.physicalCards, { ...duplicate }];
assert.throws(() => source(ctx), IxianReplacementError);
}
const anotherHolder = context();
anotherHolder.players = [anotherHolder.players[0], {
...anotherHolder.players[1], hand: [{ ...purchased }],
}];
assert.throws(() => source(anotherHolder), IxianReplacementError);
});
void test('a legal full hand permits net-zero replacement but an already over-cap hand does not', () => {
const full = context();
full.players = [{ ...full.players[0], handLimit: 2 }, full.players[1]];
const original = source(full);
const before = structuredClone(full);
const result = closeIxianReplacementSource(original, 'use', top, initialIxianReplacementCursor());
assert.equal(result.receipt.source.card.id, purchased.id);
assert.equal(result.receipt.replacement?.id, top.id);
assert.deepEqual(full, before);
const overCap = { ...full, players: [{ ...full.players[0], handLimit: 1 }, full.players[1]] };
assert.throws(() => source(overCap), IxianReplacementError);
});
void test('paid and printed Karama sources retain their native amount without recalculating payment', () => {
const paid = source();
assert.equal(paid.amount, 4);
assert.equal(paid.free, false);
for (const bid of [0, 4]) {
const karama = context();
karama.auction = { ...karama.auction!, bid };
karama.sale = { ...karama.sale!, amount: bid, free: true };
const receipt = source(karama);
assert.equal(receipt.amount, bid);
assert.equal(receipt.free, true);
}
const unpaid = context();
unpaid.auction = { ...unpaid.auction!, bid: 0 };
unpaid.sale = { ...unpaid.sale!, amount: 0 };
assert.throws(() => source(unpaid), IxianReplacementError);
});
void test('current source must preserve normal origin, bidder, lot, price, payment kind, parent and cursor', () => {
const original = source();
const variants: IxianReplacementContext[] = [
{ ...context(), status: 'finished' },
{ ...context(), phase: 4 },
{ ...context(), turn: 2 },
{ ...context(), sequence: 1 },
{ ...context(), auction: null },
{ ...context(), sale: null },
{ ...context(), auction: { ...context().auction!, bidder: 'rival' } },
{ ...context(), auction: { ...context().auction!, index: 1 } },
{ ...context(), auction: { ...context().auction!, bid: 5 } },
{ ...context(), sale: { ...context().sale!, winner: 'rival' } },
{ ...context(), sale: { ...context().sale!, amount: 5 } },
{ ...context(), sale: { ...context().sale!, free: true } },
{ ...context(), sale: { ...context().sale!, origin: 'cache' } },
{ ...context(), sale: { ...context().sale!, origin: 'blackMarket' } },
{ ...context(), sale: { ...context().sale!, seller: 'rival' } },
];
for (const ctx of variants)
assert.throws(() => validateIxianReplacementSource(ctx, original, parent), IxianReplacementError);
assert.throws(() => validateIxianReplacementSource(context(), original, 'changed-parent'), IxianReplacementError);
assert.throws(() => createIxianReplacementSource(context(), 'stale-event', parent), IxianReplacementError);
});
void test('an empty deck permits newly discarded purchased card redraw; unsold row is not duplicate custody', () => {
const ctx = context();
// The physical census has only hands and the separate unsold row: no draw deck.
ctx.physicalCards = [older, purchased, unsold];
const original = source(ctx);
const result = closeIxianReplacementSource(original, 'use', purchased, initialIxianReplacementCursor());
assert.equal(result.receipt.replacement?.id, purchased.id);
validateIxianReplacementHistory([result.receipt], result.cursor, [older, purchased, unsold]);
// Counting the delivered row alias again is a real custody error.
assert.throws(() => source({ ...ctx, physicalCards: [...ctx.physicalCards, purchased] }), IxianReplacementError);
});
void test('pass closes without a draw, and neither pass nor use can reuse an already closed cursor', () => {
const original = source();
const passed = closeIxianReplacementSource(original, 'pass', null, initialIxianReplacementCursor());
validateIxianReplacementHistory([passed.receipt], passed.cursor);
assert.throws(() => closeIxianReplacementSource(original, 'use', top, passed.cursor), IxianReplacementError);
assert.throws(() => closeIxianReplacementSource(original, 'pass', null, passed.cursor), IxianReplacementError);
assert.throws(() => closeIxianReplacementSource(original, 'pass', top, initialIxianReplacementCursor()), IxianReplacementError);
assert.throws(() => closeIxianReplacementSource(original, 'use', null, initialIxianReplacementCursor()), IxianReplacementError);
assert.throws(() => closeIxianReplacementSource(original, 'use', { ...top, name: 'wrong' }, initialIxianReplacementCursor()), IxianReplacementError);
});
void test('closed receipts survive JSON reload, transfer and a later purchase of the same recycled physical card', () => {
const first = closeIxianReplacementSource(source(), 'use', top, initialIxianReplacementCursor());
const later = context();
later.turn = 2;
later.sequence = first.cursor.sequence;
const second = closeIxianReplacementSource(source(later), 'pass', null, first.cursor);
const history = JSON.parse(JSON.stringify([first.receipt, second.receipt]));
const cursor = JSON.parse(JSON.stringify(second.cursor));
// Historical purchased and drawn cards can now be anywhere in the live census.
validateIxianReplacementHistory(history, cursor, [top, unsold, purchased, older]);
assert.equal(history[0].source.card.id, history[1].source.card.id);
assert.notEqual(history[0].source.event, history[1].source.event);
});
void test('history removal, reordering, altered source/outcome and a changed independent cursor reject', () => {
const first = closeIxianReplacementSource(source(), 'use', top, initialIxianReplacementCursor());
const ctx = context();
ctx.turn = 2;
ctx.sequence = 1;
const second = closeIxianReplacementSource(source(ctx), 'pass', null, first.cursor);
const history = [first.receipt, second.receipt];
const invalid: IxianReplacementReceipt[][] = [
[], [first.receipt], [second.receipt, first.receipt], [first.receipt, first.receipt],
[first.receipt, { ...second.receipt, previous: 'unrelated-chain' }],
[first.receipt, { ...second.receipt, source: { ...second.receipt.source, amount: 7 } }],
[first.receipt, { ...second.receipt, replacement: top }],
[{ ...first.receipt, replacement: older }, second.receipt],
];
for (const records of invalid)
assert.throws(() => validateIxianReplacementHistory(records, second.cursor), IxianReplacementError);
assert.throws(() => validateIxianReplacementHistory(history, { ...second.cursor, signature: 'changed-head' }), IxianReplacementError);
assert.throws(() => validateIxianReplacementHistory(history, { ...second.cursor, sequence: 1 }), IxianReplacementError);
assert.throws(() => validateIxianReplacementHistory(history, initialIxianReplacementCursor()), IxianReplacementError);
});
void test('receipt snapshots cannot be mutated through original source or native draw references', () => {
const ctx = context();
const before = structuredClone(ctx);
const original = source(ctx);
const drawn = { ...top };
const result = closeIxianReplacementSource(original, 'use', drawn, initialIxianReplacementCursor());
assert.deepEqual(ctx, before);
original.card.name = 'mutated original source reference';
drawn.name = 'mutated native draw reference';
assert.equal(result.receipt.source.card.name, purchased.name);
assert.equal(result.receipt.replacement?.name, top.name);
validateIxianReplacementHistory([result.receipt], result.cursor);
});
void test('unknown saved receipt fields and malformed canonical custody fail closed', () => {
const original = source();
assert.throws(() => validateIxianReplacementSource(context(), { ...original, extra: true } as never, parent), IxianReplacementError);
assert.throws(() => validateIxianReplacementSource(context(), null as never, parent), IxianReplacementError);
const result = closeIxianReplacementSource(original, 'pass', null, initialIxianReplacementCursor());
assert.throws(() => validateIxianReplacementHistory([{ ...result.receipt, extra: true } as never], result.cursor), IxianReplacementError);
assert.throws(() => validateIxianReplacementHistory([result.receipt], { ...result.cursor, extra: true } as never), IxianReplacementError);
assert.throws(() => validateIxianReplacementHistory([result.receipt], result.cursor, [{ ...purchased, effect: 'forged' }]), IxianReplacementError);
});
+122
View File
@@ -1,6 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
applyAction,
createGame,
@@ -365,3 +370,120 @@ for (const advanced of [false, true]) {
});
}
}
for (const advanced of [false, true]) {
void test(`Ixian Nexus replacement ${advanced ? 'Advanced' : 'Basic'} starts only the exact fresh classic lobby without changing seats or native options`, t => {
const { db } = fixture(t);
const game = createGame('PROTOTYP', newPlayer('i', 'Atreides', 'atreides'), advanced);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const p of game.players) p.ready = true;
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
const seats = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
const lobbyRows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 6, 'ixian-replacement'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), lobbyRows);
const result = startPrototypeRoom(db, game.code, 7, 'ixian-replacement');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
assert.equal(result.version, 8);
assert.equal(saved.status, 'setup');
assert.equal(saved.code, game.code);
assert.equal(saved.host, game.host);
assert.equal(saved.advanced, advanced);
assert.deepEqual(saved.expansions, game.expansions);
assert.deepEqual(saved.playerPositions, game.playerPositions);
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]),
game.players.map(p => [p.id, p.name, p.faction]));
assert.equal(saved.nexusIxianReplacementPreview, true);
assert.deepEqual([...saved.nexusCards!.cards!.deck].sort(), [...NEXUS_FACTIONS].sort());
for (const p of saved.players) {
const view = viewGame(saved, p.id);
assert.equal(view.nexusIxianReplacementPreview, true);
assert.equal(view.nexusCards!.card, null);
assert.equal(Object.hasOwn(view.nexusCards!, 'deck'), false);
}
const startedRows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'ixian-replacement'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), startedRows);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
}
void test('Ixian Nexus replacement rejects unready, expansion, native Ixian and Homeworld lobbies without reconfiguring them', t => {
const { db } = fixture(t);
const classic = createGame('PROTOTYP', newPlayer('i', 'Atreides', 'atreides'), true);
joinGame(classic, newPlayer('e', 'Emperor', 'emperor'));
for (const p of classic.players) p.ready = true;
const unready = structuredClone(classic);
unready.players[0].ready = false;
const expansion = structuredClone(classic);
expansion.expansions = ['ix'];
const native = createGame('PROTOTYP', newPlayer('i', 'Ixians', 'ixians'), true, ['ix']);
joinGame(native, newPlayer('e', 'Emperor', 'emperor'));
for (const p of native.players) p.ready = true;
const homeworld = applyAction(classic, classic.host, { type: 'homeworlds', enabled: true });
for (const p of homeworld.players) p.ready = true;
for (const game of [unready, expansion, native, homeworld]) {
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const seats = db.prepare('SELECT * FROM seats').all();
assert.throws(() => startPrototypeRoom(db, game.code, 7, 'ixian-replacement'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rooms);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
}
});
void test('Ixian replacement CLI takes a private exact-version backup before starting and cannot overwrite a started game or prior proof', t => {
const directory = mkdtempSync(join(tmpdir(), 'dune-ixian-replacement-'));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const database = join(directory, 'rooms.sqlite');
const db = new DatabaseSync(database);
db.exec('CREATE TABLE rooms(code TEXT PRIMARY KEY,state TEXT,version INTEGER,updated_at INTEGER); CREATE TABLE seats(room_code TEXT,player_id TEXT,token_hash TEXT);');
const game = createGame('PROTOTYP', newPlayer('a', 'Atreides', 'atreides'), true);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const p of game.players) p.ready = true;
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(game.code, JSON.stringify(game), 7, 100);
const other = structuredClone(game);
other.code = 'KEEPGAME';
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(other.code, JSON.stringify(other), 9, 50);
startPrototypeRoom(db, other.code, 9, 'ixian-replacement');
for (const p of game.players)
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(game.code, p.id, 'isolated-test-' + p.id);
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const seats = db.prepare('SELECT * FROM seats ORDER BY player_id').all();
db.close();
const output = join(directory, 'private-proof');
const invoke = (version: number, out: string) => spawnSync(process.execPath, [
'--import', 'tsx', fileURLToPath(new URL('../tools/start-prototype.ts', import.meta.url)),
'--profile', 'ixian-replacement', '--db', database, '--room', game.code,
'--version', String(version), '--out', out,
], { encoding: 'utf8', timeout: 120000 });
const started = invoke(7, output);
assert.equal(started.status, 0, started.stderr);
assert.equal(statSync(output).mode & 0o777, 0o700);
for (const name of ['games.sqlite', 'snapshot.json', 'prototype.json'])
assert.equal(statSync(join(output, name)).mode & 0o777, 0o600);
const backup = new DatabaseSync(join(output, 'games.sqlite'), { readOnly: true });
assert.deepEqual(backup.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
assert.deepEqual(backup.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
backup.close();
const current = new DatabaseSync(database, { readOnly: true });
t.after(() => current.close());
const after = current.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.deepEqual(current.prepare('SELECT * FROM rooms WHERE code=?').get(other.code), before.find(row => row.code === other.code));
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
const savedRow = current.prepare('SELECT state,version FROM rooms WHERE code=?').get(game.code)!;
assert.equal(savedRow.version, 8);
const saved = JSON.parse(savedRow.state as string) as Game;
assert.equal(saved.nexusIxianReplacementPreview, true);
assert.equal(saved.status, 'setup');
assert.equal(saved.advanced, game.advanced);
assert.deepEqual(saved.playerPositions, game.playerPositions);
assert.deepEqual(saved.players.map(p => [p.id,p.name,p.faction]), game.players.map(p => [p.id,p.name,p.faction]));
assert.notEqual(invoke(8, output).status, 0, 'existing proof directory cannot be reused');
assert.notEqual(invoke(7, join(directory, 'stale-proof')).status, 0, 'stale backup version cannot be admitted');
assert.notEqual(invoke(8, join(directory, 'redeal-proof')).status, 0, 'current version cannot redeal a started game');
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
});