600 lines
30 KiB
TypeScript
600 lines
30 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { baseDeck, choamDeck, ixDeck, treacheryDeck, type Card } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-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,
|
|
expansions: [], richese: false, advanced: false,
|
|
auction: { cards: [{ ...purchased }, { ...unsold }], index: 0, bidder: 'buyer', bid: 4 },
|
|
richeseAuction: null,
|
|
sale: { winner: 'buyer', amount: 4, free: false, origin: 'normal', seller: null },
|
|
specialPurchase: null,
|
|
players: [
|
|
{ id: 'buyer', hand: [{ ...older }, { ...purchased }], handLimit: 4 },
|
|
{ id: 'rival', hand: [], handLimit: 4 },
|
|
],
|
|
physicalCards: [{ ...older }, { ...purchased }, { ...top }, { ...unsold }],
|
|
};
|
|
}
|
|
function purchaseContext(
|
|
card: Card, expansions: readonly string[], richese = false,
|
|
): IxianReplacementContext {
|
|
const ctx = context();
|
|
ctx.expansions = expansions;
|
|
ctx.richese = richese;
|
|
ctx.auction!.cards = [{ ...card }];
|
|
ctx.players = [{ ...ctx.players[0], hand: [{ ...card }] }, ctx.players[1]];
|
|
ctx.physicalCards = [{ ...card }];
|
|
return ctx;
|
|
}
|
|
function source(ctx: IxianReplacementContext = context()) {
|
|
return createIxianReplacementSource(
|
|
ctx, ixianReplacementEvent(ctx.turn, ctx.specialPurchase?.buyer ?? 'buyer',
|
|
ctx.sequence, ctx.specialPurchase ? 0 : ctx.auction?.index ?? 0), parent,
|
|
);
|
|
}
|
|
|
|
function nativeContext(origin: 'cache' | 'blackMarket'): IxianReplacementContext {
|
|
const card = origin === 'cache' ? richeseCards()[0] : purchased;
|
|
const ctx = context();
|
|
ctx.expansions = ['choam'];
|
|
ctx.richese = true;
|
|
ctx.advanced = origin === 'blackMarket';
|
|
ctx.auction = null;
|
|
ctx.richeseAuction = {
|
|
event: `native-${origin}-auction`, cardId: card.id, source: origin, owner: 'rival',
|
|
outcome: { kind: 'sold', winner: 'buyer', amount: 7 },
|
|
};
|
|
ctx.sale = { winner: 'buyer', amount: 7, free: false, origin, seller: 'rival' };
|
|
ctx.players = [{ ...ctx.players[0], hand: [{ ...older }, { ...card }] }, ctx.players[1]];
|
|
ctx.physicalCards = [{ ...older }, { ...card }, { ...top }];
|
|
return ctx;
|
|
}
|
|
|
|
const printedKarama = baseDeck().find(card => card.effect === 'karama')!;
|
|
const directTarget = richeseCards()[1];
|
|
|
|
function specialContext(ctx: IxianReplacementContext = context()): IxianReplacementContext {
|
|
ctx.expansions = ['choam'];
|
|
ctx.richese = true;
|
|
ctx.advanced = true;
|
|
ctx.specialPurchase = {
|
|
buyer: 'rival', cardId: directTarget.id, karama: printedKarama.id,
|
|
amount: 3, turn: ctx.turn, phase: 3,
|
|
};
|
|
ctx.players = ctx.players.map(player => player.id === 'rival'
|
|
? { ...player, hand: [...player.hand, directTarget] } : player);
|
|
// The delivered target is held; the already-spent printed Karama remains in discard custody.
|
|
ctx.physicalCards = [...ctx.physicalCards, directTarget, printedKarama];
|
|
return ctx;
|
|
}
|
|
|
|
void test('committed direct Richese purchase with no auction closes the exact paid cache card', () => {
|
|
const ctx = specialContext();
|
|
ctx.auction = null;
|
|
ctx.sale = null;
|
|
const original = source(ctx);
|
|
assert.equal(original.buyer, 'rival');
|
|
assert.deepEqual(original.card, directTarget);
|
|
assert.equal(original.amount, 3);
|
|
assert.equal(original.free, false);
|
|
assert.equal(original.auctionIndex, 0);
|
|
validateIxianReplacementSource(ctx, JSON.parse(JSON.stringify(original)), parent);
|
|
for (const outcome of ['use', 'pass'] as const) {
|
|
const closed = closeIxianReplacementSource(
|
|
original, outcome, outcome === 'use' ? top : null, initialIxianReplacementCursor(),
|
|
);
|
|
assert.equal(closed.receipt.outcome, outcome);
|
|
assert.deepEqual(closed.receipt.replacement, outcome === 'use' ? top : null);
|
|
assert.throws(() => closeIxianReplacementSource(original, 'pass', null, closed.cursor), IxianReplacementError);
|
|
validateIxianReplacementHistory(JSON.parse(JSON.stringify([closed.receipt])), closed.cursor, [older]);
|
|
}
|
|
});
|
|
|
|
void test('direct receipt outranks unrelated delivered normal and native lots without changing their sources', () => {
|
|
for (const origin of ['normal', 'cache', 'blackMarket'] as const) {
|
|
const ctx = origin === 'normal' ? context() : nativeContext(origin);
|
|
if (ctx.auction) ctx.auction = { ...ctx.auction, cards: [unsold, purchased], index: 1 };
|
|
const olderSource = source(ctx);
|
|
specialContext(ctx);
|
|
const direct = source(ctx);
|
|
assert.equal(direct.buyer, 'rival');
|
|
assert.equal(direct.card.id, directTarget.id);
|
|
assert.equal(direct.amount, 3);
|
|
assert.equal(direct.free, false);
|
|
assert.equal(direct.auctionIndex, 0);
|
|
assert.throws(() => validateIxianReplacementSource(ctx, olderSource, parent), IxianReplacementError);
|
|
ctx.specialPurchase = null;
|
|
// Restoring the suspended authority still validates its original price, target and index.
|
|
validateIxianReplacementSource(ctx, olderSource, parent);
|
|
assert.throws(() => validateIxianReplacementSource(ctx, direct, parent), IxianReplacementError);
|
|
}
|
|
const priorFree = context();
|
|
priorFree.auction = { ...priorFree.auction!, bid: 0 };
|
|
priorFree.sale = { ...priorFree.sale!, amount: 0, free: true };
|
|
const oldFreeSource = source(priorFree);
|
|
specialContext(priorFree);
|
|
assert.equal(source(priorFree).amount, 3);
|
|
assert.equal(source(priorFree).free, false);
|
|
priorFree.specialPurchase = null;
|
|
validateIxianReplacementSource(priorFree, oldFreeSource, parent);
|
|
});
|
|
|
|
void test('direct purchase rejects stale authority, wrong mode, noncache targets and unspent or false costs', () => {
|
|
const ctx = specialContext();
|
|
const purchase = ctx.specialPurchase!;
|
|
const original = source(ctx);
|
|
const variants: IxianReplacementContext[] = [
|
|
{ ...ctx, status: 'finished' },
|
|
{ ...ctx, phase: 4 },
|
|
{ ...ctx, turn: 2 },
|
|
{ ...ctx, specialPurchase: { ...purchase, turn: 2 } },
|
|
{ ...ctx, specialPurchase: { ...purchase, phase: 4 as never } },
|
|
{ ...ctx, advanced: false },
|
|
{ ...ctx, richese: false },
|
|
{ ...ctx, expansions: [] },
|
|
{ ...ctx, specialPurchase: { ...purchase, amount: 0 as never } },
|
|
{ ...ctx, specialPurchase: { ...purchase, amount: 4 as never } },
|
|
{ ...ctx, specialPurchase: { ...purchase, buyer: 'unseated' } },
|
|
{ ...ctx, specialPurchase: { ...purchase, cardId: older.id } },
|
|
{ ...ctx, specialPurchase: { ...purchase, cardId: richeseCards()[0].id } },
|
|
{ ...ctx, specialPurchase: { ...purchase, karama: purchased.id } },
|
|
{ ...ctx, specialPurchase: { ...purchase, karama: directTarget.id } },
|
|
{ ...ctx, physicalCards: ctx.physicalCards.filter(card => card.id !== printedKarama.id) },
|
|
{ ...ctx, physicalCards: ctx.physicalCards.filter(card => card.id !== directTarget.id) },
|
|
{ ...ctx, physicalCards: [...ctx.physicalCards, printedKarama] },
|
|
{ ...ctx, physicalCards: ctx.physicalCards.map(card =>
|
|
card.id === printedKarama.id ? { ...card, effect: 'fabricated' } : card) },
|
|
{ ...ctx, players: ctx.players.map(player => player.id === 'rival'
|
|
? { ...player, hand: [...player.hand, printedKarama] } : player) },
|
|
{ ...ctx, players: ctx.players.map(player => player.id === 'rival'
|
|
? { ...player, hand: [] } : { ...player, hand: [...player.hand, directTarget] }) },
|
|
{ ...ctx, players: ctx.players.map(player => player.id === 'buyer'
|
|
? { ...player, hand: [...player.hand, directTarget] } : player) },
|
|
{ ...ctx, players: ctx.players.map(player => player.id === 'rival'
|
|
? { ...player, hand: [{ ...directTarget, name: 'Altered cache card' }] } : player),
|
|
physicalCards: ctx.physicalCards.map(card => card.id === directTarget.id
|
|
? { ...card, name: 'Altered cache card' } : card) },
|
|
];
|
|
for (const invalid of variants) {
|
|
assert.throws(() => source(invalid), IxianReplacementError);
|
|
assert.throws(() => validateIxianReplacementSource(invalid, original, parent), IxianReplacementError);
|
|
}
|
|
assert.throws(() => validateIxianReplacementSource(
|
|
{ ...ctx, sequence: 1 }, original, parent,
|
|
), IxianReplacementError);
|
|
assert.throws(() => validateIxianReplacementSource(ctx, original, 'changed-parent'), IxianReplacementError);
|
|
assert.throws(() => createIxianReplacementSource(ctx, 'stale-direct-event', parent), IxianReplacementError);
|
|
});
|
|
|
|
void test('a different owned cache card cannot retarget an existing direct receipt', () => {
|
|
const ctx = specialContext();
|
|
const original = source(ctx);
|
|
const another = richeseCards()[0];
|
|
ctx.players = ctx.players.map(player => player.id === 'rival'
|
|
? { ...player, hand: [...player.hand, another] } : player);
|
|
ctx.physicalCards = [...ctx.physicalCards, another];
|
|
ctx.specialPurchase = { ...ctx.specialPurchase!, cardId: another.id };
|
|
assert.throws(() => validateIxianReplacementSource(ctx, original, parent), IxianReplacementError);
|
|
});
|
|
|
|
void test('native cache and Black Market purchases close exact delivered cards once for Use or Pass', () => {
|
|
for (const origin of ['cache', 'blackMarket'] as const) {
|
|
const ctx = nativeContext(origin);
|
|
const original = source(ctx);
|
|
const delivered = ctx.players[0].hand.find(card => card.id === ctx.richeseAuction!.cardId)!;
|
|
assert.deepEqual(original.card, delivered);
|
|
assert.equal(original.amount, 7);
|
|
assert.equal(original.free, false);
|
|
assert.equal(original.auctionIndex, 0);
|
|
validateIxianReplacementSource(ctx, JSON.parse(JSON.stringify(original)), parent);
|
|
for (const outcome of ['use', 'pass'] as const) {
|
|
const closed = closeIxianReplacementSource(
|
|
original, outcome, outcome === 'use' ? top : null, initialIxianReplacementCursor(),
|
|
);
|
|
assert.equal(closed.receipt.outcome, outcome);
|
|
assert.deepEqual(closed.receipt.replacement, outcome === 'use' ? top : null);
|
|
assert.throws(() => closeIxianReplacementSource(original, 'pass', null, closed.cursor), IxianReplacementError);
|
|
assert.throws(() => closeIxianReplacementSource(original, 'use', top, closed.cursor), IxianReplacementError);
|
|
// A later census need not retain the purchased or drawn card at its old location.
|
|
validateIxianReplacementHistory(
|
|
JSON.parse(JSON.stringify([closed.receipt])), closed.cursor, [older],
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('successive single native lots use the independent sequence without inventing normal rows', () => {
|
|
const cache = nativeContext('cache');
|
|
const first = closeIxianReplacementSource(source(cache), 'pass', null, initialIxianReplacementCursor());
|
|
const blackMarket = nativeContext('blackMarket');
|
|
blackMarket.sequence = first.cursor.sequence;
|
|
const second = closeIxianReplacementSource(source(blackMarket), 'use', top, first.cursor);
|
|
assert.equal(first.receipt.source.auctionIndex, 0);
|
|
assert.equal(second.receipt.source.auctionIndex, 0);
|
|
assert.notEqual(first.receipt.source.event, second.receipt.source.event);
|
|
validateIxianReplacementHistory([first.receipt, second.receipt], second.cursor);
|
|
});
|
|
|
|
void test('native purchase authority rejects missing, open, unbid, mismatched and competing lots', () => {
|
|
for (const origin of ['cache', 'blackMarket'] as const) {
|
|
const ctx = nativeContext(origin);
|
|
const lot = ctx.richeseAuction!;
|
|
const original = source(ctx);
|
|
const variants: IxianReplacementContext[] = [
|
|
{ ...ctx, richeseAuction: null },
|
|
{ ...ctx, richeseAuction: { ...lot, outcome: null } },
|
|
{ ...ctx, richeseAuction: { ...lot, outcome: { kind: 'unbid' } } },
|
|
{ ...ctx, richeseAuction: { ...lot, outcome: { kind: 'sold', winner: 'buyer', amount: 0 } } },
|
|
{ ...ctx, richeseAuction: { ...lot, outcome: { kind: 'sold', winner: 'rival', amount: 7 } } },
|
|
{ ...ctx, richeseAuction: { ...lot, outcome: { kind: 'sold', winner: 'buyer', amount: 8 } } },
|
|
{ ...ctx, richeseAuction: { ...lot, source: origin === 'cache' ? 'blackMarket' : 'cache' } },
|
|
{ ...ctx, richeseAuction: { ...lot, cardId: unsold.id } },
|
|
{ ...ctx, richeseAuction: { ...lot, owner: 'unseated' }, sale: { ...ctx.sale!, seller: 'unseated' } },
|
|
{ ...ctx, sale: { ...ctx.sale!, seller: null } },
|
|
{ ...ctx, sale: { ...ctx.sale!, seller: 'buyer' } },
|
|
{ ...ctx, sale: { ...ctx.sale!, amount: 8 } },
|
|
{ ...ctx, sale: { ...ctx.sale!, free: true } },
|
|
{ ...ctx, sale: { ...ctx.sale!, origin: 'normal', seller: null } },
|
|
{ ...ctx, sale: null },
|
|
{ ...ctx, auction: context().auction },
|
|
{ ...ctx, expansions: [] },
|
|
{ ...ctx, richese: false },
|
|
...(origin === 'blackMarket' ? [{ ...ctx, advanced: false }] : []),
|
|
];
|
|
for (const invalid of variants) {
|
|
assert.throws(() => source(invalid), IxianReplacementError);
|
|
assert.throws(() => validateIxianReplacementSource(invalid, original, parent), IxianReplacementError);
|
|
}
|
|
// Another canonical card already in the hand cannot replace the actual lot.
|
|
const swapped = { ...ctx, richeseAuction: { ...lot, cardId: older.id } };
|
|
assert.throws(() => validateIxianReplacementSource(swapped, original, parent), IxianReplacementError);
|
|
const absent = {
|
|
...ctx, players: [{ ...ctx.players[0], hand: [older] }, ctx.players[1]],
|
|
};
|
|
assert.throws(() => source(absent), IxianReplacementError);
|
|
const duplicate = {
|
|
...ctx, players: [{ ...ctx.players[0], hand: [...ctx.players[0].hand, original.card] }, ctx.players[1]],
|
|
};
|
|
assert.throws(() => source(duplicate), IxianReplacementError);
|
|
}
|
|
});
|
|
|
|
void test('normal purchases preserve their actual nonzero lot index and saved continuation', () => {
|
|
const ctx = context();
|
|
ctx.auction = { ...ctx.auction!, cards: [unsold, purchased], index: 1 };
|
|
const original = createIxianReplacementSource(
|
|
ctx, ixianReplacementEvent(ctx.turn, 'buyer', ctx.sequence, 1), parent,
|
|
);
|
|
assert.equal(original.auctionIndex, 1);
|
|
validateIxianReplacementSource(ctx, JSON.parse(JSON.stringify(original)), parent);
|
|
const closed = closeIxianReplacementSource(original, 'pass', null, initialIxianReplacementCursor());
|
|
validateIxianReplacementHistory(JSON.parse(JSON.stringify([closed.receipt])), closed.cursor);
|
|
});
|
|
|
|
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('selected CHOAM retains all original 35 faces alongside separate native Richese custody', () => {
|
|
const deck = treacheryDeck(['choam']);
|
|
const cache = richeseCards();
|
|
assert.equal(deck.length, 35);
|
|
assert.equal(cache.length, 10);
|
|
assert.deepEqual(choamDeck().map(card => card.id).sort(), ['ix-artillery', 'ix-poison-tooth']);
|
|
for (const card of deck) {
|
|
const ctx = purchaseContext(card, ['choam'], true);
|
|
ctx.physicalCards = [...deck, ...cache];
|
|
assert.deepEqual(source(ctx).card, card);
|
|
}
|
|
});
|
|
|
|
void test('base and Ix families remain selected by actual membership, including shared CHOAM identities', () => {
|
|
for (const card of baseDeck()) {
|
|
assert.deepEqual(source(purchaseContext(card, [])).card, card);
|
|
assert.deepEqual(source(purchaseContext(card, ['choam'])).card, card);
|
|
}
|
|
const shared = choamDeck();
|
|
for (const card of ixDeck()) {
|
|
assert.deepEqual(source(purchaseContext(card, ['ix'])).card, card);
|
|
assert.deepEqual(source(purchaseContext(card, ['ix', 'choam'])).card, card);
|
|
assert.throws(() => source(purchaseContext(card, [])), IxianReplacementError);
|
|
const choamOnly = purchaseContext(card, ['choam']);
|
|
if (shared.some(member => member.id === card.id))
|
|
assert.deepEqual(source(choamOnly).card, card);
|
|
else
|
|
assert.throws(() => source(choamOnly), IxianReplacementError);
|
|
}
|
|
});
|
|
|
|
void test('every original cache face requires both selected CHOAM and native Richese even after normal recycling', () => {
|
|
for (const card of richeseCards()) {
|
|
const ctx = purchaseContext(card, ['choam'], true);
|
|
assert.deepEqual(source(ctx).card, card);
|
|
for (const expansions of [[], ['ix'], ['choam'], ['ix', 'choam']]) {
|
|
for (const richese of [false, true]) {
|
|
if (expansions.includes('choam') && richese) continue;
|
|
assert.throws(() => source(purchaseContext(card, expansions, richese)), IxianReplacementError);
|
|
}
|
|
}
|
|
for (const origin of ['cache', 'blackMarket'] as const) {
|
|
const special = purchaseContext(card, ['choam'], true);
|
|
special.sale = { ...special.sale!, origin };
|
|
assert.throws(() => source(special), IxianReplacementError);
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('an ordinary purchase cannot hide unsupported card families elsewhere in live custody', () => {
|
|
for (const card of [...ixDeck(), ...richeseCards()]) {
|
|
const ctx = context();
|
|
ctx.physicalCards = [...ctx.physicalCards, card];
|
|
assert.throws(() => source(ctx), IxianReplacementError);
|
|
}
|
|
for (const card of richeseCards()) {
|
|
for (const [expansions, richese] of [
|
|
[['choam'], false], [['ix'], true], [[], true],
|
|
] as const) {
|
|
const ctx = context();
|
|
ctx.expansions = expansions;
|
|
ctx.richese = richese;
|
|
ctx.players = [ctx.players[0], { ...ctx.players[1], hand: [card] }];
|
|
ctx.physicalCards = [...ctx.physicalCards, card];
|
|
assert.throws(() => source(ctx), IxianReplacementError);
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('CHOAM and cache faces reject altered descriptors even with matching row, hand and custody', () => {
|
|
for (const card of [...treacheryDeck(['choam']), ...richeseCards()]) {
|
|
const altered: Card[] = [
|
|
{ ...card, name: `${card.name} altered` },
|
|
{ ...card, kind: card.kind === 'special' ? 'projectile' : 'special' },
|
|
{ ...card, effect: 'fabricated' },
|
|
{ ...card, id: `${card.id}-copy` },
|
|
{ ...card, extra: true } as Card,
|
|
];
|
|
if (card.effect !== undefined) {
|
|
const missingEffect = { ...card };
|
|
delete missingEffect.effect;
|
|
altered.push(missingEffect);
|
|
}
|
|
for (const corrupt of altered)
|
|
assert.throws(() => source(purchaseContext(corrupt, ['choam'], true)), IxianReplacementError);
|
|
}
|
|
});
|
|
|
|
void test('historical receipts admit the full canonical union without weakening live family gates', () => {
|
|
const [shared] = choamDeck();
|
|
const [cache] = richeseCards();
|
|
const original = source(purchaseContext(shared, ['choam'], true));
|
|
const first = closeIxianReplacementSource(original, 'use', cache, initialIxianReplacementCursor());
|
|
const later = purchaseContext(cache, ['choam'], true);
|
|
later.turn = 2;
|
|
later.sequence = first.cursor.sequence;
|
|
const cacheSource = source(later);
|
|
const second = closeIxianReplacementSource(cacheSource, 'use', cache, first.cursor);
|
|
const history = JSON.parse(JSON.stringify([first.receipt, second.receipt]));
|
|
validateIxianReplacementHistory(history, second.cursor, [...treacheryDeck(['ix']), ...richeseCards()]);
|
|
assert.equal(second.receipt.replacement?.id, cacheSource.card.id);
|
|
assert.throws(() => validateIxianReplacementSource(
|
|
{ ...later, richese: false }, cacheSource, parent,
|
|
), IxianReplacementError);
|
|
assert.throws(() => validateIxianReplacementHistory(history, second.cursor, [
|
|
...treacheryDeck(['ix']), ...richeseCards(), { ...cache },
|
|
]), 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);
|
|
});
|