293 lines
17 KiB
TypeScript
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);
|
|
});
|
|
|