diff --git a/components/nullentropy-box.tsx b/components/nullentropy-box.tsx
index 5216342e..e2ef579b 100644
--- a/components/nullentropy-box.tsx
+++ b/components/nullentropy-box.tsx
@@ -6,6 +6,7 @@ import { Button } from './ui/button';
import { CardInspector, CardRules } from './card-inspector';
const fullHandPolicy = 'Provisional full-hand exchange: retrieval and the played Box discard settle atomically, leaving your final hand at its limit. The printed intermediate-capacity timing remains unresolved.';
+const shipmentPricePolicy = 'Provisional refund rule: the purchased shipping price, the player who bought it and its shipment beneficiary stay unchanged. Recovering its original card cost prevents a refund if the Guild later stops that shipment with special Karama, even after that card is discarded again. Taking another card leaves any still-unclaimed cost under its usual refund rule.';
type BoxProps = {
game: GameView;
@@ -24,6 +25,7 @@ export function NullentropyBox({ game, act, busy }: BoxProps) {
Your Nullentropy Box search is paid. Complete the private card choice in
the decision panel above.
{box.fullHandExchange && {fullHandPolicy}}
+ {game.karamaShipping && {shipmentPricePolicy}}
);
return (
@@ -42,6 +44,7 @@ export function NullentropyBox({ game, act, busy }: BoxProps) {
the cards. If only one card is eligible, the server takes it
automatically.
+ {game.karamaShipping && {shipmentPricePolicy}
}
{box.fullHandExchange && {fullHandPolicy}
}
@@ -84,6 +87,7 @@ export function NullentropySearch({ game, act, busy }: BoxProps) {
These discard cards are visible only to you during this paid search.
Inspection does not select a card.
+ {game.karamaShipping && {shipmentPricePolicy}
}
{game.nullentropy?.fullHandExchange && {fullHandPolicy}
}
c.id === g.karamaShipping?.card)
- ),
- 'Searching while a shipping Karama has a provisional Guild refund claim awaits resolution.',
- );
// A completed purchase has already paid its bid; it is no longer a reservation.
const funding = structuredClone(g);
if (funding.currentAuctionSale) {
@@ -4737,6 +4727,16 @@ function validateNullentropyBegin(g: Game, owner: Player, cardId: string) {
);
return { card, candidates };
}
+/** Physical recovery ends only this cost's provisional stop refund, not its price. */
+function claimNullentropyShipmentCost(
+ g: Pick,
+ card: string,
+) {
+ if (g.karamaShipping?.card === card) g.karamaShipping.costClaimed = true;
+ const pending = g.pendingKarama;
+ if (pending?.use.kind === 'shipment' && pending.use.card === card)
+ pending.costClaimed = true;
+}
function finishNullentropy(
g: Game,
player: string,
@@ -4772,6 +4772,7 @@ function finishNullentropy(
: 'Invalid private search selection.',
);
}
+ claimNullentropyShipmentCost(context, cardId);
validateTransferCompletion(
context,
getPlayer(context, player),
@@ -4802,6 +4803,10 @@ function finishNullentropy(
);
owner.hand = result.ownerHand;
g.discard = result.discard;
+ claimNullentropyShipmentCost({
+ karamaShipping: g.karamaShipping,
+ pendingKarama: pending.resume.pendingKarama,
+ }, cardId);
setMovementDiscardKnowledge(g, recordMovementBoxKnowledge(g.movementDiscardKnowledge,
g.players.map(p => p.id), player, result.discard));
g.pendingNullentropy = null;
@@ -17798,6 +17803,7 @@ function projectMovementBoxRecovery(source: Game, actor: string, box: string, se
try { result = resolveNullentropyBox(player.hand, box, context.discard, selected,
context.discard.filter(card => card.id !== selected).map(card => card.id), handLimit(player), true); }
catch (error) { throw new RuleError(error instanceof Error ? error.message : 'Invalid owned Box recovery.'); }
+ claimNullentropyShipmentCost(context, selected);
validateTransferCompletion(context, player, player, result.ownerHand, result.ownerHand, { type: 'card', card: box });
player.hand = result.ownerHand; context.discard = result.discard;
setMovementDiscardKnowledge(context, recordMovementBoxKnowledge(context.movementDiscardKnowledge,
diff --git a/game/reference.ts b/game/reference.ts
index aa7c06d0..b9744f33 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -1014,9 +1014,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'Payment is recorded once before inspection. Refresh or reconnect restores an unfinished paid search for its owner without charging again. While it is open, other gameplay actions wait so they cannot change the reserved Box or discard pile. The completed search saves the recovered card and shuffled pile before restoring an interrupted response, decision or phase opening. Recovery does not charge, select or shuffle again, and does not replay the earlier action.',
'The selected card enters your hand privately. Other players do not receive the selected identity or the shuffled discard faces. Temporary search access ends on completion; the normal discard pile remains private, even though the played Box is placed face up on top. Past inspection records do not become a permanent live discard browser.',
'An active Truthtrance question and exact card or transaction commitments must resolve as required. Basic and Advanced full-hand holders have a visibly provisional atomic exchange: retrieve one non-Box and discard the played Box, leaving the final hand at its current limit. The printed intermediate-capacity question remains unresolved; over-limit hands are rejected.',
- 'A pending Guild refund claim on a discarded shipping Karama also blocks starting the search while that provisional refund policy is unresolved. The guard protects the competing claim to that card; it is not a general ban on Box during Shipment. Empty or only-Box searches are rejected before payment under the current unsupported-empty-search policy; there is no fallback deck draw.',
+ 'A pending purchased-shipment cost does not block the search. Under the provisional refund composition, recovering that exact cost preserves its purchased price, creator and beneficiary but ends its later Guild special-stop refund, even if discarded again. Selecting another card leaves the cost’s existing claim status unchanged. Empty or only-Box searches are rejected before payment under the current unsupported-empty-search policy; there is no fallback deck draw.',
'All four AI profiles start only when their own projected availability and spice reserve allow it. They do not inspect unpaid discard contents. Once paid, they choose from their entitled cards, prefer existing functional effects over unfinished Richese faces and complete a legal selection even if only unfinished effects remain. A paid search takes priority over optional actions.',
- 'The paid search and continuations are integrated in development fixtures, with a connected full-hand prototype in both modes. The provisional Guild refund interaction remains guarded. The explicit Semuta preview can recover the used Box after a clean completed search without replaying payment or shuffle; other discard compositions remain separate. Full Richese starts and complete browser/mobile/full-game acceptance remain gated.',
+ 'The paid search and continuations are integrated in development fixtures, with a connected full-hand prototype in both modes. Purchased-shipment cost recovery now uses the provisional nonduplication policy without blocking the search. The explicit Semuta preview can recover the used Box after a clean completed search without replaying payment or shuffle; other discard compositions remain separate. Full Richese starts and complete browser/mobile/full-game acceptance remain gated.',
],
related: [
'card-richese-nullentropy-box',
@@ -1030,7 +1030,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
- 'Paid inspection, exact private custody, shuffled remainder, Box on top, sole-choice completion and restored parent continuations are integrated. Basic and Advanced full-hand exchange use a connected provisional final-capacity policy. Guild-refund guards remain; wider discard/commitment refinement is separate.',
+ 'Paid inspection, exact private custody, shuffled remainder, Box on top, sole-choice completion and restored parent continuations are integrated. Basic and Advanced full-hand exchange use a connected provisional final-capacity policy. Recovering an exact shipment cost ends only its provisional refund eligibility, retaining the original price and continuation; wider discard/commitment refinement is separate.',
},
{
area: 'Player controls',
@@ -1048,7 +1048,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Documentation',
status: 'Partial',
detail:
- 'Payment, temporary private access, disposal order, continuation recovery and separate capacity/refund interpretations are documented. This does not grant generic discard browsing or certify the remaining expansion effects.',
+ 'Payment, temporary private access, disposal order, continuation recovery and provisional capacity/claimed-cost refund interpretations are documented. This does not grant generic discard browsing or certify the remaining expansion effects.',
},
{
area: 'Verification',
@@ -1058,6 +1058,9 @@ export const RULE_TOPICS: RuleTopic[] = [
evidence: [
'tests/nullentropy-box.test.ts',
'tests/nullentropy-box-engine.test.ts',
+ 'tests/box-shipment-price.test.ts',
+ 'tests/box-shipment-price-controls.test.ts',
+ 'tests/fixture-box-shipment-price.ts',
'tests/nullentropy-box-engine-review.test.ts',
'tests/nullentropy-box-bots.test.ts',
'tests/nullentropy-box-recovery.test.ts',
diff --git a/tests/box-shipment-price-controls.test.ts b/tests/box-shipment-price-controls.test.ts
new file mode 100644
index 00000000..86a97e70
--- /dev/null
+++ b/tests/box-shipment-price-controls.test.ts
@@ -0,0 +1,133 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { Children, createElement, isValidElement, type ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { NullentropyBox, NullentropySearch } from '../components/nullentropy-box';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
+import { baseDeck } from '../game/cards';
+import { richeseCards } from '../game/richese-cards';
+
+// Controlled owner projections supplement the native Box/price source suite.
+// They prove presentation and the actual hook-free selection callback only;
+// no paid Game state, original cost identity or refund receipt is installed.
+function projected(paid = false) {
+ const game = createGame('BOXSHIPMENTPRICECONTROLS', newPlayer('a', 'Atreides', 'atreides'), false, ['choam']);
+ joinGame(game, newPlayer('r', 'Richese', 'richese'));
+ joinGame(game, newPlayer('g', 'Guild', 'guild'));
+ const view = viewGame(game, 'a');
+ const box = richeseCards().find(card => card.effect === 'nullentropyBox');
+ const cost = baseDeck().find(card => card.effect === 'karama'), other = baseDeck().find(card => card.kind === 'shield');
+ assert.ok(box && cost && other);
+ view.status = 'playing'; view.phase = 5; view.active = 'a';
+ view.karamaShipping = { owner: 'r', player: 'g' };
+ view.nullentropy = {
+ card: box, blocked: null,
+ search: paid ? { event: 'controlled-paid-box-event', cards: [cost, other] } : null,
+ };
+ if (paid) view.decision = { kind: 'nullentropy', player: 'a' };
+ return { game: view, box, cost, other };
+}
+function boxMarkup(game: GameView, busy = false) {
+ // NullentropyBox owns useId: always enter it through the React renderer.
+ return renderToStaticMarkup(createElement(NullentropyBox, { game, busy, act() {} }));
+}
+function searchMarkup(game: GameView, busy = false) {
+ return renderToStaticMarkup(createElement(NullentropySearch, { game, busy, act() {} }));
+}
+function labelText(node: ReactNode): string {
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
+ return Children.toArray(node).map(child => isValidElement<{ children?: ReactNode }>(child)
+ ? labelText(child.props.children)
+ : typeof child === 'string' || typeof child === 'number' ? String(child) : '').join('');
+}
+function click(node: ReactNode, label: string): boolean {
+ for (const child of Children.toArray(node)) {
+ if (!isValidElement<{ children?: ReactNode; onClick?: () => void }>(child)) continue;
+ if (labelText(child.props.children) === label && child.props.onClick) { child.props.onClick(); return true; }
+ if (click(child.props.children, label)) return true;
+ }
+ return false;
+}
+function select(game: GameView, name: string, busy = false): Action[] {
+ const actions: Action[] = [];
+ // This leaf is hook-free; only its real selection handler is invoked.
+ click(NullentropySearch({ game, busy, act(action) { actions.push(action); } }), `Take ${name} and finish search`);
+ return actions;
+}
+function guidance(html: string) {
+ assert.match(html, /Provisional refund rule/);
+ assert.match(html, /purchased shipping price, the player who bought it and its shipment beneficiary stay unchanged/);
+ assert.match(html, /Recovering its original card cost prevents a refund if the Guild later stops that shipment with special Karama/);
+ assert.match(html, /even after that card is discarded again/);
+ assert.match(html, /Taking another card leaves any still-unclaimed cost under its usual refund rule/);
+}
+
+void test('active purchased shipping price guidance is persistent before Box payment without showing discard candidates', () => {
+ const f = projected();
+ for (let render = 0; render < 3; render++) {
+ const html = boxMarkup(f.game); guidance(html);
+ assert.match(html, /Pay 2 spice to the bank to privately search/);
+ assert.match(html, /Pay 2 spice and search privately/);
+ assert.match(html, /There is no free cancellation after seeing the cards/);
+ assert.doesNotMatch(html, new RegExp(`Take ${f.cost.name}|Take ${f.other.name}|controlled-paid-box-event`));
+ }
+});
+
+void test('paid Box reminder and entitled selection panel retain the same provisional price and refund explanation', () => {
+ const f = projected(true);
+ for (let render = 0; render < 3; render++) {
+ guidance(boxMarkup(f.game)); guidance(searchMarkup(f.game));
+ assert.match(boxMarkup(f.game), /Your Nullentropy Box search is paid/);
+ assert.match(searchMarkup(f.game), /You have paid 2 spice/);
+ assert.match(searchMarkup(f.game), /These discard cards are visible only to you during this paid search/);
+ assert.match(searchMarkup(f.game), /Inspection does not select a card/);
+ }
+});
+
+void test('the actual paid search callbacks select either entitled card with only the unchanged event and card', () => {
+ const f = projected(true), event = f.game.nullentropy!.search!.event;
+ assert.deepEqual(select(f.game, f.cost.name), [{ type: 'decision', event, card: f.cost.id }]);
+ assert.deepEqual(select(f.game, f.other.name), [{ type: 'decision', event, card: f.other.id }]);
+ assert.deepEqual(select(f.game, 'Unshown card'), []);
+ for (const card of [f.cost, f.other])
+ assert.equal(Object.keys(select(f.game, card.name)[0]).sort().join(','), 'card,event,type');
+});
+
+void test('busy controls preserve the two-spice fee and guide while suppressing actual paid selections', () => {
+ const before = projected(), paid = projected(true);
+ const html = boxMarkup(before.game, true); guidance(html);
+ assert.match(html, /disabled=""[^>]*>Pay 2 spice and search privately/);
+ assert.deepEqual(select(paid.game, paid.cost.name, true), []);
+ assert.deepEqual(select(paid.game, paid.other.name, true), []);
+ guidance(searchMarkup(paid.game, true));
+});
+
+void test('existing prepay blocks and full-hand guidance coexist with the purchased-price notice', () => {
+ const f = projected();
+ f.game.nullentropy!.blocked = 'Finish the current owned preparation before paying.';
+ f.game.nullentropy!.fullHandExchange = true;
+ const html = boxMarkup(f.game); guidance(html);
+ assert.match(html, /Finish the current owned preparation before paying/);
+ assert.match(html, /disabled=""[^>]*>Pay 2 spice and search privately/);
+ assert.match(html, /Provisional full-hand exchange/);
+ const paid = projected(true); paid.game.nullentropy!.fullHandExchange = true;
+ assert.match(searchMarkup(paid.game), /Provisional full-hand exchange/); guidance(searchMarkup(paid.game));
+});
+
+void test('the price notice is conditional on an active public grant and a paid search remains owner-only', () => {
+ const before = projected(); before.game.karamaShipping = null;
+ assert.doesNotMatch(boxMarkup(before.game), /Provisional refund rule|Recovering its original card cost/);
+ const paid = projected(true); paid.game.karamaShipping = null;
+ assert.doesNotMatch(boxMarkup(paid.game), /Provisional refund rule/);
+ assert.doesNotMatch(searchMarkup(paid.game), /Provisional refund rule/);
+ paid.game.me = 'g'; assert.equal(searchMarkup(paid.game), ''); assert.deepEqual(select(paid.game, paid.cost.name), []);
+ paid.game.me = 'a'; paid.game.decision = null;
+ assert.equal(searchMarkup(paid.game), ''); assert.deepEqual(select(paid.game, paid.cost.name), []);
+});
+
+void test('prepay guidance never inspects discard faces or an unprojected physical shipping cost', () => {
+ const f = projected();
+ Object.defineProperty(f.game, 'discard', { get() { throw Error('The prepay panel inspected discard faces'); } });
+ Object.defineProperty(f.game.karamaShipping!, 'card', { get() { throw Error('The price cost is not public projection'); } });
+ guidance(boxMarkup(f.game));
+});
diff --git a/tests/box-shipment-price.test.ts b/tests/box-shipment-price.test.ts
new file mode 100644
index 00000000..b94991ae
--- /dev/null
+++ b/tests/box-shipment-price.test.ts
@@ -0,0 +1,234 @@
+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 })));
+ }
+ }
+});
diff --git a/tests/fixture-box-shipment-price.ts b/tests/fixture-box-shipment-price.ts
new file mode 100644
index 00000000..3bff88fa
--- /dev/null
+++ b/tests/fixture-box-shipment-price.ts
@@ -0,0 +1,168 @@
+import assert from 'node:assert/strict';
+import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
+import { TERRITORIES, location } from '../game/board';
+import { presenceAt } from '../game/force-presence';
+import { semutaGuildIncomeInventory, nextSemutaGuildIncomeNativeStep } from './fixture-semuta-guild-income';
+import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
+
+export const BOX_SHIPMENT_PRICE_BOX = 'richese-nullentropy-box';
+export type BoxShipmentPriceStep = { actor: string; action: Action };
+export type BoxShipmentPriceOptions = { advanced?: boolean; otherCandidate?: boolean };
+export function boxShipmentPricePlayer(game: Game, id: string) {
+ const player = game.players.find(player => player.id === id);
+ assert.ok(player, `The original ${id} seat must remain present`);
+ return player;
+}
+export const boxShipmentPriceInventory = semutaGuildIncomeInventory;
+export const boxShipmentPriceClean = (game: Game) => !game.response && !game.decision &&
+ !game.phaseOpening && !game.pendingTreacheryDiscard && !game.pendingNullentropy && !game.truthtrance;
+export function boxShipmentPricePhysical(game: Game) {
+ return { active: game.active, order: [...game.order], movementRemaining: [...(game.movementRemaining ?? [])],
+ players: game.players.map(player => ({ id: player.id, spice: player.spice, reserves: player.reserves,
+ tanks: player.tanks, forces: structuredClone(player.forces), elites: structuredClone(player.elites),
+ shipped: player.shipped, moved: player.moved, specialKaramaUsed: player.specialKaramaUsed })) };
+}
+export function actBoxShipmentPrice(game: Game, actor: string, action: Action,
+ actions: BoxShipmentPriceStep[] = []) {
+ actions.push({ actor, action: structuredClone(action) });
+ return withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
+}
+
+/** Only the original cache lot is bought. All other controls retain their
+ * existing native no-spend policy, including Advanced Guild timing. */
+export function nextBoxShipmentPriceStep(game: Game): BoxShipmentPriceStep {
+ if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
+ const decision = game.decision;
+ if (decision?.kind === 'richeseCache') return { actor: decision.player, action: {
+ type: 'decision', event: game.richeseBidding!.event, card: BOX_SHIPMENT_PRICE_BOX,
+ method: 'onceAround', direction: 'counterclockwise' } };
+ if (!decision && game.richeseAuction?.cardId === BOX_SHIPMENT_PRICE_BOX &&
+ !game.richeseAuction.outcome && game.richeseAuction.active === 'g' && game.richeseAuction.bid === 0)
+ return { actor: 'g', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } };
+ }
+ return nextSemutaGuildIncomeNativeStep(game);
+}
+export function advanceBoxShipmentPrice(game: Game, until: (state: Game) => boolean,
+ actions: BoxShipmentPriceStep[] = []): Game {
+ for (let count = 0; count < 1500; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The original Box price programme ended before its owned boundary');
+ const step = nextBoxShipmentPriceStep(game);
+ game = actBoxShipmentPrice(game, step.actor, step.action, actions);
+ }
+ throw Error('The native Box price programme did not reach its finite boundary');
+}
+
+/** Original public Richese/Guild/Atreides/Emperor setup. Only the unopened
+ * starting deal, unopened land positions and scalar lotteries are selected.
+ * Guild buys the physical Box for1; Richese may really discard its original
+ * Weather Control; Atreides really spends its own distinct printed Karama.
+ * No played hand, wallet, board, clock or purchased grant is installed. */
+export function createBoxShipmentPriceFixture(options: BoxShipmentPriceOptions = {}) {
+ const advanced = options.advanced ?? false, otherCandidate = options.otherCandidate ?? true;
+ let game = createGame('BOXSHIPMENTPRICE', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
+ for (const [id, name, faction] of [['g', 'Guild', 'guild'], ['a', 'Atreides', 'atreides'],
+ ['e', 'Emperor', 'emperor']] as const) joinGame(game, newPlayer(id, name, faction));
+ const actions: BoxShipmentPriceStep[] = [];
+ const act = (actor: string, action: Action) => { game = actBoxShipmentPrice(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceBoxShipmentPrice(game, until, actions); };
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const initial = structuredClone(game);
+ act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
+ const setup = structuredClone(game);
+ assert.equal(game.status, 'setup');
+ assert.ok(game.players.every(player => player.hand.length === 0), 'Choose only the original unopened deal');
+ const selected = new Set(), startingDealSelection: { actor: string; position: number; from: number; card: string }[] = [];
+ for (const [actor, effect] of [['r', 'weather'], ['g', 'karama'], ['a', 'karama'], ['e', 'projectile']] as const) {
+ const position = game.players.findIndex(player => player.id === actor);
+ const from = game.deck.findIndex(card => !selected.has(card.id) &&
+ (effect === 'projectile' ? card.kind === effect : card.effect === effect));
+ assert.ok(position >= 0 && from >= position, `The original ${actor} starting card must still be undealt`);
+ const card = game.deck[from].id;
+ [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
+ selected.add(card); startingDealSelection.push({ actor, position, from, card });
+ }
+ const beforeStartingDeal = structuredClone(game);
+ reach(state => state.status === 'playing');
+ const afterSetup = structuredClone(game), originalInventory = boxShipmentPriceInventory(game);
+ const idFor = (actor: string) => startingDealSelection.find(row => row.actor === actor)!.card;
+ const alternative = idFor('r'), stopKarama = idFor('g'), karama = idFor('a');
+ for (const { actor, card } of startingDealSelection)
+ assert.ok(boxShipmentPricePlayer(game, actor).hand.some(held => held.id === card));
+ const land: Game['spiceDeck'] = [];
+ for (let count = 0; count < (advanced ? 2 : 1); count++) {
+ const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
+ assert.ok(at >= 0); land.push(game.spiceDeck.splice(at, 1)[0]);
+ }
+ game.spiceDeck.unshift(...land);
+ let weather: { before: Game; action: Action; after: Game } | null = null;
+ if (otherCandidate) {
+ reach(state => state.phase === 0 && state.stormPending !== null && boxShipmentPriceClean(state));
+ const before = structuredClone(game), action: Action = { type: 'card', card: alternative, amount: 0 };
+ act('r', action); reach(boxShipmentPriceClean);
+ weather = { before, action, after: structuredClone(game) };
+ assert.equal(game.discard.filter(card => card.id === alternative).length, 1);
+ }
+ reach(state => state.decision?.kind === 'richeseCache');
+ const beforePurchase = structuredClone(game), acquisitionStart = actions.length;
+ reach(state => boxShipmentPricePlayer(state, 'g').hand.some(card => card.id === BOX_SHIPMENT_PRICE_BOX) && boxShipmentPriceClean(state));
+ const acquired = structuredClone(game), acquisition = { before: beforePurchase,
+ actions: structuredClone(actions.slice(acquisitionStart)), after: acquired };
+ assert.ok(acquisition.actions.some(step => step.actor === 'g' && step.action.type === 'richeseBid' && step.action.amount === 1));
+ reach(state => state.phase === 5 && state.active === 'a' && boxShipmentPriceClean(state));
+ const beforePrice = structuredClone(game), priceAction: Action = { type: 'card', card: karama, mode: 'shipment', target: 'a' };
+ act('a', priceAction); reach(boxShipmentPriceClean);
+ assert.deepEqual(game.karamaShipping, { player: 'a', owner: 'a', card: karama });
+ assert.equal(game.discard.filter(card => card.id === karama).length, 1);
+ assert.ok(boxShipmentPricePlayer(game, 'g').hand.some(card => card.id === stopKarama));
+ assert.equal(boxShipmentPricePlayer(game, 'g').spice, boxShipmentPricePlayer(beforePurchase, 'g').spice - 1);
+ const destination = TERRITORIES.find(territory => territory.type === 'sand' &&
+ territory.sectors.some(sector => sector !== game.storm) && game.players.every(player => presenceAt(player, territory.id) === 0));
+ assert.ok(destination);
+ const sector = destination.sectors.find(sector => sector !== game.storm)!;
+ const shipment: Action = { type: 'ship', territory: destination.id, sector, amount: 3, elite: 0, allyPayment: 0 };
+ return { game, advanced, otherCandidate, holder: 'g', owner: 'g', guild: 'g', beneficiary: 'a', creator: 'a',
+ box: BOX_SHIPMENT_PRICE_BOX, karama, stopKarama, alternative, initial, setup, beforeStartingDeal,
+ startingDealSelection, afterSetup, weather, acquisition, acquired, beforePrice, priceAction,
+ action: { type: 'card', card: BOX_SHIPMENT_PRICE_BOX } as Action,
+ originalGrant: structuredClone(game.karamaShipping), originalInventory,
+ originalPhysical: boxShipmentPricePhysical(game), shipment, target: location(destination.id, sector),
+ amount: 3, shipmentCost: 3, boxFee: 2, actions, sourceActionCount: actions.length,
+ staging: ['Original unopened deal/Spice positions and scalar lotteries only. Actual cache Box1 acquisition, optional Weather discard and native Atreides price activation; no played state is installed.'] };
+}
+export type BoxShipmentPriceFixture = ReturnType;
+
+export function payBoxShipmentPrice(fixture: BoxShipmentPriceFixture, game = structuredClone(fixture.game),
+ actions: BoxShipmentPriceStep[] = []) {
+ return actBoxShipmentPrice(game, fixture.holder, fixture.action, actions);
+}
+export function selectBoxShipmentPrice(fixture: BoxShipmentPriceFixture, game: Game, card: string,
+ actions: BoxShipmentPriceStep[] = []) {
+ assert.ok(game.pendingNullentropy, 'Selection requires the actually paid original search');
+ return actBoxShipmentPrice(game, fixture.holder,
+ { type: 'decision', event: game.pendingNullentropy.event, card }, actions);
+}
+export function declareBoxShipmentPrice(fixture: BoxShipmentPriceFixture, game: Game,
+ actions: BoxShipmentPriceStep[] = []) {
+ return actBoxShipmentPrice(game, fixture.beneficiary, fixture.shipment, actions);
+}
+export function allowBoxShipmentPrice(fixture: BoxShipmentPriceFixture, game: Game,
+ actions: BoxShipmentPriceStep[] = []) {
+ if (!game.advanced) {
+ assert.equal(boxShipmentPricePlayer(game, fixture.beneficiary).shipped, true);
+ return game;
+ }
+ assert.equal(game.decision?.kind, 'guildShipment');
+ return actBoxShipmentPrice(game, fixture.guild, { type: 'decision', allow: true }, actions);
+}
+export function stopBoxShipmentPrice(fixture: BoxShipmentPriceFixture, game: Game, card: string,
+ actions: BoxShipmentPriceStep[] = []) {
+ return actBoxShipmentPrice(game, fixture.guild, { type: 'card', card, mode: 'special' }, actions);
+}
+/** Retire only actual original response/discard continuations, stopping before
+ * a new turn/action. Native shipment success or stop leaves Atreides active. */
+export function finishBoxShipmentPrice(fixture: BoxShipmentPriceFixture, game: Game,
+ actions: BoxShipmentPriceStep[] = []) {
+ return advanceBoxShipmentPrice(game, state => state.turn === fixture.game.turn && state.phase === 5 &&
+ state.active === fixture.beneficiary && boxShipmentPriceClean(state), actions);
+}
diff --git a/tests/nullentropy-box-engine.test.ts b/tests/nullentropy-box-engine.test.ts
index 72a72842..16495696 100644
--- a/tests/nullentropy-box-engine.test.ts
+++ b/tests/nullentropy-box-engine.test.ts
@@ -38,17 +38,53 @@ void test('a sole eligible card is taken automatically while every other Box sta
const done=begin(g);assert.equal(done.pendingNullentropy,null);assert.equal(done.decision,null);assert.equal(done.players[0].spice,8);
assert.deepEqual(done.players[0].hand,[selected]);assert.deepEqual(done.discard.map(c=>c.id),['other-box',boxId]);
});
-void test('illegal starts do not charge or expose a search, including Guild-refund cases',()=>{
- for(const change of ['funds','empty','onlyBox','reservedRefund','preselect'] as const){
+void test('illegal starts do not charge or expose a search',()=>{
+ for(const change of ['funds','empty','onlyBox','preselect'] as const){
const g=fixture();
if(change==='funds')g.players[0].spice=1;if(change==='empty')g.discard=[];
if(change==='onlyBox')g.discard=[{...g.players[0].hand[0],id:'other-box'}];
- if(change==='reservedRefund'){g.players[1].faction='guild';g.karamaShipping={owner:'p',player:'p',card:g.discard[0].id};}
const before=structuredClone(g);
assert.throws(()=>applyAction(g,'p',{type:'card',card:boxId,...(change==='preselect'?{give:g.discard[0].id}:{})}));
assert.deepEqual(g,before);assert.equal(viewGame(g,'p').nullentropy!.search,null);
}
});
+void test('a paid Box search preserves the active price and claims only its recovered original cost',()=>{
+ for(const recoverCost of [false,true]){
+ const g=fixture();g.players[1].faction='guild';
+ const index=g.deck.findIndex(card=>card.effect==='karama');assert.ok(index>=0);
+ const cost=g.deck.splice(index,1)[0];g.discard.push(cost);
+ g.karamaShipping={owner:'q',player:'p',card:cost.id};
+ const original=structuredClone(g.karamaShipping),paid=begin(g);
+ assert.deepEqual(paid.karamaShipping,original);assert.equal(paid.players[0].spice,8);
+ const done=choose(paid,recoverCost?cost.id:g.discard[0].id);
+ assert.deepEqual(done.karamaShipping,{...original,...(recoverCost?{costClaimed:true}:{})});
+ assert.equal(done.players[0].spice,8);assert.equal(done.discard.at(-1)!.id,boxId);
+ }
+});
+void test('Box recovery preserves a suspended Worthless price response and marks only its selected cost',()=>{
+ // Controlled physical setup; the conversion, paid search and response use real actions.
+ for(const recoverCost of [false,true])for(const cancel of [false,true]){
+ let g=fixture('beneGesserit',true,5);g.players[1].faction='guild';
+ const cost=g.deck.splice(g.deck.findIndex(card=>card.kind==='worthless'),1)[0];
+ g.players[0].hand.push(cost);
+ const counter=g.deck.splice(g.deck.findIndex(card=>card.effect==='karama'),1)[0];
+ g.players[1].hand.push(counter);
+ g=applyAction(g,'p',{type:'card',card:cost.id,mode:'shipment',target:'q'});
+ assert.equal(g.response?.kind,'worthlessKarama');assert.equal(g.karamaShipping,null);
+ const response=structuredClone(g.response),paid=begin(g);
+ const done=choose(paid,recoverCost?cost.id:g.discard[0].id);
+ assert.deepEqual(done.response,response);assert.equal(done.players[0].spice,8);
+ assert.equal(done.pendingKarama?.costClaimed,recoverCost?true:undefined);
+ assert.equal(done.karamaShipping,null);
+ if(cancel)g=applyAction(done,'q',{type:'card',card:counter.id,mode:'cancel'});
+ else{
+ g=done;
+ while(g.response)g=applyAction(g,g.players.find(p=>!g.response!.passed.includes(p.id))!.id,{type:'passResponse'});
+ }
+ assert.equal(g.pendingKarama,null);
+ assert.deepEqual(g.karamaShipping,cancel?null:{owner:'p',player:'q',card:cost.id,...(recoverCost?{costClaimed:true}:{})});
+ }
+});
void test('a paid auction bid is not charged again as a reservation when Box is played during its remaining income response',()=>{
const g=fixture('richese',true,3);g.players[0].spice=2;
const purchased=g.deck.shift()!;g.players[0].hand.push(purchased);