Integrate exact Ixian Nexus purchased-card replacement
This commit is contained in:
1 parent
c43ab64872
commit
a2b04dbfba
26 files changed
+4588
-34
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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};
|
||||
}
|
||||
+2189
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));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user