import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine'; import { nexusInventory, nexusReload } from './fixture-nexus-cards'; import { wonEmperorNexusBankAuction } from './fixture-nexus-emperor-purchase'; const owner = 'q'; function reject(g: Game, player: string, action: Action, reason: RegExp) { const before = JSON.stringify(g); assert.throws(() => applyAction(g, player, action), reason); assert.equal(JSON.stringify(g), before); } void test('real Basic/Advanced normal auction purchase keeps exactly the personally affordable bid and spends the physical Nexus card', () => { for (const advanced of [false, true]) { const g = wonEmperorNexusBankAuction(advanced); const price = g.auction!.bid; const lot = g.auction!.cards[g.auction!.index]; const before = JSON.stringify(g); const ownerView = viewGame(g, owner); assert.deepEqual(ownerView.nexusEmperorSecretAlly!.purchase, { blocked: null, price }); for (const id of ['p', 'r']) { assert.equal(viewGame(g, id).nexusEmperorSecretAlly, null); assert.equal(JSON.stringify(viewGame(g, id)).includes('nexusEmperorPurchaseHistory'), false); } const action = { type: 'nexusEmperorPurchase', event: ownerView.nexusEmperorSecretAlly!.event }; const done = applyAction(g, owner, action); assert.equal(JSON.stringify(g), before); nexusInventory(done); assert.equal(done.players.find(p => p.id === owner)!.spice, g.players.find(p => p.id === owner)!.spice); assert.equal(done.players.find(p => p.id === owner)!.hand.filter(card => card.id === lot.id).length, 1); assert.equal(done.nexusCards!.cards!.hands[owner], null); assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1); assert.deepEqual(done.nexusEmperorPurchaseEvents, [action.event]); assert.equal(done.nexusEmperorPurchaseHistory?.[0].card, lot.id); assert.equal(done.nexusEmperorPurchaseHistory?.[0].price, price); assert.equal(done.nexusEmperorPurchaseHistory?.[0].afterSpice, done.nexusEmperorPurchaseHistory?.[0].beforeSpice); assert.equal(done.log.filter(entry => entry.text.includes('spent Emperor Nexus Secret Ally after proving')).length, 1); for (const p of done.players) assert.deepEqual(viewGame(nexusReload(done), p.id), viewGame(done, p.id)); reject(done, owner, action, /Emperor Nexus|current|payment|card/i); } }); void test('every eligible winner confirms payment regardless of concealed Emperor card ownership', () => { const withCard = wonEmperorNexusBankAuction(false); const without = nexusReload(withCard); without.nexusCards!.cards!.hands[owner] = null; without.nexusCards!.cards!.deck.push('emperor'); const waitingWith = normalizeAutomaticGame(withCard); const waitingWithout = normalizeAutomaticGame(without); assert.deepEqual(waitingWith.decision, { kind: 'auctionPayment', player: owner }); assert.deepEqual(waitingWithout.decision, waitingWith.decision); assert.equal(viewGame(waitingWithout, owner).nexusEmperorSecretAlly, null); const paid = applyAction(waitingWithout, owner, { type: 'decision', karama: false }); assert.equal(paid.players.find(p => p.id === owner)!.spice, without.players.find(p => p.id === owner)!.spice - without.auction!.bid); assert.equal(paid.players.find(p => p.id === owner)!.hand.filter( card => card.id === without.auction!.cards[without.auction!.index].id).length, 1); nexusInventory(paid); }); void test('a prior normal purchase cannot authorize spending Emperor Nexus on a later lot or revised bid', () => { let g = wonEmperorNexusBankAuction(false); const first = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event }; const revised = nexusReload(g); revised.auction!.bid++; assert.notEqual(viewGame(revised, owner).nexusEmperorSecretAlly!.event, first.event); reject(revised, owner, first, /current Emperor Nexus/i); g = applyAction(g, owner, { type: 'decision', karama: false }); for (let i = 0; g.decision?.kind !== 'auctionPayment' && i < 20; i++) { // Native purchased-card choices now initialize at fresh setup for classic // core games; the paid lot opens its native replacement window first. if (g.decision?.kind === 'nexusIxianReplacement') g = applyAction(g, g.decision.player, { type: 'nexusIxianReplacementPass', event: g.decision.event }); else if (g.auction?.active === owner && !g.auction.bid) g = applyAction(g, owner, { type: 'bid', amount: 2 }); else if (g.auction) g = applyAction(g, g.auction.active, { type: 'passBid' }); } assert.equal(g.auction?.index, 1); assert.equal(g.decision?.player, owner); assert.notEqual(viewGame(g, owner).nexusEmperorSecretAlly!.event, first.event); reject(g, owner, first, /current Emperor Nexus/i); const second = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event }; const done = applyAction(g, owner, second); const receipt = done.nexusEmperorPurchaseHistory?.[0]; assert.ok(receipt && 'auctionIndex' in receipt); assert.equal(receipt.auctionIndex, 1); }); void test('invalid normal-lot custody and full winning hand cannot spend the physical Nexus card', () => { const g = wonEmperorNexusBankAuction(false); const action = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event }; const duplicate = nexusReload(g); duplicate.deck.push(structuredClone(g.auction!.cards[g.auction!.index])); // Duplicate card custody is now rejected by the native purchased-card // integrity check before the Emperor purchase gate is even consulted. assert.throws(() => viewGame(duplicate, owner), /single physical custody/); reject(duplicate, owner, action, /single physical custody/); const full = nexusReload(g); full.players.find(p => p.id === owner)!.hand.push(...full.deck.splice(0, 4)); assert.match(viewGame(full, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /room for the committed/); reject(full, owner, action, /room for the committed/); }); void test('stale, foreign, malformed, underfunded and ally-funded purchase attempts reject before card or auction mutation', () => { const g = wonEmperorNexusBankAuction(false); const action = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event }; reject(g, 'p', action, /Emperor Nexus|current|payment/i); reject(g, owner, { ...action, event: 'stale' }, /current|payment/i); reject(g, owner, { ...action, price: 0 }, /current|payment/i); const underfunded = nexusReload(g); underfunded.players.find(p => p.id === owner)!.spice = g.auction!.bid - 1; assert.match(viewGame(underfunded, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /own spice/); reject(underfunded, owner, action, /own spice/); const allyFunded = nexusReload(g); allyFunded.auction!.allyPayment = 1; assert.match(viewGame(allyFunded, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /own spice/); reject(allyFunded, owner, action, /own spice/); }); void test('signed historical purchase rejects a changed price, lot, proof or missing independent-use marker after JSON restore', () => { const g = wonEmperorNexusBankAuction(true); const action = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event }; const done = applyAction(g, owner, action); for (const tamper of [ (copy: Game) => { copy.nexusEmperorPurchaseHistory![0].price++; }, (copy: Game) => { copy.nexusEmperorPurchaseHistory![0].card = 'forged-lot'; }, (copy: Game) => { copy.nexusEmperorPurchaseHistory![0].afterSpice--; }, (copy: Game) => { delete copy.nexusEmperorPurchaseEvents; }, (copy: Game) => { copy.nexusEmperorPurchaseEvents!.push(action.event); }, ]) { const copy = nexusReload(done); tamper(copy); const before = JSON.stringify(copy); assert.throws(() => viewGame(copy, owner), /Emperor Nexus purchase/); assert.equal(JSON.stringify(copy), before); } });