Allow Box recovery of purchased shipment costs
This commit is contained in:
1 parent
725672364d
commit
0c848bec74
12 files changed
+724
-25
No files matched your search
@@ -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));
|
||||
});
|
||||
@@ -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 })));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user