Allow Box recovery of purchased shipment costs

This commit is contained in:
Quintine committed 2026-10-26 07:16:27 +11:00
1 parent 725672364d
commit 0c848bec74
12 files changed
+724 -25

No files matched your search

+133
View File
@@ -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));
});
+234
View File
@@ -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 })));
}
}
});
+168
View File
@@ -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<string>(), 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<typeof createBoxShipmentPriceFixture>;
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);
}
+39 -3
View File
@@ -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);