import assert from 'node:assert/strict'; import test from 'node:test'; import {applyAction, initializeGuildBetrayalGameForAudit, normalizeAutomaticGame, viewGame, type Action, type Game} from '../game/engine'; import {validateNexusCards} from '../game/nexus-cards'; import {homeworldGameIntegrity} from '../game/homeworld-game'; import {signGuildBetrayalInvoice} from '../game/nexus-guild-betrayal'; import {createGuildBetrayalFixture, settleGuildBetrayalFixture, type GuildBetrayalFixture, type GuildBetrayalFixtureOptions} from './fixture-nexus-guild-betrayal'; function physicalShipment(game: Game) { return {custody: game.homeworlds?.custody, forces: game.players.map(p => ({id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites, advisors: p.advisors, shipped: p.shipped, moved: p.moved})), active: game.active, remaining: game.movementRemaining}; } function rejects(game: Game, actor: string, action: Action) { const saved = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, saved, 'Rejected actions cannot alter the saved funded declaration.'); } function take(fixture: GuildBetrayalFixture): Game { return applyAction(JSON.parse(JSON.stringify(fixture.game)) as Game, fixture.holder, {type: 'guildBetrayalUse', event: fixture.event}); } function proveFullPayment(fixture: GuildBetrayalFixture): void { const done = take(fixture); assert.deepEqual(physicalShipment(done), physicalShipment(fixture.original), 'The same native source/delivery suffix runs exactly once.'); assert.equal(done.pendingGuildBetrayal, null); const invoice = fixture.game.pendingGuildBetrayal!.invoice; for (const p of done.players) { const prior = fixture.before.players.find(seat => seat.id === p.id)!; assert.equal(p.spice, prior.spice - (p.id === fixture.shipper ? invoice.ownPayment : 0) + (p.id === fixture.holder ? fixture.price : 0), `Full fee has exactly one receiver: ${p.id}`); } if (fixture.donor) assert.equal(done.aid[fixture.donor].amount, fixture.before.aid[fixture.donor].amount - invoice.allyPayment); validateNexusCards(done.nexusCards!.cards!, done.players); assert.equal(done.nexusCards!.cards!.hands[fixture.holder], null); assert.equal(done.nexusCards!.cards!.discard.at(-1), 'guild'); assert.equal(done.guildBetrayal!.status, 'completed'); const receipt = done.nexusGuildBetrayalHistory!.at(-1)!; assert.equal(receipt.invoice.price, fixture.price); assert.equal(receipt.recipient, fixture.holder); assert.equal(receipt.mode, 'betrayal'); assert.equal(done.guildBetrayalSourceReceipts!.at(-1)!.receipt, receipt.sourceReceipt); if (done.homeworlds) homeworldGameIntegrity(done); rejects(done, fixture.holder, {type: 'guildBetrayalUse', event: fixture.event}); rejects(done, fixture.shipper, fixture.declaration); } for (const advanced of [false, true]) { for (const source of ['reserve', 'guildTransport', 'homeworld', 'junction'] as const) { void test(`${advanced ? 'Advanced' : 'Basic'} ${source}: full original funded fee replaces native bank/Guild income and preserves native custody`, () => { proveFullPayment(createGuildBetrayalFixture({advanced, source})); }); } for (const source of ['reserve', 'homeworld', 'junction'] as const) { void test(`${advanced ? 'Advanced' : 'Basic'} ${source}: own payment is funded upfront and then fully refunded`, () => { const fixture = createGuildBetrayalFixture({advanced, source, payer: 'holder'}); proveFullPayment(fixture); const prior = fixture.before.players.find(p => p.id === fixture.holder)!.spice; assert.equal(take(fixture).players.find(p => p.id === fixture.holder)!.spice, prior); }); } void test(`${advanced ? 'Advanced' : 'Basic'} all-pass preserves the actual default payment and keeps every Nexus card`, () => { const fixture = createGuildBetrayalFixture({advanced, source: 'reserve'}); let done = fixture.game; const hands = structuredClone(done.nexusCards!.cards!.hands); for (const id of fixture.game.pendingGuildBetrayal!.required) done = applyAction(done, id, {type: 'guildBetrayalPass', event: fixture.event}); done = settleGuildBetrayalFixture(done); assert.deepEqual(physicalShipment(done), physicalShipment(fixture.original)); assert.deepEqual(done.players.map(p => p.spice), fixture.original.players.map(p => p.spice)); assert.deepEqual(done.nexusCards!.cards!.hands, hands); assert.equal(done.nexusGuildBetrayalHistory!.at(-1)!.recipient, null); assert.equal(done.players.find(p => p.id === fixture.guild)!.spice, fixture.before.players.find(p => p.id === fixture.guild)!.spice + fixture.price); }); } for (const options of [ {source: 'reserve', allyPayment: 1}, {source: 'homeworld', allyPayment: 1}, {source: 'junction', allyPayment: 1}, {source: 'guildTransport', allyPayment: 1}, {source: 'reserve', homeworlds: true, occupiedJunction: true}, {source: 'reserve', homeworlds: true, occupiedJunction: true, allyPayment: 1}, {source: 'guildTransport', homeworlds: true}, ] satisfies GuildBetrayalFixtureOptions[]) { void test(`Funded native ${options.source} preserves ally escrow/low-Junction full fee ${JSON.stringify(options)}`, () => { const fixture = createGuildBetrayalFixture(options); proveFullPayment(fixture); if ('occupiedJunction' in options) { const oldGuild = fixture.before.players.find(p => p.id === fixture.guild)!.spice; assert.equal(fixture.original.players.find(p => p.id === fixture.guild)!.spice - oldGuild, Math.ceil(fixture.price / 2)); assert.equal(fixture.game.homeworlds!.custody!.visitors['homeworld:guild'][fixture.holder].normal, 1); } }); } void test('Own refund cannot supply missing upfront spice; zero Fremen reinforcement has no payment gate or Nexus cost', () => { const own = createGuildBetrayalFixture({payer: 'holder'}); const unfunded = structuredClone(own.before); unfunded.players.find(p => p.id === own.holder)!.spice = 0; rejects(unfunded, own.holder, own.declaration); assert.equal(unfunded.nexusCards!.cards!.hands[own.holder], 'guild'); for (const advanced of [false, true]) { const free = createGuildBetrayalFixture({advanced, zero: true}); assert.equal(free.price, 0); assert.equal(free.game.pendingGuildBetrayal, null); assert.equal(free.game.nexusCards!.cards!.hands[free.holder], 'guild'); assert.equal(free.game.players.find(p => p.id === free.shipper)!.spice, free.before.players.find(p => p.id === free.shipper)!.spice); const destination = `${String(free.declaration.territory)}:${Number(free.declaration.sector)}`; assert.notEqual(Number(free.declaration.sector), free.before.storm); assert.equal(free.game.players.find(p => p.id === free.shipper)!.forces[destination], (free.before.players.find(p => p.id === free.shipper)!.forces[destination] ?? 0) + 2); } }); void test('Advanced Guild special Karama stops before any positive invoice, refund or Nexus cost', () => { const fixture = createGuildBetrayalFixture({advanced: true}); let game = structuredClone(fixture.before); const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0); const card = game.deck.splice(index, 1)[0]; game.players.find(p => p.id === fixture.guild)!.hand.push(card); const originalSpice = game.players.map(p => p.spice); const originalForces = game.players.map(p => ({forces: p.forces, reserves: p.reserves})); game = applyAction(game, fixture.shipper, fixture.declaration); assert.equal(game.decision?.kind, 'guildShipment'); game = applyAction(game, fixture.guild, {type: 'card', mode: 'special', card: card.id}); game = settleGuildBetrayalFixture(game); assert.equal(game.pendingGuildBetrayal, null); assert.equal(game.nexusCards!.cards!.hands[fixture.holder], 'guild'); assert.deepEqual(game.players.map(p => p.spice), originalSpice); assert.deepEqual(game.players.map(p => ({forces: p.forces, reserves: p.reserves})), originalForces); }); void test('A purchased native Karama rate binds its actual bank-funded fee, not the ordinary tariff', () => { const fixture = createGuildBetrayalFixture({payer: 'other'}); let game = structuredClone(fixture.before); const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0); const card = game.deck.splice(index, 1)[0]; game.players.find(p => p.id === fixture.shipper)!.hand.push(card); game = applyAction(game, fixture.shipper, {type: 'card', mode: 'shipment', card: card.id, target: fixture.shipper}); game = settleGuildBetrayalFixture(game); const before = structuredClone(game); game = applyAction(game, fixture.shipper, {...fixture.declaration, amount: 3}); game = applyAction(game, fixture.holder, {type: 'guildBetrayalUse', event: game.pendingGuildBetrayal!.invoice.event}); assert.equal(game.players.find(p => p.id === fixture.shipper)!.spice, before.players.find(p => p.id === fixture.shipper)!.spice - 2); assert.equal(game.players.find(p => p.id === fixture.holder)!.spice, before.players.find(p => p.id === fixture.holder)!.spice + 2); assert.equal(game.players.find(p => p.id === fixture.guild)!.spice, before.players.find(p => p.id === fixture.guild)!.spice); assert.equal(game.karamaShipping, null); }); void test('The fixed neutral projection exposes only event/shipper and private legal choices; pending payment preempts every underlying action', () => { const fixture = createGuildBetrayalFixture({payer: 'holder'}); const otherFace = structuredClone(fixture.before); const cards = otherFace.nexusCards!.cards!; const index = cards.deck.indexOf('choam'); assert.ok(index >= 0); cards.deck[index] = 'guild'; cards.hands[fixture.holder] = 'choam'; const hidden = applyAction(otherFace, fixture.shipper, fixture.declaration); for (const p of fixture.game.players) { const left = viewGame(fixture.game, p.id).guildBetrayalReaction!; const right = viewGame(hidden, p.id).guildBetrayalReaction!; assert.deepEqual(Object.keys(left).sort(), ['blocked', 'canPass', 'canUse', 'event', 'hasPassed', 'shipper']); assert.deepEqual({event: left.event, shipper: left.shipper, canPass: left.canPass, hasPassed: left.hasPassed}, {event: right.event, shipper: right.shipper, canPass: right.canPass, hasPassed: right.hasPassed}); if (p.id !== fixture.holder) assert.deepEqual(left, right); } assert.equal(viewGame(fixture.game, fixture.holder).guildBetrayalReaction!.canUse, true); assert.equal(viewGame(hidden, fixture.holder).guildBetrayalReaction!.canUse, false); for (const action of [fixture.declaration, {type: 'endMovement'}, {type: 'ready'}, {type: 'pledgeAid', amount: 0}, {type: 'nexusGuildCunning'}, {type: 'guildBetrayalUse', event: 'old'}, {type: 'guildBetrayalUse', event: fixture.event, recipient: fixture.holder}, {type: 'guildBetrayalUse', event: fixture.event, amount: fixture.price}]) rejects(fixture.game, fixture.holder, action); const passed = applyAction(fixture.game, fixture.holder, {type: 'guildBetrayalPass', event: fixture.event}); assert.equal(viewGame(passed, fixture.holder).guildBetrayalReaction!.hasPassed, true); rejects(passed, fixture.holder, {type: 'guildBetrayalUse', event: fixture.event}); assert.deepEqual(normalizeAutomaticGame(passed), passed); }); void test('Malformed, stale, orphaned and suspended native parents reject before apply, projection or automatic normalization', () => { const fixture = createGuildBetrayalFixture(); const mutations: ((game: Game) => void)[] = [ game => {delete game.pendingGuildBetrayal;}, game => {delete game.guildBetrayal;}, game => {game.guildBetrayal!.event = 'foreign';}, game => {game.pendingGuildBetrayal!.invoice.price += 1;}, game => {game.pendingGuildBetrayal!.invoice.ownPayment = 0;}, game => {game.pendingGuildBetrayal!.passed.push(fixture.holder);}, game => {game.pendingGuildBetrayal!.required = [fixture.holder];}, game => {game.pendingGuildBetrayal!.signature = 'forged';}, game => {game.players.find(p => p.id === fixture.shipper)!.spice = 0;}, game => {game.players.find(p => p.id === fixture.shipper)!.reserves -= 1;}, game => {game.active = fixture.guild;}, game => {game.turn += 1;}, game => {game.decision = {kind: 'guildShipment', player: fixture.guild, shipper: fixture.shipper, territory: 'arrakeen', sector: 10, amount: 2};}, ]; for (const mutate of mutations) { const game = structuredClone(fixture.game); mutate(game); const saved = structuredClone(game); assert.throws(() => viewGame(game, fixture.holder)); assert.throws(() => normalizeAutomaticGame(game)); rejects(game, fixture.holder, {type: 'guildBetrayalUse', event: fixture.event}); assert.deepEqual(game, saved); } const completed = take(fixture); for (const mutate of [ (game: Game) => {game.nexusGuildBetrayalHistory!.pop();}, (game: Game) => {game.guildBetrayalSourceReceipts!.pop();}, (game: Game) => {game.nexusGuildBetrayalHistory![0].signature = 'forged';}, (game: Game) => {game.players.find(p => p.id === fixture.shipper)!.shipped = false;}, ]) { const game = structuredClone(completed); mutate(game); assert.throws(() => viewGame(game, fixture.holder)); assert.throws(() => normalizeAutomaticGame(game)); } assert.throws(() => initializeGuildBetrayalGameForAudit(fixture.game), /fresh/); }); void test('No publicly possible holder auto-settles the native fee without a fabricated Nexus payment window', () => { const fixture = createGuildBetrayalFixture(); const before = structuredClone(fixture.before); const cards = before.nexusCards!.cards!; for (const p of before.players) { const face = cards.hands[p.id]; if (face !== null) {cards.deck.push(face); cards.hands[p.id] = null;} } const done = settleGuildBetrayalFixture(applyAction(before, fixture.shipper, fixture.declaration)); assert.equal(done.pendingGuildBetrayal, null); assert.equal(done.players.find(p => p.id === fixture.guild)!.spice, before.players.find(p => p.id === fixture.guild)!.spice + fixture.price); assert.equal(done.players.find(p => p.id === fixture.holder)!.spice, before.players.find(p => p.id === fixture.holder)!.spice); assert.deepEqual(physicalShipment(done), physicalShipment(fixture.original)); validateNexusCards(done.nexusCards!.cards!, done.players); }); function outsidePaymentProfile(game: Game): Game { const native = structuredClone(game); delete native.guildBetrayalPreview; delete native.guildBetrayal; delete native.pendingGuildBetrayal; delete native.nexusGuildBetrayalHistory; delete native.guildBetrayalSourceReceipts; return native; } void test('Saved native cross permission is independently required even for a self-consistent paid frame or purchased Karama rate', () => { for (const purchasedRate of [false, true]) { const fixture = createGuildBetrayalFixture({source: 'guildTransport', payer: 'other'}); let game = structuredClone(fixture.game); if (purchasedRate) { game = structuredClone(fixture.before); const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0); const card = game.deck.splice(index, 1)[0]; game.players.find(p => p.id === fixture.shipper)!.hand.push(card); game = settleGuildBetrayalFixture(applyAction(game, fixture.shipper, {type: 'card', mode: 'shipment', card: card.id, target: fixture.shipper})); game = applyAction(game, fixture.shipper, fixture.declaration); } // Re-sign only the changed saved alliance facts, not a copied native quote. // All counters, source, destination, tariff and full funding remain genuine. game.players.find(p => p.id === fixture.shipper)!.ally = null; game.players.find(p => p.id === fixture.guild)!.ally = null; const frame = game.pendingGuildBetrayal!; const proof = JSON.parse(frame.invoice.sourceSignature) as {players: {id: string; ally: string | null}[]}; for (const seat of proof.players) seat.ally = game.players.find(p => p.id === seat.id)!.ally; frame.invoice.sourceSignature = JSON.stringify(proof); frame.invoice.originalReceiver = purchasedRate ? null : fixture.guild; frame.invoice.signature = signGuildBetrayalInvoice(frame.invoice); frame.signature = JSON.stringify([frame.invoice.signature, frame.invoice.sourceSignature, frame.required, frame.passed]); game.guildBetrayal!.sourceSignature = frame.invoice.sourceSignature; const saved = structuredClone(game); for (const operation of [ () => viewGame(game, fixture.holder), () => normalizeAutomaticGame(game), () => applyAction(game, fixture.holder, {type: 'guildBetrayalUse', event: frame.invoice.event}), ]) { assert.throws(operation, /Native Guild transport requires the Guild or its reciprocal ally/); assert.deepEqual(game, saved, 'A saved paid rate cannot self-authorize native cross transport or move resources.'); } } }); void test('Native Guild and reciprocal Guild ally cross shipments remain legal outside the bounded profile', () => { for (const payer of ['guild', 'other'] as const) { const fixture = createGuildBetrayalFixture({source: 'guildTransport', payer}); proveFullPayment(fixture); const before = outsidePaymentProfile(fixture.before); const done = settleGuildBetrayalFixture(applyAction(before, fixture.shipper, fixture.declaration)); const prior = before.players.find(p => p.id === fixture.shipper)!; const shipper = done.players.find(p => p.id === fixture.shipper)!; assert.equal(shipper.shipped, true); assert.deepEqual(physicalShipment(done), physicalShipment(fixture.original)); assert.equal(shipper.spice, prior.spice - fixture.price); assert.equal(done.nexusCards!.cards!.hands[fixture.holder], 'guild'); } });