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); });