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
@@ -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 && <span className="block">{fullHandPolicy}</span>}
|
||||
{game.karamaShipping && <span className="block">{shipmentPricePolicy}</span>}
|
||||
</p>
|
||||
);
|
||||
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.
|
||||
</p>
|
||||
{game.karamaShipping && <p className="fine">{shipmentPricePolicy}</p>}
|
||||
{box.fullHandExchange && <p className="notice">{fullHandPolicy}</p>}
|
||||
<CardRules card={box.card} />
|
||||
<CardInspector card={box.card} />
|
||||
@@ -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.
|
||||
</p>
|
||||
{game.karamaShipping && <p className="fine">{shipmentPricePolicy}</p>}
|
||||
{game.nullentropy?.fullHandExchange && <p className="notice">{fullHandPolicy}</p>}
|
||||
<div
|
||||
className="grid min-w-0 gap-4"
|
||||
|
||||
+35
-5
@@ -16,6 +16,41 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Box shipping-cost recovery — 26 October. Prototyped / Partial.**
|
||||
Paid Box search now permits an active purchased-shipment cost in discard.
|
||||
Exact cost recovery preserves its original price/creator/beneficiary and ends
|
||||
only its provisional Guild special-stop refund, even after rediscard. A different
|
||||
selection leaves the existing claim status unchanged. Suspended Worthless
|
||||
conversion retains the selected provenance and its original pass/cancel response.
|
||||
Persistent Box controls explain the provisional composition; no mode gate changes.
|
||||
|
||||
Two disjoint native-source/control lanes and root integration pass **14 files,
|
||||
126/126**, types/lint/build and **nine native engine/GameTable SSR/actual paid
|
||||
selection-callback journeys** on unchanged source. Twelve new native cases,
|
||||
seven projection/control supplements and two controlled engine cases cover the
|
||||
changed contract, including all four minimal profiles. Genuine public sources
|
||||
retain all45 original cards: real cache Box1 acquisition, original Weather
|
||||
alternative and original Atreides price activation. Normal sources use60 actions;
|
||||
sole sources use Basic55/Advanced60. Basic delivers immediately; Advanced restores
|
||||
its exact interception, then allows or stops with the same recovered card or a
|
||||
separate original Guild card. Fees, invoices, typed physical custody and subsequent
|
||||
ordinary movement remain intact. No played hand, wallet, force, clock or grant is
|
||||
installed in these native sources.
|
||||
|
||||
Frozen v3 report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-box-shipment-price-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261026-box-shipment-price-runtime/`.
|
||||
No saved-room/D1/server, reset/restart, deployment or recovery operation occurs.
|
||||
DOM/hydration, browser/mobile, complete rules/combinations and final assurance
|
||||
remain due. [Source and provisional policy](NULLENTROPY_BOX_ENGINE_AUDIT.md#shipping-price-cost-recovery--26-october-2026).
|
||||
|
||||
Next concrete card-function gap: Residual Poison still rejects its pre-leader
|
||||
window after two answered Cunning inspections. Scope the existing native-first
|
||||
reconciliation rather than postponing the printed card window; see
|
||||
[Nexus inspection boundary](NEXUS_ATREIDES_RULES.md#residual-poison-and-two-inspected-commitments),
|
||||
`validateResidualPoison`, `reconcileChangedBattleInspections` and
|
||||
`tests/nexus-residual-ambiguity.test.ts`.
|
||||
|
||||
**Reactive Fremen Nexus worm prevention — 26 October. Prototyped / Partial.**
|
||||
Fremen Nexus Betrayal now responds at actual unmade ordinary/Great Maker
|
||||
rides, after the original completed effects. Uniform public Nexus-back holders
|
||||
@@ -44,11 +79,6 @@ server, reset/restart, deployment or recovery operation occurs. DOM/hydration,
|
||||
browser/mobile and exact-revision deployed acceptance remain due.
|
||||
[Source and selected priority](NEXUS_FREMEN_RULES.md#reactive-before-ride-betrayal--26-october-2026).
|
||||
|
||||
Next bounded card-function gap: Box still rejects paid search while a discarded
|
||||
shipping Karama has a provisional Guild refund claim. Existing recovered-cost
|
||||
provenance supplies a related policy; see [the Box contract](NULLENTROPY_BOX_ENGINE_AUDIT.md)
|
||||
and `validateNullentropyBegin` before choosing that composition.
|
||||
|
||||
**Occupied Ecaz Duke grant — 26 October. Prototyped / Partial.**
|
||||
The printed occupied Ecaz benefit now grants the living shared Duke to its
|
||||
qualified occupier or reciprocal ally. A sole recipient receives him automatically;
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
Initial primary-source and engine audit, 2026-09-06. The bounded ordinary-reserve cancellation policy below was selected on 2026-09-28 after a targeted publisher-source search found no affordability procedure; the later original audit remains historical evidence for unfinished transport and module combinations.
|
||||
|
||||
## Nullentropy recovery of the price cost — 26 October 2026
|
||||
|
||||
Paid Box recovery now shares the existing claimed-cost nonduplication policy:
|
||||
recovering the exact original cost ends its future provisional stop refund,
|
||||
even after rediscard, without canceling or transferring the purchased price.
|
||||
Recovering another card leaves any still-unclaimed cost under its existing
|
||||
exact-card refund rule. This removes the old blanket Box search guard without
|
||||
claiming a new publisher timing ruling.
|
||||
[Current Box contract](NULLENTROPY_BOX_ENGINE_AUDIT.md#shipping-price-cost-recovery--26-october-2026).
|
||||
|
||||
## Purchased rate with native Richese Cunning — 25 October 2026
|
||||
|
||||
The native unallied pair now retains an original independently purchased rate:
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 October 2026 — Box recovery of a purchased shipment cost
|
||||
|
||||
**Prototyped / Partial.** Remove the blanket pending-refund search ban. Exact
|
||||
cost recovery marks the existing claimed-cost provenance in both prospective
|
||||
completion/movement forecasting and actual settlement. It preserves the original
|
||||
purchased price, creator and beneficiary while ending only that cost's provisional
|
||||
special-stop refund, including after rediscard. A different selection retains its
|
||||
existing claim status. Suspended Worthless conversion keeps its exact parent and
|
||||
pass/cancel behavior. Persistent human guidance explains the selected composition;
|
||||
this extends the earlier Semuta nonduplication policy, not a publisher example.
|
||||
|
||||
Frozen **14-file126/126**, types/lint/build and **nine native runtime/SSR/paid
|
||||
callback journeys** pass. Twelve native cases cover real public R/G/A/E setup,
|
||||
original cache Box1 purchase, both selections, Basic immediate delivery, Advanced
|
||||
same-card/separate-card stops and restored interception, sole automatic recovery,
|
||||
rejection/one fee and all four minimal profiles. All45 original cards remain;
|
||||
source actions are normal60 and sole Basic55/Advanced60. Seven controlled UI
|
||||
supplements and two controlled engine cases add guidance, busy/callbacks and
|
||||
suspended conversion. Initial feedback corrected the Basic interception assumption,
|
||||
a test-only narrowing assertion and a label helper's string conversion.
|
||||
|
||||
Proof: `/home/quintine/.local/share/dune/checkpoints/20261026-box-shipment-price-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261026-box-shipment-price-runtime/`.
|
||||
No saved-room/D1/server, reset/restart, deployment or recovery operation. No new
|
||||
mode admission; full rules/combinations, DOM/hydration, browser/mobile and final
|
||||
assurance remain due. [Source and selected policy](NULLENTROPY_BOX_ENGINE_AUDIT.md#shipping-price-cost-recovery--26-october-2026).
|
||||
|
||||
## 26 October 2026 — Reactive Fremen Nexus worm prevention
|
||||
|
||||
**Prototyped / Partial.** Fremen Nexus Betrayal now responds at actual unmade ordinary/Great Maker
|
||||
|
||||
@@ -2,6 +2,38 @@
|
||||
|
||||
Audit: 2026-09-06, with Basic and Advanced runtime amendments on 8 October below. This is a source/continuation contract, not complete Richese certification. Read with [the acquisition audit](RICHESE_ACQUISITION_RULES.md). The full-hand publisher question remains unresolved; the user's first-version instruction permits the visibly qualified prototype, not a new timing ruling.
|
||||
|
||||
## Shipping-price cost recovery — 26 October 2026
|
||||
|
||||
Box can now search while the original purchased-shipment cost remains in
|
||||
discard. Every eligible non-Box card remains selectable. Selecting that exact
|
||||
cost ends its provisional special-stop refund eligibility, including after a
|
||||
later rediscard; the purchased price, creator and beneficiary remain unchanged.
|
||||
Selecting a different card leaves the original cost's existing refund status
|
||||
unchanged. This extends the [selected Semuta nonduplication policy](SEMUTA_SOURCE_UPDATE_20260907.md#shipment-price-cost-and-claimed-card-refund--16-october-2026)
|
||||
under the 14 October choose-recommended authority, not a publisher worked example.
|
||||
|
||||
The engine marks recovered-cost provenance before prospective continuation
|
||||
validation and only commits it with a valid physical selection. A suspended
|
||||
Worthless conversion retains that provenance in its original parent response;
|
||||
passing activates the original price, while cancellation still cancels it.
|
||||
Payment, search lock, disposal order and all other resource/commitment checks
|
||||
remain. The former blanket pending-refund guard below is superseded.
|
||||
|
||||
Frozen14-file126/126, types/lint/build and nine native engine/GameTable SSR/paid
|
||||
selection-callback journeys pass. Real public R/G/A/E setup, cache Box1 purchase,
|
||||
original Weather alternative and Atreides price retain all45 physical cards.
|
||||
Normal sources use60 actions; sole sources use Basic55/Advanced60. Both choices
|
||||
keep the bank invoice, Advanced original interception resumes exactly, claimed
|
||||
same-card stops do not refund, and unclaimed separate-card stops retain the
|
||||
existing refund. Basic still settles immediately without Advanced special powers.
|
||||
Sole recovery, stale/foreign denial and all four minimal profiles are included.
|
||||
Controlled projection/engine supplements remain distinct from original sources.
|
||||
|
||||
Report/logs: `/home/quintine/.local/share/dune/checkpoints/20261026-box-shipment-price-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261026-box-shipment-price-runtime/`. No saved-room/D1/
|
||||
server, deployment, restart or recovery operation. DOM/hydration, browser/mobile,
|
||||
complete rules/combinations and final assurance remain due.
|
||||
|
||||
## Advanced full-hand exchange follow-up — 8 October 2026
|
||||
|
||||
Advanced now shares the Basic atomic final-capacity policy below.
|
||||
@@ -58,7 +90,11 @@ This is an ordinary card effect: no generic faction/alliance Karama cancellation
|
||||
|
||||
The full-hand intermediate-capacity question remains unresolved. Basic and Advanced use the explicit atomic-exchange prototype above. Empty or only-Box piles still have no legal result and are rejected before payment under the existing unsupported-empty-search policy. There is no deck fallback or permission to waste the fee; every Box is excluded from retrieval.
|
||||
|
||||
A further boundary comes from an existing provisional engine policy, not the Box source. `executeSpecialKaramaIntent` for Guild may refund `g.karamaShipping.card` by taking that exact previously discarded Karama back into its original owner's hand. Box could first recover that same card. Pausing the pile during search does not resolve the later conflicting claim. Do not silently blacklist an otherwise legal non-Box search card as an official rule. Until the provisional refund policy is resolved, guard that particular pending-refund context before payment and label the reason accurately. No broader ban on Box during Shipment is justified.
|
||||
The existing provisional Guild special-stop policy can refund the exact original
|
||||
shipping cost only while it remains unclaimed. The 26 October composition above
|
||||
supersedes the earlier search ban: Box recovery marks that exact cost as claimed,
|
||||
preserving the price without creating a later second claim to the card. No
|
||||
otherwise legal non-Box card is blacklisted, and no broader Shipment ban applies.
|
||||
|
||||
## Proposed persisted contract
|
||||
|
||||
@@ -109,7 +145,7 @@ In `game/engine.ts`:
|
||||
| `draw` | When deck is empty it shuffles all of `g.discard` into the deck and empties the pile. | Prevent during search, including automatic callers. Selection never calls `draw`. |
|
||||
| `discard` | Ordinary card play, Karama, battle cleanup, CHOAM, Terror and Ambassador exchanges append cards. | Lock or reserve through the explicit settlement path. |
|
||||
| Distrans early branch | Assigns both hands and appends its discarded activation directly, bypassing `discard`. | Central pending-search lock before this branch; protect the played Box in transfer validation too. |
|
||||
| Guild special Karama | Directly splices its refundable shipping Karama out of discard. | Lock while browsing; resolve the pre-existing future-refund claim before allowing search, as described above. |
|
||||
| Guild special Karama | Directly splices its refundable shipping Karama out of discard. | Lock while browsing; mark exact recovered-cost provenance at selection as described above; future unclaimed refunds remain separate. |
|
||||
| CHOAM cash-in / special | Early dispatch can discard many cards without the ordinary decision branch. | Lock before early dispatch; do not let a cash-in remove the reserved Box. |
|
||||
| Ixian replacement / technology, Harkonnen bonus, auction/setup dealing | Draws may recycle the searched pile; accepted replacements also discard. | Preserve their parent state and prevent completion until search settles. |
|
||||
| Battle winner cards / Moritani retention / Terror discard / Ecaz Ixian effect | Finalizers append cards and then continue into other effects. | Suspend the exact decision; do not replay or pre-finish its disposal. |
|
||||
@@ -144,7 +180,7 @@ Others receive only the public pending decision owner and a generic paid-search
|
||||
|
||||
Semuta's printed reaction is to another player's discard. The relevant new event is the final Box discard; selecting a card from discard is not a new discard and shuffling is not a series of discard events. Future Semuta implementation must react to the completed Box disposal before advancing past any required immediate reaction, using one event-bound physical Box. It must not rerun the paid search or reclaim the selected card as though that card was discarded. Precise concurrent Semuta priority and full-hand timing remain outside the verified Box contract; do not claim an invented FAQ order.
|
||||
|
||||
Required focused cases: one fee/one shuffle/exact top Box; every non-Box family selectable; every Box excluded; empty/full/funding/commitment failures immutable before inspection; all viewers before/after payment and after expiry; queued Truthtrance and early CHOAM/Distrans blocked while browsing; parent response/decision/opening preserved; deck-empty bonus cannot recycle the pile during search; future Guild refund guard; promise-preserving fee; reserved gift/accepted Ix Box cannot activate; JSON reload and duplicate begin/select CAS; no-op authenticated GET and automatic workers; private AI selection on all profiles. No such runtime tests were executed for this document-only audit.
|
||||
Required focused cases: one fee/one shuffle/exact top Box; every non-Box family selectable; every Box excluded; empty/full/funding/commitment failures immutable before inspection; all viewers before/after payment and after expiry; queued Truthtrance and early CHOAM/Distrans blocked while browsing; parent response/decision/opening preserved; deck-empty bonus cannot recycle the pile during search; exact claimed/unclaimed shipping-cost refund; promise-preserving fee; reserved gift/accepted Ix Box cannot activate; JSON reload and duplicate begin/select CAS; no-op authenticated GET and automatic workers; private AI selection on all profiles. No such runtime tests were executed for this document-only audit.
|
||||
|
||||
## Pure custody helper checkpoint
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Rule decision index
|
||||
|
||||
## Box recovery of a purchased shipment cost — 26 October 2026
|
||||
|
||||
Under the 14 October choose-recommended authority, extend the selected Semuta
|
||||
nonduplication policy to paid Box recovery. Every eligible non-Box remains
|
||||
selectable. Recovering the exact active or pending-conversion shipment cost
|
||||
ends its provisional Guild special-stop refund, even after rediscard, while
|
||||
retaining the original price, creator and beneficiary. A different selection
|
||||
does not alter that cost's claim status. The original conversion response may
|
||||
still pass or cancel normally. This supersedes the blanket Box search guard;
|
||||
it is a provisional composition, not a publisher worked example or new mode gate.
|
||||
[Source and continuation boundary](NULLENTROPY_BOX_ENGINE_AUDIT.md#shipping-price-cost-recovery--26-october-2026).
|
||||
|
||||
## Fremen Betrayal at an actual pending ride — 26 October 2026
|
||||
|
||||
The printed turn-long worm-riding alternative now has an executable reactive
|
||||
|
||||
+16
-10
@@ -4697,16 +4697,6 @@ function validateNullentropyBegin(g: Game, owner: Player, cardId: string) {
|
||||
owner.hand.length <= limit,
|
||||
'The final exchanged hand must not exceed its hand limit.',
|
||||
);
|
||||
const guild = byFaction(g, 'guild');
|
||||
requireRule(
|
||||
!(
|
||||
g.karamaShipping?.card &&
|
||||
guild &&
|
||||
!guild.specialKaramaUsed &&
|
||||
g.discard.some((c) => 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<Game, 'karamaShipping' | 'pendingKarama'>,
|
||||
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,
|
||||
|
||||
+7
-4
@@ -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',
|
||||
|
||||
@@ -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