import assert from 'node:assert/strict'; import test from 'node:test'; import { viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { actBoxShipmentPrice, allowBoxShipmentPrice, boxShipmentPriceInventory, boxShipmentPricePhysical, boxShipmentPricePlayer as player, createBoxShipmentPriceFixture, declareBoxShipmentPrice, finishBoxShipmentPrice, payBoxShipmentPrice, selectBoxShipmentPrice, stopBoxShipmentPrice, type BoxShipmentPriceFixture, } from './fixture-box-shipment-price'; function rejected(game: Game, actor: string, action: Action) { const before = structuredClone(game); assert.throws(() => actBoxShipmentPrice(game, actor, action)); assert.deepEqual(game, before); } function originalSource(f: BoxShipmentPriceFixture) { assert.equal(f.game.status, 'playing'); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.beneficiary); assert.equal(f.game.response ?? null, null); assert.equal(f.game.decision ?? null, null); assert.equal(f.game.pendingNullentropy ?? null, null); assert.equal(f.game.pendingTreacheryDiscard ?? null, null); assert.deepEqual(f.game.karamaShipping, f.originalGrant); assert.equal(f.game.karamaShipping!.card, f.karama); assert.equal(f.game.karamaShipping!.owner, f.creator); assert.equal(f.game.karamaShipping!.player, f.beneficiary); assert.equal(Object.hasOwn(f.game.karamaShipping!, 'costClaimed'), false); assert.notEqual(f.karama, f.stopKarama); assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.box)); assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.stopKarama)); assert.ok(player(f.afterSetup, f.creator).hand.some(card => card.id === f.karama)); assert.ok(f.acquisition.actions.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1)); assert.equal(f.priceAction.card, f.karama); assert.equal(f.priceAction.mode, 'shipment'); assert.equal(f.game.discard.filter(card => card.id === f.karama).length, 1); assert.equal(f.game.discard.filter(card => card.id === f.alternative).length, f.otherCandidate ? 1 : 0); if (f.otherCandidate) { assert.ok(f.weather); assert.equal(f.weather.action.card, f.alternative); assert.equal(f.weather.action.amount, 0); } else assert.equal(f.weather, null); assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(boxShipmentPriceInventory(f.game), f.originalInventory); } function recovered(f: BoxShipmentPriceFixture, selected: string) { const paid = payBoxShipmentPrice(f); assert.equal(player(paid, f.holder).spice, player(f.game, f.holder).spice - f.boxFee); assert.deepEqual(paid.karamaShipping, f.originalGrant, 'Payment alone does not claim the original price cost'); assert.ok(paid.pendingNullentropy); const search = viewGame(paid, f.holder).nullentropy!.search; assert.ok(search); assert.deepEqual(search.cards.map(card => card.id).sort(), [f.karama, f.alternative].sort()); let game = selectBoxShipmentPrice(f, paid, selected); game = finishBoxShipmentPrice(f, game); assert.equal(player(game, f.holder).spice, player(f.game, f.holder).spice - f.boxFee); assert.ok(player(game, f.holder).hand.some(card => card.id === selected)); assert.equal(game.discard.filter(card => card.id === f.box).length, 1); assert.equal(game.discard.at(-1)!.id, f.box); assert.equal(game.pendingNullentropy ?? null, null); assert.deepEqual(game.karamaShipping, { ...f.originalGrant, ...(selected === f.karama ? { costClaimed: true } : {}) }); assert.deepEqual(boxShipmentPriceInventory(game), f.originalInventory); return { paid, game }; } for (const advanced of [false, true]) { for (const selectedCost of [true, false]) { void test(`${advanced ? 'Advanced' : 'Basic'} genuine Box selects ${selectedCost ? 'active price cost' : 'other discard'} and preserves the real bank-paid shipment`, () => { const f = createBoxShipmentPriceFixture({ advanced }); originalSource(f); if (!advanced) rejected(f.game, f.guild, { type: 'card', card: f.stopKarama, mode: 'special' }); const selected = selectedCost ? f.karama : f.alternative; let { game } = recovered(f, selected); const before = structuredClone(player(game, f.beneficiary)), guildSpice = player(game, f.guild).spice; game = declareBoxShipmentPrice(f, game); if (advanced) { assert.equal(game.decision?.kind, 'guildShipment'); assert.equal(player(game, f.beneficiary).spice, before.spice); assert.equal(player(game, f.beneficiary).reserves, before.reserves); } game = allowBoxShipmentPrice(f, game); game = finishBoxShipmentPrice(f, game); assert.equal(player(game, f.beneficiary).spice, before.spice - f.shipmentCost); assert.equal(player(game, f.beneficiary).reserves, before.reserves - f.amount); assert.equal(player(game, f.beneficiary).forces[f.target], f.amount); assert.equal(player(game, f.beneficiary).shipped, true); assert.equal(player(game, f.beneficiary).moved, 0); assert.equal(player(game, f.guild).spice, guildSpice, 'The purchased price pays the bank, without Guild income'); assert.equal(game.karamaShipping, null); assert.equal(player(game, f.holder).hand.filter(card => card.id === selected).length, 1); rejected(game, f.beneficiary, f.shipment); assert.deepEqual(boxShipmentPriceInventory(game), f.originalInventory); }); } if (advanced) void test('Advanced genuine Box recovery can spend the same price card for a Guild stop without a second refund', () => { const f = createBoxShipmentPriceFixture({ advanced }); originalSource(f); let { game } = recovered(f, f.karama); const before = structuredClone(player(game, f.beneficiary)), guildSpice = player(game, f.guild).spice; game = declareBoxShipmentPrice(f, game); game = stopBoxShipmentPrice(f, game, f.karama); game = finishBoxShipmentPrice(f, game); assert.equal(player(game, f.beneficiary).spice, before.spice); assert.equal(player(game, f.beneficiary).reserves, before.reserves); assert.deepEqual(player(game, f.beneficiary).forces, before.forces); assert.equal(player(game, f.beneficiary).shipped, true); assert.equal(player(game, f.beneficiary).moved, 0); assert.equal(player(game, f.guild).spice, guildSpice); assert.equal(player(game, f.guild).specialKaramaUsed, true); assert.equal(player(game, f.creator).hand.some(card => card.id === f.karama), false); assert.equal(player(game, f.guild).hand.some(card => card.id === f.karama), false); assert.ok(player(game, f.guild).hand.some(card => card.id === f.stopKarama)); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); assert.equal(game.discard.filter(card => card.id === f.box).length, 1); assert.equal(game.karamaShipping, null); rejected(game, f.guild, { type: 'card', card: f.karama, mode: 'special' }); game = actBoxShipmentPrice(game, f.beneficiary, { type: 'endMovement' }); assert.notEqual(game.active, f.beneficiary); assert.deepEqual(boxShipmentPriceInventory(game), f.originalInventory); }); if (advanced) void test('Advanced genuine Box search during Guild interception restores the original shipment before a same-card stop', () => { const f = createBoxShipmentPriceFixture({ advanced }); originalSource(f); const declared = declareBoxShipmentPrice(f, structuredClone(f.game)); assert.equal(declared.decision?.kind, 'guildShipment'); const originalDecision = structuredClone(declared.decision), originalShipment = structuredClone(declared.pendingShipment); assert.ok(originalShipment); const originalShipper = structuredClone(player(declared, f.beneficiary)); const paid = payBoxShipmentPrice(f, declared); assert.ok(paid.pendingNullentropy); assert.deepEqual(paid.pendingNullentropy.resume.decision, originalDecision); assert.deepEqual(paid.pendingShipment, originalShipment); assert.deepEqual(paid.karamaShipping, f.originalGrant); let game = selectBoxShipmentPrice(f, paid, f.karama); assert.deepEqual(game.decision, originalDecision); assert.deepEqual(game.pendingShipment, originalShipment); assert.deepEqual(game.karamaShipping, { ...f.originalGrant, costClaimed: true }); assert.equal(player(game, f.holder).spice, player(f.game, f.holder).spice - f.boxFee); game = stopBoxShipmentPrice(f, game, f.karama); game = finishBoxShipmentPrice(f, game); assert.deepEqual(player(game, f.beneficiary).forces, originalShipper.forces); assert.equal(player(game, f.beneficiary).reserves, originalShipper.reserves); assert.equal(player(game, f.beneficiary).spice, originalShipper.spice); assert.equal(player(game, f.beneficiary).shipped, true); assert.equal(player(game, f.beneficiary).moved, 0); assert.equal(game.pendingShipment ?? null, null); assert.equal(game.karamaShipping, null); assert.equal(player(game, f.creator).hand.some(card => card.id === f.karama), false); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); assert.equal(game.discard.filter(card => card.id === f.box).length, 1); assert.deepEqual(boxShipmentPriceInventory(game), f.originalInventory); }); if (advanced) void test('Advanced genuine Box other-card selection leaves the unclaimed price cost refundable exactly once', () => { const f = createBoxShipmentPriceFixture({ advanced }); originalSource(f); let { game } = recovered(f, f.alternative); const before = structuredClone(player(game, f.beneficiary)); game = declareBoxShipmentPrice(f, game); game = stopBoxShipmentPrice(f, game, f.stopKarama); game = finishBoxShipmentPrice(f, game); assert.equal(player(game, f.beneficiary).spice, before.spice); assert.equal(player(game, f.beneficiary).reserves, before.reserves); assert.deepEqual(player(game, f.beneficiary).forces, before.forces); assert.equal(player(game, f.beneficiary).shipped, true); assert.equal(player(game, f.creator).hand.filter(card => card.id === f.karama).length, 1); assert.equal(game.discard.some(card => card.id === f.karama), false); assert.equal(game.discard.filter(card => card.id === f.stopKarama).length, 1); assert.equal(player(game, f.guild).hand.filter(card => card.id === f.alternative).length, 1); assert.equal(game.karamaShipping, null); rejected(game, f.guild, { type: 'card', card: f.stopKarama, mode: 'special' }); assert.equal(player(game, f.creator).hand.filter(card => card.id === f.karama).length, 1); assert.deepEqual(boxShipmentPriceInventory(game), f.originalInventory); }); void test(`${advanced ? 'Advanced' : 'Basic'} genuine sole-candidate Box automatically recovers and marks the original price cost`, () => { const f = createBoxShipmentPriceFixture({ advanced, otherCandidate: false }); originalSource(f); assert.deepEqual(f.game.discard.map(card => card.id), [f.karama]); const game = finishBoxShipmentPrice(f, payBoxShipmentPrice(f)); assert.equal(game.pendingNullentropy ?? null, null); assert.equal(game.decision ?? null, null); assert.equal(player(game, f.holder).spice, player(f.game, f.holder).spice - f.boxFee); assert.equal(player(game, f.holder).hand.filter(card => card.id === f.karama).length, 1); assert.deepEqual(game.karamaShipping, { ...f.originalGrant, costClaimed: true }); assert.deepEqual(game.discard.map(card => card.id), [f.box]); assert.deepEqual(boxShipmentPriceInventory(game), f.originalInventory); }); void test(`${advanced ? 'Advanced' : 'Basic'} genuine Box stale and foreign choices retain the paid search and charge its fee once`, () => { const f = createBoxShipmentPriceFixture({ advanced }); originalSource(f); const paid = payBoxShipmentPrice(f), event = paid.pendingNullentropy!.event; rejected(paid, f.creator, { type: 'decision', event, card: f.karama }); rejected(paid, f.holder, { type: 'decision', event: 'stale', card: f.karama }); rejected(paid, f.holder, { type: 'decision', event, card: f.stopKarama }); rejected(paid, f.holder, f.action); assert.deepEqual(paid.karamaShipping, f.originalGrant); assert.equal(player(paid, f.holder).spice, player(f.game, f.holder).spice - f.boxFee); const done = selectBoxShipmentPrice(f, paid, f.karama); rejected(done, f.holder, { type: 'decision', event, card: f.alternative }); assert.equal(player(done, f.holder).spice, player(f.game, f.holder).spice - f.boxFee); assert.deepEqual(boxShipmentPriceInventory(done), f.originalInventory); }); } void test('four existing profiles make a legal paid native Box selection while preserving the original purchased price', () => { for (const advanced of [false, true]) { const f = createBoxShipmentPriceFixture({ advanced }), paid = payBoxShipmentPrice(f); for (const difficulty of DIFFICULTIES) { const view = viewGame(paid, f.holder); view.players.find(seat => seat.id === f.holder)!.bot = difficulty; const candidates = view.nullentropy!.search!.cards.map(card => card.id), action = botActions(view)[0]; assert.ok(action, `${advanced}/${difficulty}`); assert.equal(action.type, 'decision'); assert.equal(action.event, paid.pendingNullentropy!.event); assert.ok(candidates.includes(String(action.card))); const done = actBoxShipmentPrice(structuredClone(paid), f.holder, action); assert.equal(player(done, f.holder).spice, player(f.game, f.holder).spice - f.boxFee); assert.equal(done.karamaShipping!.owner, f.creator); assert.equal(done.karamaShipping!.player, f.beneficiary); assert.equal(done.karamaShipping!.costClaimed ?? false, action.card === f.karama); assert.deepEqual(boxShipmentPriceInventory(done), f.originalInventory); const before = boxShipmentPricePhysical(f.game), after = boxShipmentPricePhysical(done); assert.equal(after.active, before.active); assert.deepEqual(after.players.map(seat => ({ ...seat, spice: 0 })), before.players.map(seat => ({ ...seat, spice: 0 }))); } } });