Files
Dune/tests/nexus-ixian-replacement-engine.test.ts

293 lines
17 KiB
TypeScript

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.equal(viewGame(ix.game,rival).nexusIxianReplacement!.purchased,null);
assert.equal(viewGame(ix.game,rival).nexusIxianReplacement!.canUse,false);
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);
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(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('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);
});