Bind printed transfer and order plays to Truthtrance

This commit is contained in:
Quintine committed 2026-10-27 08:00:08 +11:00
1 parent 3e959504ef
commit 4c4bebe85e
25 files changed
+1245 -57

No files matched your search

+8 -4
View File
@@ -76,13 +76,15 @@ const config: MixedActionConfig<TurnActionLeaf> = {
or requested Prescience field in this battle. They count before later Karama prevention.
A requested field is not the opponent&apos;s answer or inspected value. Borrowed Nexus and special powers do not count.
These conditions close at the actual native choice or when its original source expires.
Printed-card conditions ask you to play Hajr, Tleilaxu Ghola or Nullentropy Box in its printed role after this answer this turn.
Printed-card conditions ask you to play Hajr, Tleilaxu Ghola, Nullentropy Box, Distrans or Juice of Sapho in its printed role after this answer this turn.
An earlier play, discard, sale, Karama role or recovery alone does not count; an actual later replay after Box recovery does.
Hajr counts when played even if its extra movement is unused. Tleilaxu Ghola is a card play, separate from ordinary Revival.
Hajr conditions close at your own Movement turn end; Tleilaxu Ghola conditions close at the actual turn end.
Nullentropy Box counts when its printed activation is accepted and you pay 2 spice to begin the private search.
Earlier activation or recovery alone does not count. The selected recovered card stays private and is not promised by this condition.
Box conditions close at the actual turn end; complete the paid search with the existing native controls.
Distrans and Juice of Sapho conditions count an actual later printed use, not a particular recipient, transferred card or order outcome.
Their native controls retain those choices. The deadline is the actual turn end, including for a whole No answer.
Weather Control, Family Atomics and Harvester conditions count an actual accepted future printed-role play after this answer
in the original open Storm or fresh spice-blow window. Earlier plays, disposal, other roles and recovery alone do not count.
They do not ask for a selected storm distance or resulting spice amount. Each condition closes only when its original native window closes.
@@ -169,7 +171,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
if (kind === 'ship' || kind === 'move') onChange({ kind,
territory: value.kind === 'ship' || value.kind === 'move' ? value.territory : 'carthag', minimum });
}}>
<option value="cardPlay">Play Hajr, Tleilaxu Ghola or Nullentropy Box in its printed role</option>
<option value="cardPlay">Play Hajr, Tleilaxu Ghola, Nullentropy Box, Distrans or Juice of Sapho in its printed role</option>
<option value="windowCardPlay">Use a printed card in this original native window or shipment-price opportunity</option>
<option value="battlePower">Declare native Voice or Prescience in this battle</option>
<option value="battlePlan">Use an actual current battle-plan choice</option>
@@ -185,13 +187,15 @@ const config: MixedActionConfig<TurnActionLeaf> = {
{value.kind === 'cardPlay' ? <>
<label htmlFor={`${id}-effect`}>Printed card role after this answer</label>
<select id={`${id}-effect`} value={value.effect} disabled={disabled}
aria-invalid={value.effect !== 'hajr' && value.effect !== 'ghola' && value.effect !== 'nullentropyBox'} onChange={event => {
aria-invalid={!['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho'].includes(value.effect)} onChange={event => {
const effect = event.currentTarget.value;
if (effect === 'hajr' || effect === 'ghola' || effect === 'nullentropyBox') onChange({ kind: 'cardPlay', effect });
if (effect === 'hajr' || effect === 'ghola' || effect === 'nullentropyBox' || effect === 'distrans' || effect === 'juiceOfSapho') onChange({ kind: 'cardPlay', effect });
}}>
<option value="hajr">Play Hajr in its printed role after this answer this turn</option>
<option value="ghola">Play Tleilaxu Ghola in its printed role after this answer this turn</option>
<option value="nullentropyBox">Activate Nullentropy Box and pay 2 spice after this answer this turn</option>
<option value="distrans">Play Distrans in its printed role after this answer this turn</option>
<option value="juiceOfSapho">Play Juice of Sapho in its printed role after this answer this turn</option>
</select>
</> : value.kind === 'windowCardPlay' ? <>
<label htmlFor={`${id}-window-effect`}>Printed role in the original window</label>
+54 -5
View File
@@ -36,6 +36,9 @@ const namesWindow = (mixed: MixedActionExpression<TurnActionLeaf>, effect: Windo
const namesBoxPlay = (mixed: MixedActionExpression<TurnActionLeaf>): boolean => mixed.kind === 'action'
? mixed.action.kind === 'cardPlay' && mixed.action.effect === 'nullentropyBox'
: mixed.kind !== 'fact' && mixed.terms.some(namesBoxPlay);
const namesPrintedPlay = (mixed: MixedActionExpression<TurnActionLeaf>, effect: 'distrans' | 'juiceOfSapho'): boolean => mixed.kind === 'action'
? mixed.action.kind === 'cardPlay' && mixed.action.effect === effect
: mixed.kind !== 'fact' && mixed.terms.some(term => namesPrintedPlay(term, effect));
/** One projected native packet; no client branch selection or completion solver. */
export function TurnActionGuideButton(props: TurnActionProps) {
@@ -122,6 +125,21 @@ export function TurnActionGuideButton(props: TurnActionProps) {
return JSON.stringify({ cash: owner?.spice, hand: owner?.hand?.map(card => card.id), limit: owner?.handLimit });
};
const boxOwnerSignature = heldCard?.effect === 'nullentropyBox' ? boxOwnerSource(game) : null;
const distrans = heldCard?.effect === 'distrans' ? game.distrans : null;
const distransSignature = JSON.stringify(distrans);
const saphoOptions = heldCard?.effect === 'juiceOfSapho' ? game.saphoOptions : null;
const saphoSignature = JSON.stringify(saphoOptions);
const printedHandSource = (current: GameView) => {
const owner = current.players.find(player => player.id === actor);
return JSON.stringify({ hand: owner?.hand?.map(card => ({ id: card.id, kind: card.kind, effect: card.effect })), limit: owner?.handLimit });
};
const printedHandSignature = heldCard?.effect === 'distrans' || heldCard?.effect === 'juiceOfSapho' ? printedHandSource(game) : null;
const distransCapacitySource = (current: GameView) => {
const recipient = current.players.find(player => player.id === action.target);
return JSON.stringify(recipient && { id: recipient.id, count: recipient.handCount, limit: recipient.handLimit });
};
const distransCapacitySignature = heldCard?.effect === 'distrans' ? distransCapacitySource(game) : null;
const saphoDecisionSignature = heldCard?.effect === 'juiceOfSapho' ? JSON.stringify(decision) : null;
const auctionCards = game.karamaAuctionCards, auctionCardsSignature = JSON.stringify(auctionCards);
const shipmentCards = game.karamaShipmentCards, shipmentCardsSignature = JSON.stringify(shipmentCards);
const thumperCards = game.thumperCards, thumperCardsSignature = JSON.stringify(thumperCards);
@@ -207,10 +225,32 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const printedBox = ownCard && !!heldCard && heldCard.kind === 'special' && heldCard.effect === 'nullentropyBox' &&
quotedBoxCard && !!windowPromise && current.turnActionPromises.includes(windowPromise) &&
JSON.stringify(windowPromise.mixed) === windowQuestion && namesBoxPlay(windowPromise.mixed);
const printedRoleSource = ownCard && !!heldCard && heldCard.kind === 'special' && !!windowPromise &&
current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion &&
printedHandSource(current) === printedHandSignature;
const distransChoice = typeof action.target === 'string' && action.target !== actor &&
current.players.some(player => player.id === action.target) ? distrans?.choices.find(choice => choice.recipient === action.target) : undefined;
const distransGiven = typeof action.give === 'string' && action.give !== heldCard?.id
? distransChoice?.cards.find(card => card.id === action.give) : undefined;
const heldGiven = heldCard?.effect === 'distrans' && me?.hand?.find(card => card.id === action.give);
const printedDistrans = printedRoleSource && heldCard?.effect === 'distrans' && namesPrintedPlay(windowPromise!.mixed, 'distrans') &&
!!distrans && current.distrans === distrans && JSON.stringify(current.distrans) === distransSignature &&
distrans.blocked === null && distrans.card.id === heldCard.id && distrans.card.kind === 'special' && distrans.card.effect === 'distrans' &&
!!distransChoice && distransChoice.blocked === null && !!distransGiven && !!heldGiven &&
distransGiven.kind === heldGiven.kind && distransGiven.effect === heldGiven.effect &&
distransCapacitySource(current) === distransCapacitySignature &&
Object.keys(action).every(key => ['type', 'card', 'target', 'give', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key));
const printedSapho = printedRoleSource && heldCard?.effect === 'juiceOfSapho' && namesPrintedPlay(windowPromise!.mixed, 'juiceOfSapho') &&
!!saphoOptions && current.saphoOptions === saphoOptions && JSON.stringify(current.saphoOptions) === saphoSignature &&
current.decision === decision && JSON.stringify(current.decision) === saphoDecisionSignature &&
saphoOptions.some(option => option.scope === action.scope && option.event === action.event && option.mode === action.mode) &&
Object.keys(action).every(key => ['type', 'card', 'scope', 'event', 'mode', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key));
const saphoDecision = printedSapho && phase === 8 && action.scope === 'extortion' && current.decision === decision &&
current.decision?.kind === 'moritaniExtortion' && JSON.stringify(current.decision) === saphoDecisionSignature;
const genericCard = ownCard && action.type === 'card' && !auctionPurchaseAction && !printedShipmentAction && quotedBoxCard &&
(!boxRoleNamed || heldCard?.effect !== 'nullentropyBox' || printedBox) &&
(!shipmentPriceAction || (action.target ?? actor) === current.active) &&
!!heldCard && heldCard.effect !== 'ghola' && heldCard.effect !== 'hajr' && !isWindowEffect(heldCard.effect);
!!heldCard && heldCard.effect !== 'ghola' && heldCard.effect !== 'hajr' && heldCard.effect !== 'distrans' && heldCard.effect !== 'juiceOfSapho' && !isWindowEffect(heldCard.effect);
const earlyWindowBox = (phase === 0 || phase === 1 || phase === 3 || phase === 5) && genericCard && heldCard?.effect === 'nullentropyBox' &&
action.mode === undefined && Object.keys(action).every(key => ['type', 'card', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key)) &&
!!box && current.nullentropy === box &&
@@ -293,7 +333,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
!current.ready.includes(actor) && me.charityClaimed === false && current.charity === charity &&
charitySource(current) === charitySignature && current.charity.incomePending === false &&
Number.isSafeInteger(current.charity.amount) && current.charity.amount > 0;
const opportunity = printedBox || openingReady || earlyWindowReady || earlyWindowBox || printedWindow || ownedPower || payment || paidBox || printedHajr || printedGhola || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2
const opportunity = printedDistrans || printedSapho || printedBox || openingReady || earlyWindowReady || earlyWindowBox || printedWindow || ownedPower || payment || paidBox || printedHajr || printedGhola || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2
? charityClaim || !current.ready.includes(actor) && (action.type === 'ready' || genericCard)
: phase === 3
? invoice || nativePass || requiredBid || action.type === 'passBid' && !current.richeseAuction && currentAuction && current.auction?.active === actor
@@ -311,7 +351,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
current.turnActionPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
!promise.fulfilled && !promise.released) &&
(!amalReadyAction || openingReady) &&
!!me && !me.autopilot && !current.truthtrance && !current.response && (!current.decision || paidBox || invoice || earlyWindowBox && ownInvoiceSource) &&
!!me && !me.autopilot && !current.truthtrance && !current.response && (!current.decision || paidBox || invoice || saphoDecision || earlyWindowBox && ownInvoiceSource) &&
(!current.phaseOpening || allowedOpeningAction) && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
const amount = action.forces && typeof action.forces === 'object'
@@ -321,6 +361,9 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const recipient = game.players.find(player => player.id === action.target)?.name ?? 'the quoted recipient';
const gholaLeader = action.leader === 'kwisatz' ? 'Kwisatz Haderach'
: ghola?.leaders.find(leader => leader.id === action.leader)?.name ?? 'the quoted leader';
const saphoScope = action.scope === 'onceAround' ? 'Once Around bidding' : action.scope === 'cyclicAuction' ? 'this cyclic auction'
: action.scope === 'movement' ? 'remaining Movement order' : action.scope === 'battleOrder' ? 'remaining battle order'
: action.scope === 'extortion' ? 'Extortion order' : action.scope === 'revival' ? 'remaining Revival order' : 'the quoted native order';
const sector = typeof action.sector === 'number' ? `, sector ${action.sector}` : '';
const label = action.type === 'bribe' ? `Pay ${amount} spice to ${recipient} while honoring your whole answer`
: action.type === 'card' && heldCard?.effect === 'hajr' ? 'Play Hajr in its printed role while honoring your whole answer'
@@ -330,6 +373,9 @@ export function TurnActionGuideButton(props: TurnActionProps) {
: action.type === 'card' && windowEffect === 'karamaAuction' ? 'Spend a printed Karama to purchase this ordinary lot while honoring your whole answer'
: action.type === 'card' && windowEffect === 'karamaShipment' ? `Spend a printed Karama to grant ${recipient} this native shipment price while honoring your whole answer`
: action.type === 'card' && heldCard?.effect === 'nullentropyBox' ? 'Pay 2 spice to activate Nullentropy Box while honoring your whole answer'
: action.type === 'card' && heldCard?.effect === 'distrans' ? `Give ${distrans?.choices.find(choice => choice.recipient === action.target)?.cards.find(card => card.id === action.give)?.name ?? 'the quoted card'} to ${recipient} with Distrans while honoring your whole answer`
: action.type === 'card' && heldCard?.effect === 'juiceOfSapho' ? `Play Juice of Sapho: ${action.scope === 'battleAggressor'
? 'become this battle’s aggressor' : `${saphoScope}, ${String(action.mode)}`} while honoring your whole answer`
: action.type === 'card' && windowEffect ? `Play ${windowRoleNames[windowEffect]} in its original native window${windowEffect === 'weather' ? ` with storm distance ${amount}` : ''} while honoring your whole answer`
: action.type === 'battlePlan' ? 'Fill the quoted battle plan for review before sealing'
: action.type === 'voice' ? `Declare native Voice: opponent ${action.must ? 'must' : 'cannot'} play ${battleCardLabel(String(action.kind))}`
@@ -422,13 +468,15 @@ export function TurnActionPromises(props: TurnActionProps) {
Native Voice and Prescience conditions describe your original use or explicit decline, exact Voice command,
or requested Prescience field, before later Karama prevention. The requested field is not the inspected answer or value.
Borrowed Nexus and special powers do not count. These conditions close at the actual native choice or when its original source expires.
Printed-card conditions count actual future Hajr, Tleilaxu Ghola or Nullentropy Box plays in their printed role after the answer this turn.
Printed-card conditions count actual future Hajr, Tleilaxu Ghola, Nullentropy Box, Distrans or Juice of Sapho plays in their printed role after the answer this turn.
Earlier plays, discards, sales, Karama roles and recovery alone do not count; an actual later Box replay does.
Hajr counts even if its extra movement is unused and closes at your own Movement turn end.
Tleilaxu Ghola is separate from ordinary Revival and remains open until the actual turn ends.
Nullentropy Box counts when its printed activation is accepted and you pay 2 spice to begin the private search.
Earlier activation or recovery alone does not count. The selected recovered card stays private and is not promised by this condition.
Box conditions close at the actual turn end; complete the paid search with the existing native controls.
Distrans and Juice of Sapho conditions count the actual later printed use, not a particular recipient, transferred card or order outcome.
Their native controls retain those choices. The deadline is the actual turn end, including for a whole No answer.
Weather Control, Family Atomics and Harvester conditions count an actual accepted future printed-role play after the answer
in the original open Storm or fresh spice-blow window. Earlier plays, disposal, other roles and recovery alone do not count.
They do not promise a selected storm distance or resulting spice amount. Each condition closes only when its original native window closes.
@@ -488,9 +536,10 @@ export function TurnActionAnswers(props: TurnActionProps) {
A remaining alliance condition stays open through temporary membership until that close. Neither answer promises another player’s consent.
Current battle-plan conditions use the actual native plan; an open condition settles when both plans are revealed.
Traitor conditions use the original Reveal or decline declaration, before later Karama or betrayal prevention.
Hajr, Tleilaxu Ghola and Nullentropy Box conditions require actual later printed-role plays, not earlier play, disposal or recovery alone.
Hajr, Tleilaxu Ghola, Nullentropy Box, Distrans and Juice of Sapho conditions require actual later printed-role plays, not earlier play, disposal or recovery alone.
Box counts at accepted activation and payment of 2 spice; the recovered card is private and is not promised by this condition.
Hajr closes at your own Movement turn end even if its extra move is unused; Ghola and Box close at the actual turn end.
Distrans and Juice of Sapho, including No, close at the actual turn end; a recipient, transferred card or order outcome is not promised.
Bids close with their lot; Revival closes with your native Revival opportunity.
Shipment and movement close at your own Movement turn end; payment conditions last until the actual turn ends.
A known Inflation Double opening can close payments earlier without closing Ghola.
+30
View File
@@ -20,6 +20,36 @@ open mode gates.
Continue remaining Basic functions before Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Truthtrance / printed transfer and order cards — 27 October. Prototyped / Partial.**
Actual Distrans and Sapho use can be promised without fixing a transfer or order
choice. Costs, current native options, whole AND/OR/No, original turn-end caps,
frozen facts, exact human guides and four legal profiles connect. Upcoming
Movement uses public order/counters without new income. A real ordinary-auction
Semuta claim retains the original lot and standing bid.
Frozen 25 affected test files pass **410/410**, with types/lint/build and
six journey groups/eight original-source programmes. Runtime includes 39
GameTable SSR renders, 8 answer callbacks, 7 whole-guide callbacks,
1 native Sapho callback and 2 real Semuta commit callbacks. Actual cache
purchases, unopened Truth deals, budgets, current turns and inventories remain.
Source/continuation counts derive from arrays; no played state is installed.
Focused feedback finds the unfinished ordinary-auction discard fence and a
fixture that expected an already-last Guild-TAKE tail to move last again. The
native Semuta continuation is connected; older prebid/late-battle drivers now
finish the real discard before continuing. Current legal roles take guide
priority over deferral, and immediate No blocks native controls/policy fallback.
The Sapho no-op rule is preserved.
Real early Revival/pay9/Movement uses Sapho free after cash reaches zero.
A separate actual turn2 Box recovery does not itself count as Distrans replay.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-transfer-order-proof-v3/`;
journeys/HTML: adjacent `20261027-truth-transfer-order-runtime/run/`.
No HTTP/storage boundary, saved-game/database, restart or deployed operation
changes. Future Semuta promises, other future calendars/funding, combinations,
complete modes and final assurance/browser/deployed acceptance remain open.
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-transfer-and-order-cards--27-october-2026).
**Truthtrance / printed Nullentropy Box — 27 October. Prototyped / Partial.**
Whole questions can name actual printed activation after answering. The accepted
two-spice fee records use before private selection; the selected card is outside
+20
View File
@@ -1,5 +1,25 @@
# Dune implementation status
## 27 October 2026 — Printed transfer/order commitments
Prototyped / Partial. Distrans and Sapho actual use share native costs/options
with other whole commitments, retaining private choices and full-turn No caps.
Upcoming native Movement creates no income or foreign actions. Completed normal-
auction Truth/Distrans costs now retain their original standing lot through
Semuta. Native no-ops remain; actual early pay9/Sapho and paid Box/replay paths
retain physical chronology. Human controls and four legal profiles connect.
Frozen 25 affected test files pass **410/410**, with types/lint/build and
six journey groups/eight original-source programmes. Runtime includes 39
GameTable SSR renders, 8 answer callbacks, 7 whole-guide callbacks,
1 native Sapho callback and 2 real Semuta commit callbacks. Actual cache
purchases, unopened Truth deals, budgets, current turns and inventories remain.
Source/continuation counts derive from arrays; no played state is installed.
No strategic AI, recovery campaign, saved-game operation, restart or deployment
occurs. Broader future actions/calendars and full acceptance remain unfinished.
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-transfer-and-order-cards--27-october-2026).
Private proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-transfer-order-proof-v3/`.
## 27 October 2026 — Printed Nullentropy Box commitments
Prototyped / Partial. Actual paid activation records the future printed play;
+10
View File
@@ -13,6 +13,16 @@ contract supersede the earlier absence of that mode below. The
and a fixed public final opportunity in fresh Richese battles; old battles keep
their early protocol.
## Future printed-use commitment — 27 October 2026
Whole Truthtrance questions can now name actual printed Sapho use after
answering in this turn. They do not fix a scope/mode/result. Current native
options and an upcoming original Movement quote share the original resources;
only a current exact option becomes an action guide. Native no-op, Guild,
retained-current and own-started-turn policies remain. No lasts to turn end,
independently of Movement expiry. Other future ordering calendars remain partial.
[Contract and evidence](TRUTHTRANCE_TURN_ACTIONS.md#printed-transfer-and-order-cards--27-october-2026).
## Ordered Advanced Revival — 26 October 2026
**Prototyped / Partial.** First/last now applies to the unfinished opportunities
+11
View File
@@ -2,6 +2,17 @@
Audit date: 2026-09-06. Read `docs/RICHESE_ACQUISITION_RULES.md` first. This audit does not activate the card or certify the remaining Richese effects.
## Future printed-use commitment — 27 October 2026
Whole Truthtrance questions can name actual future printed Distrans use this
turn, without fixing its recipient or given card. The same native validator
owns custody, capacity and timing; its actual transfer/disposal records use.
Private completion retains other physical-card and money commitments. Current
exact guides reuse the actual private choices. An ordinary lot also retains its
standing state through the used card's Semuta window. Neither behavior adds a
fee, recipient consent, new bid opportunity or new start permission.
[Contract and evidence](TRUTHTRANCE_TURN_ACTIONS.md#printed-transfer-and-order-cards--27-october-2026).
## Untouched ordinary lot — 26 October 2026
The printed exception excludes use during a bid, without defining its complete
+14
View File
@@ -1,5 +1,19 @@
# Rule decision index
## Truthtrance printed transfer/order use — 27 October 2026
Prototyped / Partial. Future Distrans and Sapho predicates concern actual native
printed use, not the selected transfer or order result. Reuse current validators,
physical costs and a shared completion purse; unknown acquisitions provide no
new effect. Their No intervals close at turn end. Upcoming original Movement
uses public order/entry counters without inventing income or foreign actions.
Existing native no-op, Guild and retained-current timing remains authoritative.
Completed Truthtrance/Distrans costs can now pause an ordinary auction for
Semuta while retaining its standing bid; Semuta has no printed bid exception,
while Distrans's separate existing timing policy remains. This is bounded
composition, not a new publisher priority ruling or full-mode certificate.
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-transfer-and-order-cards--27-october-2026).
## Truthtrance printed Box activation — 27 October 2026
Prototyped / Partial. A future printed Nullentropy Box play counts when its real
+13
View File
@@ -2,6 +2,19 @@
7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits.
## Completed ordinary-auction Truth/Distrans costs — 27 October 2026
The native preview now preserves an ordinary lot while offering a completed
Truthtrance or Distrans discard to Semuta. A standing ordinary bid remains
committed. Existing signed continuations retain the lot, bid, actor, hands and
prices; claim/pass does not replay the card effect or begin another auction.
The printed Semuta effect has no during-bid exclusion. Distrans's own distinct
bid-start guard remains, so it can produce this cost only before an ordinary
bid/pass or in another already allowed native window. This removes an unfinished
composition fence, not a new source priority ruling. Future Semuta commitment
coverage and broader optional/auction families remain separate.
[Connected contract and evidence](TRUTHTRANCE_TURN_ACTIONS.md#printed-transfer-and-order-cards--27-october-2026).
## Guild Ambassador and Revival Spiritual costs — 26 October 2026
**Prototyped / Partial.** Existing `karamaTerminalDiscard` and
+50
View File
@@ -1,5 +1,55 @@
# Truthtrance combined current-turn actions
## Printed transfer and order cards — 27 October 2026
**Prototyped / Partial.** `cardPlay/distrans` and `cardPlay/juiceOfSapho`
count actual native printed use after answering this turn. Distrans transfers
another held eligible card and disposes its own physical cost. Sapho uses one
currently supported native order/aggressor option and disposes its card. The
predicate names neither a transfer recipient/card nor a Sapho scope, mode or
outcome. Earlier use, recovery alone and another role do not count. Both No
intervals last through Mentat; only Hajr retains Movement-end expiry.
Native validators own capacity, reservations, timing, completed actions and
legal choices. Private completion applies those same costs/effects before
checking every other commitment. Distrans and Sapho add no spice fee. A giver
cannot lose another required physical card to satisfy a transfer, and another
player's started combined turn remains unsplit. Current guide packets must
still match the exact current native choices; later calendar actions are not
sent early. Existing known recovery remains possible, without borrowing an
unseen future acquired effect. No route or choice is installed in the real game.
Earlier Sapho questions can quote an upcoming original Movement opportunity
with the public order and normal entry counters. Existing earlier owned
preparation and the same purse remain. That quote grants no new income, cards,
foreign move or Guild decision. Native first/last/no-op and retained-current
policies remain; an already-last tail cannot consume Sapho merely to stay last.
Other future ordering calendars and broader preparations remain partial.
Completed public Truthtrance/Distrans costs now retain an ordinary auction
through their signed Semuta continuation, including a standing normal bid.
Semuta has no printed during-bid exclusion; Distrans keeps its separate existing
bid-start guard. The neutral window and real claim preserve the original lot,
bid, price and actor, without replaying a transfer or starting another auction.
This extends the native discard path, not future Semuta commitment coverage.
Strict optional role stages preserve old row bytes. Human editors, persistent
guidance, exact native callbacks and four minimally legal profiles connect the
new roles. Immediate No restrictions also block the native manual choices and
minimal-policy fallback. Strategy/calibration remain deferred. This is bounded software
composition of existing Truthtrance and native card contracts, not a dedicated
publisher worked example. Wider future actions/income, combinations, complete
modes and final assurance/deployed acceptance remain open.
Frozen 25 affected test files pass **410/410**, with types/lint/build and
six journey groups/eight original-source programmes. Runtime includes 39
GameTable SSR renders, 8 answer callbacks, 7 whole-guide callbacks,
1 native Sapho callback and 2 real Semuta commit callbacks. Actual cache
purchases, unopened Truth deals, budgets, current turns and inventories remain.
Source/continuation counts derive from arrays; no played state is installed.
Private proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-transfer-order-proof-v3/`;
journeys/HTML: adjacent `20261027-truth-transfer-order-runtime/run/`.
This is local native/SSR/callback evidence, not hydration or deployed acceptance.
## Printed Nullentropy Box — 27 October 2026
**Prototyped / Partial.** `cardPlay/nullentropyBox` concerns an accepted printed
+126 -12
View File
@@ -5156,6 +5156,8 @@ function distransBlock(g: Game, owner: Player, card: Card): string | null {
return 'Distrans is available during an active game.';
if (g.truthtrance)
return 'Resolve the active Truthtrance question before changing hands.';
const promised = printedRolePlayBlock(g, owner.id, 'distrans');
if (promised) return promised;
const reserved = transferCardBlock(g, owner, card);
if (reserved) return reserved;
return distransAuctionBlock(g);
@@ -8586,6 +8588,11 @@ function semutaOfferSupported(
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
entries: FreshDiscardBatch['entries'],
) {
// These completed public costs retain the original ordinary lot through the
// existing signed continuation. Semuta itself has no during-bid exception;
// native Distrans still cannot produce a transfer after that bid starts.
const ordinaryAuctionDiscard = g.phase === 3 && !!g.auction && !g.richeseAuction && !g.currentAuctionSale &&
(continuation.kind === 'truthtranceDiscard' || continuation.kind === 'distransDiscard');
const richeseGiftDiscard = continuation.kind === 'karamaRicheseGiftDiscard';
const richeseDiscard = richeseGiftDiscard || continuation.kind === 'karamaRicheseAuctionDiscard';
const auctionAdvantageDiscard = continuation.kind === 'karamaAuctionAdvantageDiscard';
@@ -8903,7 +8910,7 @@ function semutaOfferSupported(
continuation.use.response.kind === 'emperorIncome'
? !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal'
? !!g.auction && !g.richeseAuction : !!g.richeseAuction && !g.auction)
: (continuation.kind === 'saphoAuctionDiscard' && continuation.cyclic === 'normal') || !g.auction)) &&
: ordinaryAuctionDiscard || (continuation.kind === 'saphoAuctionDiscard' && continuation.cyclic === 'normal') || !g.auction)) &&
(advisorDiscard || auctionCostDiscard || bonusDiscard || auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' ||
continuation.kind === 'ixAllyCard' ||
(continuation.kind === 'karamaIncomeDiscard' &&
@@ -19009,6 +19016,14 @@ function printedCardRule<T>(operation: () => T): T {
function nativePrintedCardCount(g: Game, player: string, effect: PrintedCardEffect): number {
return printedCardRule(() => printedCardPlayCount(g.printedCardPlays, player, g.turn, effect));
}
/** Read-only immediate No constraints also protect native controls and minimal
* policies that choose a card outside the dedicated whole-answer guide. */
function printedRolePlayBlock(g: Game, player: string, effect: PrintedCardEffect): string | null {
if (!g.turnActionPromises) return null;
try { turnActionRule(() => recordTurnActionCardPlay(g.turnActionPromises!, player, g.turn, effect)); }
catch (error) { if (error instanceof RuleError) return error.message; throw error; }
return null;
}
function recordNativePrintedCardPlay(g: Game, player: string, effect: PrintedCardEffect) {
const policy = (g as TurnActionCardProbe)._turnActionCardPolicy;
requireRule(policy?.player !== player || !policy.forbidden.includes(effect),
@@ -19022,6 +19037,18 @@ function turnActionCardsMatch(g: Game, actor: string, requirements: readonly Tur
}
function* nativePrintedCardActions(g: Game, actor: string, effect: PrintedCardEffect): Generator<Action> {
const player = getPlayer(g, actor);
if (effect === 'distrans') {
const quote = distransView(g, player);
if (quote && !quote.blocked) for (const choice of quote.choices) if (!choice.blocked)
for (const card of choice.cards)
yield { type: 'card', card: quote.card.id, target: choice.recipient, give: card.id };
return;
}
if (effect === 'juiceOfSapho') {
for (const option of saphoOptions(g, player))
yield { type: 'card', card: 'richese-juice-of-sapho', scope: option.scope, event: option.event, mode: option.mode };
return;
}
if (effect === 'nullentropyBox') {
for (const card of player.hand.filter(card => card.effect === effect)) {
try { validateNullentropyBegin(g, player, card.id); yield { type: 'card', card: card.id }; }
@@ -19072,9 +19099,32 @@ function guardTurnActionBoxCost(source: Game, owner: Player, box: string) {
for (const seed of turnActionBoxSeeds(source, owner.id, box)) if (turnActionCompletion(seed.game, owner.id)) return;
throw new RuleError('The two-spice Box payment must preserve your accepted whole Truthtrance answers.');
}
/** Native transfer/order effects retain their physical cost and public queue.
* No recipient consent, unseen acquisition or income is assumed. */
function* turnActionTransferOrderSeeds(source: Game, actor: string, requirements: readonly TurnActionCardRequirement[]):
Generator<{ game: Game; actions: Action[] }> {
const search = function* (game: Game, actions: Action[]): Generator<{ game: Game; actions: Action[] }> {
const pending = [...new Set(requirements.filter(required => required.answer &&
(required.effect === 'distrans' || required.effect === 'juiceOfSapho') &&
nativePrintedCardCount(game, actor, required.effect) <= required.baseline).map(required => required.effect))];
for (const effect of pending) for (const action of nativePrintedCardActions(game, actor, effect)) {
try {
const played = applyActionInner(game, actor, action, 'movementPreparation');
const active = played.active, extortion = played.decision?.kind === 'moritaniExtortion' ? played.decision : null;
const projected = groundMovementProbe(played, actor);
projected.active = active;
if (extortion) projected.decision = extortion;
yield* search(projected, [...actions, action]);
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
// Prefer a legal current printed use; retain deferral as an alternative.
yield { game, actions };
};
yield* search(source, []);
}
/** Current Ghola and known owned Box routes share the original physical cost.
* A separate Movement forecast retains the later-play alternative. */
function* turnActionCardSeeds(source: Game, actor: string, requirements: readonly TurnActionCardRequirement[]):
function* turnActionOwnedCardSeeds(source: Game, actor: string, requirements: readonly TurnActionCardRequirement[]):
Generator<{ game: Game; actions: Action[] }> {
const gholaDue = requirements.some(required => required.effect === 'ghola' && required.answer &&
nativePrintedCardCount(source, actor, 'ghola') <= required.baseline);
@@ -19115,6 +19165,24 @@ function* turnActionCardSeeds(source: Game, actor: string, requirements: readonl
if (projected) yield* plays(projected, [{ type: 'card', card: box.id }]);
}
}
function* turnActionCardSeeds(source: Game, actor: string, requirements: readonly TurnActionCardRequirement[]):
Generator<{ game: Game; actions: Action[] }> {
if (!requirements.some(required => required.answer &&
(required.effect === 'distrans' || required.effect === 'juiceOfSapho') &&
nativePrintedCardCount(source, actor, required.effect) <= required.baseline)) {
yield* turnActionOwnedCardSeeds(source, actor, requirements);
return;
}
for (const side of turnActionTransferOrderSeeds(source, actor, requirements))
for (const owned of turnActionOwnedCardSeeds(side.game, actor, requirements))
yield { game: owned.game, actions: [...side.actions, ...owned.actions] };
// Recovery or Ghola may first provide a known physical card or native income.
for (const owned of turnActionOwnedCardSeeds(source, actor, requirements)) {
if (!owned.actions.length) continue;
for (const side of turnActionTransferOrderSeeds(owned.game, actor, requirements))
if (side.actions.length) yield { game: side.game, actions: [...owned.actions, ...side.actions] };
}
}
type TurnActionWindowCardRequirement = Extract<TurnActionBranchLeaf, { kind: 'windowCardPlay' }>;
const isWindowCardEffect = (effect: unknown): effect is WindowCardEffect =>
WINDOW_CARD_EFFECTS.some(candidate => candidate === effect);
@@ -19346,6 +19414,12 @@ function guardTurnActionPrintedCardCost(g: Game, player: Player, used: Card) {
const trial = structuredClone(g), owner = getPlayer(trial, player.id);
owner.spice += pendingBattleRevivalIncome(trial, owner);
trial.truthtrance = null; trial.response = null; trial.pendingKarama = null;
if (used.effect === 'juiceOfSapho' || used.effect === 'distrans') {
// The effect is committed before its optional used-card claim. The actual
// claim/counter choices stay at the table, without projected income.
trial.pendingTreacheryDiscard = null;
if (trial.decision?.kind === 'moritaniExtortion') trial.decision = null;
}
// The Ghola effect/cost is complete; forecast beyond its owned interruption
// without executing or borrowing the original market's pending income.
if (trial.pendingChoamMarketGhola?.player === player.id) trial.pendingChoamMarketGhola = null;
@@ -19830,7 +19904,8 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
// permanent restrictions such as Inflation Double remain authoritative.
const ownedInvoice = trial.decision?.kind === 'auctionPayment' && trial.decision.player === actor;
const ownedBox = trial.decision?.kind === 'nullentropy' && trial.pendingNullentropy?.player === actor;
const paymentWindow = ownedInvoice || ownedBox ? { ...trial, decision: null,
const extortion = trial.decision?.kind === 'moritaniExtortion';
const paymentWindow = ownedInvoice || ownedBox || extortion ? { ...trial, decision: null,
pendingNullentropy: ownedBox ? undefined : trial.pendingNullentropy } : trial;
if (required.length && bribeTimingBlock(paymentWindow)) continue;
const total = required.reduce((sum, payment) => sum + payment.amount, 0);
@@ -19925,7 +20000,8 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
const compiled = compileMixedActionExpression(claim.mixed,
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
const open = turnActionTransportOpen(g, p.id);
let bid = false, revival = false, alliance = false, plan = false, traitor = false, cardHajr = false, cardGhola = false, cardBox = false;
let bid = false, revival = false, alliance = false, plan = false, traitor = false;
const printedCards = new Set<PrintedCardEffect>();
const powers = new Set<NativeBattlePower>();
const windowCards: TurnActionWindowCards = {};
const visit = (node: TurnActionClaim['mixed']): void => {
@@ -19938,9 +20014,7 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
if (node.action.kind === 'windowCardPlay') windowCards[node.action.effect] = {
source: node.action.source, played: false, closed: false,
};
if (node.action.kind === 'cardPlay' && node.action.effect === 'hajr') cardHajr = true;
if (node.action.kind === 'cardPlay' && node.action.effect === 'ghola') cardGhola = true;
if (node.action.kind === 'cardPlay' && node.action.effect === 'nullentropyBox') cardBox = true;
if (node.action.kind === 'cardPlay') printedCards.add(node.action.effect);
if (node.action.kind === 'forces' || node.action.kind === 'leader') revival = true;
} else if (node.kind !== 'fact') node.terms.forEach(visit);
};
@@ -19972,9 +20046,10 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
closed: false, call: null } : null } : undefined;
let promise: TurnActionPromise = { turn: claim.turn, mixed: structuredClone(claim.mixed), event, player: p.id, asker, answer, compiled,
...(Object.keys(windowCards).length ? { windowCards } : {}),
...(cardHajr || cardGhola || cardBox ? { cardPlays: { hajr: cardHajr ? { played: false, closed: !open } : null,
ghola: cardGhola ? { played: false, closed: false } : null,
...(cardBox ? { nullentropyBox: { played: false, closed: false } } : {}) } } : {}),
...(printedCards.size ? { cardPlays: { hajr: printedCards.has('hajr') ? { played: false, closed: !open } : null,
ghola: printedCards.has('ghola') ? { played: false, closed: false } : null,
...Object.fromEntries([...printedCards].filter(effect => effect !== 'hajr' && effect !== 'ghola')
.map(effect => [effect, { played: false, closed: false }])) } } : {}),
...(combat ? { combat } : {}),
...(preparation ? { preparation } : {}),
...(nexus ? { alliance: { source: nexus.source, partner: null, closed: false } } : {}),
@@ -20109,8 +20184,10 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
const promise = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn)[0];
const ownedBox = g.decision?.kind === 'nullentropy' && g.pendingNullentropy?.player === player.id;
const ownedInvoice = g.decision?.kind === 'auctionPayment' && g.decision.player === player.id;
const saphoExtortion = g.decision?.kind === 'moritaniExtortion' &&
saphoOptions(g, player).some(option => option.scope === 'extortion');
if (!promise || g.truthtrance || g.response || g.pendingTreacheryDiscard ||
g.decision && !ownedBox && !ownedInvoice) return null;
g.decision && !ownedBox && !ownedInvoice && !saphoExtortion) return null;
const completion = turnActionCompletion(g, player.id);
if (!completion) return null;
if (g.phaseOpening) {
@@ -20136,8 +20213,10 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
const shipmentTarget = preparation?.type === 'card' && preparation.mode === 'shipment'
? preparation.target ?? player.id : null;
if ((preparation?.type !== 'bid' || g.auction?.active === player.id) &&
(!preparation || turnActionCurrentPrintedPreparation(g, player, preparation)) &&
(shipmentTarget === null || shipmentTarget === g.active)) action = preparation;
}
if (saphoExtortion && (action?.type !== 'card' || action.scope !== 'extortion')) return null;
if (!action && g.phase <= 5) {
const lot = g.phase === 3 && !g.decision ? bidOpportunity(g) : null;
if (lot && g.auction?.active === player.id) action = completion.bid ??
@@ -20193,7 +20272,10 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
const windows: WindowCardEffect[] = printedWindow ? [printedWindow]
: action.type === 'decision' && action.karama === true ? ['karamaAuction']
: action.type === 'ready' || held?.effect === 'nullentropyBox' ? [...WINDOW_CARD_EFFECTS] : [];
const guidePromise = windows.length ? liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row =>
const sideRole = held?.effect === 'distrans' || held?.effect === 'juiceOfSapho' ? held.effect : null;
const guidePromise = sideRole ? liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row =>
row.cardPlays?.[sideRole] && !row.cardPlays[sideRole]!.played && !row.cardPlays[sideRole]!.closed) ?? promise
: windows.length ? liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row =>
windows.some(effect => {
const stage = row.windowCards?.[effect];
return stage && !stage.closed && (!printedWindow || !stage.played) &&
@@ -20203,6 +20285,15 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
for (const key of ['bidPromiseEvent', 'revivalExpressionPromiseEvent', 'leaderRevivalPromiseEvent', 'revivalPromiseEvent']) delete packet[key];
return { event: guidePromise.event, action: { ...packet, turnActionPromiseEvent: guidePromise.event, turnActionPromisePhase: g.phase } };
}
/** A future calendar quote is not a current native action. Use the same live
* transfer/order choices as manual controls before offering a guided packet. */
function turnActionCurrentPrintedPreparation(g: Game, player: Player, action: Action): boolean {
if (action.type !== 'card') return true;
const card = player.hand.find(card => card.id === action.card);
if (card?.effect !== 'distrans' && card?.effect !== 'juiceOfSapho') return true;
return [...nativePrintedCardActions(g, player.id, card.effect)].some(choice =>
['type', 'card', 'target', 'give', 'scope', 'event', 'mode'].every(key => choice[key] === action[key]));
}
function travelRule<T>(operation: () => T): T {
try { return operation(); }
catch (error) { if (error instanceof TravelPromiseError) throw new RuleError(error.message); throw error; }
@@ -20406,6 +20497,23 @@ function groundMovementCompletionNative(source: Game, actor: string,
return null;
}
for (const seed of earlyShipmentCompletionSeeds(game, source, actor, cardPlays.length > 0)) {
if (cardPlays.some(required => required.effect === 'juiceOfSapho' && required.answer &&
nativePrintedCardCount(seed.game, actor, required.effect) <= required.baseline)) {
// Upcoming native Movement has a public order, not a singleton owned
// probe. Reset only its ordinary entry counters; grant no future cash,
// cards or foreign moves, and retain earlier owned preparation once.
const future = charityForecastContext(seed.game);
future.phase = 5; future.movementRemaining = [...source.order];
future.saphoMovementLast = null; future.guildTimingGranted = false; future.guildTimingLocked = false;
for (const player of future.players) resetPlayerMovement(player);
future.active = future.movementRemaining[0] ?? null;
for (const prepared of turnActionTransferOrderSeeds(future, actor, cardPlays)) {
if (!prepared.actions.length) continue;
const completion = groundMovementCompletionAtMovement(groundMovementProbe(prepared.game, actor), actor,
promises, expressions, cardPlays);
if (completion) return { actions: [...seed.actions, ...prepared.actions, ...completion.actions], early: seed.early };
}
}
resetGroundMovementForecast(seed.game, actor);
const completion = groundMovementCompletionAtMovement(seed.game, actor, promises, expressions, cardPlays);
if (completion) return { actions: [...seed.actions, ...completion.actions], early: seed.early };
@@ -25601,6 +25709,7 @@ function saphoMovementRule<T>(calculate: () => T): T {
/** Own custody and public finite scopes only; no opponent hand scan. */
function saphoOptions(g: Game, p: Player): SaphoOption[] {
if (!saphoCleanWindow(g)) return [];
if (printedRolePlayBlock(g, p.id, 'juiceOfSapho')) return [];
const card = p.hand.find((c) => c.id === 'richese-juice-of-sapho');
if (
!card ||
@@ -25703,6 +25812,7 @@ function playSapho(g: Game, p: Player, action: Action) {
option,
'This Juice of Sapho opportunity is unavailable, already completed or expired.',
);
recordNativePrintedCardPlay(g, p.id, 'juiceOfSapho');
if (option.scope === 'battleAggressor') {
const b = g.battle!;
const next = saphoAggressorRule(() => changeSaphoAggressor(b.saphoAggressor,
@@ -38825,7 +38935,9 @@ function applyActionInner(
return g;
}
if (t === 'card' && action.card === 'richese-juice-of-sapho') {
const used = p.hand.find(card => card.id === action.card);
playSapho(g, p, action);
if (execution === 'live' && used) guardTurnActionPrintedCardCost(g, p, used);
return g;
}
if (t === 'tupileIntelligence') {
@@ -38955,6 +39067,8 @@ function applyActionInner(
p.hand = result.ownerHand;
recipient.hand = result.recipientHand;
g.discard.push(result.discarded);
recordNativePrintedCardPlay(g, p.id, 'distrans');
if (execution === 'live') guardTurnActionPrintedCardCost(g, p, result.discarded);
log(
g,
`${p.name} played Distrans to give ${recipient.name} one private Treachery Card, then discarded Distrans. No spice, purchase bonus or recipient confirmation is required.`,
+2 -2
View File
@@ -1,4 +1,4 @@
export const PRINTED_CARD_EFFECTS = ['hajr', 'ghola', 'nullentropyBox'] as const;
export const PRINTED_CARD_EFFECTS = ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho'] as const;
export type PrintedCardEffect = typeof PRINTED_CARD_EFFECTS[number];
export type PrintedCardPlayCounts = {
turn: number;
@@ -41,7 +41,7 @@ function validateCounts(counts: unknown): asserts counts is PrintedCardPlayCount
function validateRequest(player: unknown, turn: unknown, cardEffect: unknown): void {
check(identifier(player) && positive(turn) && effect(cardEffect),
'Choose the printed Hajr, Ghola or Nullentropy Box play, current turn and exact player.');
'Choose a supported printed-card play, current turn and exact player.');
}
/** Private native witness baseline only. Per-answer promises track their own
+3 -2
View File
@@ -751,7 +751,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A transfer must preserve binding battle answers, compulsory discards and both sides of a pending Harkonnen hand exchange. The recipient must retain room for its mandatory incoming return. Optional proposals that become unavailable can still finish through their existing decline or no-effect path.',
'A different legal transfer can fill or free space for a pending Richese gift. The ordinary Distrans transfer resolves first, and the pending gift rechecks capacity before moving its own card. A canceled alliance gift does not automatically prevent using this separate card effect.',
'All four AI profiles use their own legal projected cards. They may give an allied Bene Gesserit or CHOAM a Worthless card, or share a duplicate ordinary weapon, defense or Karama to free a full hand. They do not inspect the recipient’s private cards and preserve existing decision priority.',
'The explicitly opted-in Semuta preview now offers a neutral all-seat response to the public used Distrans after a clean completed private transfer. The given card remains private and belongs to its recipient; only the used Distrans is claimable. Responses or decisions already in progress, unresolved bid and self-transfer rulings, full combined Richese games and ordinary expansion starts remain unfinished.',
'The explicitly opted-in Semuta preview offers a neutral all-seat response to the public used Distrans after a completed private transfer, including before an ordinary bid starts. Completed Truthtrance costs also retain a standing ordinary bid through their Semuta window. The given card remains private and belongs to its recipient; only the used Distrans is claimable. Responses or decisions already in progress, unresolved bid and self-transfer rulings, full combined Richese games and ordinary expansion starts remain unfinished.',
],
related: [
'card-richese-distrans',
@@ -3230,6 +3230,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A printed-card condition requires an actual Hajr or Tleilaxu Ghola play in its printed role after this answer. Earlier plays, discards, sales, Karama roles and recovery alone do not count; a genuine later Box recovery followed by replay can count. No hidden card is needed merely to ask the public named condition.',
'Hajr counts even if its extra move is unused, closes at your own Movement end and must earn its actual Advanced timing. Ghola remains available through this turn’s Mentat and uses real Tanks, eligible leader or Kwisatz and typed selection; it does not spend or satisfy ordinary Revival quotas. Ordinary returns and Ghola share the actual remaining Tanks and purse.',
'A printed Nullentropy Box clause counts its accepted two-spice search activation after answering this turn, before private selection. It does not name the recovered card; earlier use or recovery alone does not count. No lasts through Mentat. The actual fee shares the purse with payments and physical actions; an unpaid unknown recovered card cannot supply another effect. Whole native Basic Richese answers retain their real Semuta discard continuation. Current guides use native availability and paid selection keeps its own panel.',
'Printed Distrans and Juice of Sapho clauses count actual native use after answering this turn. They do not select a transfer recipient/card or an order scope/mode/outcome. Native costs, capacity, reservations, first/last, no-op and unsplit-current-turn rules remain. Earlier Sapho answers may wait for original Movement without new income; only current exact choices become guides. These No intervals last through Mentat. Wider future ordering and action calendars remain partial.',
'Weather Control and Family Atomics conditions concern a future printed play in this original open Storm window, including after dial reveal and before movement starts. Harvester concerns this original fresh blow. A later blow has a different identity even in the same phase. Earlier plays, disposal, other roles and recovery alone do not count.',
'These original-window conditions describe playing the card, with the normal chosen Weather distance, Atomics qualifying forces/casualties and Harvester arithmetic. They do not describe a future selected distance or amount. Accepting the window must preserve the whole answer; a later payment alternative can remain. No caps last through actual source closure, rather than ending at your own Ready.',
'A current-lot Karama condition counts a future physical printed Karama cost for this original ordinary auction card, either immediate purchase or its winning invoice. Bidding alone, another Karama role, a converted Worthless card, an earlier play, recovery or a later lot does not count. A bid-and-Karama answer must retain the real bid before the purchase; free acquisition still uses the original native controls and hand limit. A whole No shares its payment and bid funding without assuming a forbidden free invoice. Unseen future auction cards cannot fund another promised card play; actual owned knowledge remains usable.',
@@ -3249,7 +3250,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed Hajr/Ghola and original power declarations; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-thumper-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Current funding, physical event meaning, private alternatives, separate deadlines and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TURN_ACTIONS.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/fixture-truth-box-play.ts', 'tests/truth-box-play-runtime.test.ts', 'tests/truth-opaque-card-plan.test.ts', 'tests/fixture-truth-choam-income.ts', 'tests/truth-choam-income-runtime.test.ts', 'tests/window-card-play.test.ts', 'tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/fixture-truth-turn-window-card.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/fixture-truth-turn-karama.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-forecast.test.ts', 'tests/fixture-truth-turn-karama-shipment.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-karama-shipment-funding.test.ts', 'tests/fixture-truth-turn-thumper.ts', 'tests/truth-turn-thumper-runtime.test.ts', 'tests/fixture-truth-turn-amal.ts', 'tests/truth-turn-amal-runtime.test.ts', 'game/ordinary-payment-plan.ts', 'tests/ordinary-payment-plan.test.ts', 'tests/fixture-truth-charity-payments.ts', 'tests/truth-charity-payments-runtime.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/fixture-truth-transfer-order-play.ts', 'tests/truth-transfer-order-play-runtime.test.ts', 'tests/fixture-distrans-prebid.ts', 'tests/fixture-sapho-started-movement.ts', 'tests/fixture-truth-box-play.ts', 'tests/truth-box-play-runtime.test.ts', 'tests/truth-opaque-card-plan.test.ts', 'tests/fixture-truth-choam-income.ts', 'tests/truth-choam-income-runtime.test.ts', 'tests/window-card-play.test.ts', 'tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/fixture-truth-turn-window-card.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/fixture-truth-turn-karama.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-forecast.test.ts', 'tests/fixture-truth-turn-karama-shipment.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-karama-shipment-funding.test.ts', 'tests/fixture-truth-turn-thumper.ts', 'tests/truth-turn-thumper-runtime.test.ts', 'tests/fixture-truth-turn-amal.ts', 'tests/truth-turn-amal-runtime.test.ts', 'game/ordinary-payment-plan.ts', 'tests/ordinary-payment-plan.test.ts', 'tests/fixture-truth-charity-payments.ts', 'tests/truth-charity-payments-runtime.test.ts'] },
],
},
{
+7 -3
View File
@@ -752,9 +752,13 @@ export function truthQuestionText(
action.effect === 'atomics' ? 'Family Atomics' : 'Harvester'} in its printed role after this answer in this original ${
action.effect === 'harvester' ? 'fresh Spice Blow' : 'Storm window'} this turn`;
}
if (action.kind === 'cardPlay') return action.effect === 'nullentropyBox'
? 'you activate Nullentropy Box in its printed role and pay its 2-spice search cost after this answer this turn'
: `you play ${action.effect === 'hajr' ? 'Hajr' : 'Tleilaxu Ghola'} in its printed role after this answer this turn`;
if (action.kind === 'cardPlay') {
if (action.effect === 'nullentropyBox')
return 'you activate Nullentropy Box in its printed role and pay its 2-spice search cost after this answer this turn';
if (action.effect === 'distrans') return 'you complete a printed Distrans transfer and discard Distrans after this answer this turn';
return `you play ${action.effect === 'hajr' ? 'Hajr' : action.effect === 'ghola' ? 'Tleilaxu Ghola' :
'Juice of Sapho'} in its printed role after this answer this turn`;
}
if (action.kind === 'battlePower') {
const claim = action.claim, where = `in this original battle in ${locationName(action.territory)}`;
if (claim.kind === 'voiceCommand') return `you actually declare native Voice ${claim.must ? 'requiring' : 'forbidding'} ${claim.cardType} ${where} (later prevention does not change the declaration)`;
+6 -5
View File
@@ -168,7 +168,7 @@ export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
}
if (value.kind === 'cardPlay') {
check(keys(value, 'effect,kind') && PRINTED_CARD_EFFECTS.some(effect => effect === value.effect),
'Choose only the printed Hajr, Tleilaxu Ghola or Nullentropy Box play after this answer.');
'Choose only a supported printed-card play after this answer.');
return { kind: 'cardPlay', effect: value.effect as PrintedCardEffect };
}
if (value.kind === 'battlePlan' || value.kind === 'traitorCall') {
@@ -336,10 +336,11 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
}
}
if ('cardPlays' in promise) {
check(object(promise.cardPlays) && (keys(promise.cardPlays, 'ghola,hajr') ||
keys(promise.cardPlays, [...PRINTED_CARD_EFFECTS].sort().join(','))), 'Retain only the native printed-card play stages.');
check(object(promise.cardPlays) && Object.hasOwn(promise.cardPlays, 'hajr') && Object.hasOwn(promise.cardPlays, 'ghola') &&
Object.keys(promise.cardPlays).every(key => PRINTED_CARD_EFFECTS.some(effect => effect === key)),
'Retain only the native printed-card play stages and original Hajr/Ghola keys.');
for (const effect of PRINTED_CARD_EFFECTS) {
if (effect === 'nullentropyBox' && !Object.hasOwn(promise.cardPlays, effect)) continue;
if (!Object.hasOwn(promise.cardPlays, effect)) continue;
const stage = promise.cardPlays[effect];
check(stage === null || object(stage) && keys(stage, 'closed,played') && typeof stage.played === 'boolean' &&
typeof stage.closed === 'boolean', 'Retain the actual printed-role play and native stage closure.');
@@ -700,7 +701,7 @@ export function closeTurnActionBattlePower<T extends TurnActionPromise>(promises
* success, disposal, recovery and real card custody; none count as this event. */
export function recordTurnActionCardPlay<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number, effect: PrintedCardEffect): T[] {
check(PRINTED_CARD_EFFECTS.some(candidate => candidate === effect), 'Record an accepted printed Hajr, Tleilaxu Ghola or Nullentropy Box play.');
check(PRINTED_CARD_EFFECTS.some(candidate => candidate === effect), 'Record an accepted supported printed-card play.');
const updates = new Map<T, T>();
for (const promise of liveTurnActionPromises(promises, player, turn)) {
readPromise(promise);
+13 -8
View File
@@ -4,7 +4,7 @@ import { handLimit, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
actDistransPrebid, buyDistransPrebidLot, createDistransPrebidFixture,
actDistransPrebid, advanceDistransPrebid, buyDistransPrebidLot, createDistransPrebidFixture,
distransPrebidClean, distransPrebidInventory, distransPrebidPlayer as player,
distransPrebidPosition, distransPrebidUntouched, finishDistransPrebidLot,
playDistransPrebid, type DistransPrebidFixture, type DistransPrebidStep,
@@ -51,7 +51,7 @@ function source(fixture: DistransPrebidFixture) {
player(purchase.before, purchase.winner).spice - 1);
}
}
function transferred(f: DistransPrebidFixture, before: Game, after: Game) {
function transferred(f: DistransPrebidFixture, before: Game, after: Game, actions: DistransPrebidStep[] = []) {
assert.deepEqual(distransPrebidPosition(after), distransPrebidPosition(before),
'Transfer retains the original physical pool, index, opener, active owner, order and every wallet');
assert.deepEqual(player(after, f.holder).hand,
@@ -63,7 +63,13 @@ function transferred(f: DistransPrebidFixture, before: Game, after: Game) {
assert.equal(player(after, f.recipient).hand.length, player(before, f.recipient).hand.length + 1);
assert.equal(after.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(distransPrebidInventory(after), f.originalInventory);
const from = actions.length;
after = advanceDistransPrebid(after, distransPrebidClean, actions);
assert.ok(actions.slice(from).every(step => step.action.type === 'semutaPass'),
'Complete only the actual neutral fresh-discard controls before resuming this lot');
assert.deepEqual(distransPrebidPosition(after), distransPrebidPosition(before));
assert.ok(distransPrebidUntouched(after), 'The transfer does not count as the first bid or pass');
return after;
}
function completeBid(game: Game, bidder: string, actions: DistransPrebidStep[] = []) {
assert.ok(game.auction);
@@ -89,7 +95,7 @@ for (const advanced of [false, true]) {
const before = structuredClone(f.game), actions: DistransPrebidStep[] = [];
let game = playDistransPrebid(f, f.game, actions);
assert.deepEqual(f.game, before);
transferred(f, before, game);
game = transferred(f, before, game, actions);
rejected(game, f.holder, f.action);
const bidder = game.auction!.active;
game = actDistransPrebid(game, bidder, { type: 'bid', amount: 1, allyPayment: 0 }, actions);
@@ -124,7 +130,7 @@ for (const advanced of [false, true]) {
assert.equal(viewGame(game, f.holder).distrans!.blocked, null);
const before = structuredClone(game);
game = playDistransPrebid(f, game);
transferred(f, before, game);
game = transferred(f, before, game);
rejected(game, f.holder, f.action);
assert.deepEqual(distransPrebidInventory(game), f.originalInventory);
});
@@ -138,7 +144,7 @@ for (const advanced of [false, true]) {
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
const before = structuredClone(f.game);
let game = playDistransPrebid(f);
transferred(f, before, game);
game = transferred(f, before, game);
assert.equal(player(game, f.holder).hand.length, 2);
const purchased = buyDistransPrebidLot(game, f.holder);
game = purchased.game;
@@ -154,8 +160,7 @@ for (const advanced of [false, true]) {
assert.equal(f.game.auction!.index, 2);
assert.equal(f.game.auction!.active, f.recipient);
const before = structuredClone(f.game);
const filled = playDistransPrebid(f);
transferred(f, before, filled);
const filled = transferred(f, before, playDistransPrebid(f));
assert.equal(filled.auction!.active, f.recipient);
assert.equal(filled.active, f.recipient);
assert.equal(player(filled, f.recipient).hand.length, handLimit(player(filled, f.recipient)));
@@ -206,7 +211,7 @@ for (const advanced of [false, true]) {
rejected(f.game, f.holder, { ...f.action, give: f.card });
rejected(f.game, f.holder, { ...f.action, target: 'absent-seat' });
rejected(f.game, f.holder, { ...f.action, give: player(f.game, f.recipient).hand[0].id });
const game = playDistransPrebid(f);
const game = transferred(f, f.game, playDistransPrebid(f));
rejected(game, f.holder, f.action);
assert.deepEqual(distransPrebidInventory(game), f.originalInventory);
});
+8 -2
View File
@@ -8,7 +8,8 @@ import { nextSemutaGuildIncomeNativeStep } from './fixture-semuta-guild-income';
export const DISTRANS_PREBID_CARD = 'richese-distrans';
export type DistransPrebidStep = { actor: string; action: Action };
export type DistransPrebidCapacity = 'ordinary' | 'donorFull' | 'recipientLastSlot' | 'recipientFull';
export type DistransPrebidOptions = { advanced?: boolean; capacity?: DistransPrebidCapacity };
export type DistransPrebidOptions = { advanced?: boolean; capacity?: DistransPrebidCapacity;
truthFor?: readonly ('r' | 'a' | 'e')[] };
export function distransPrebidPlayer(game: Game, id: string) {
const player = game.players.find(player => player.id === id);
@@ -128,10 +129,14 @@ export function createDistransPrebidFixture(options: DistransPrebidOptions = {})
const setup = structuredClone(game);
assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => player.hand.length === 0));
const truthFor = options.truthFor ?? [];
assert.ok(truthFor.length <= 2 && new Set(truthFor).size === truthFor.length,
'Select at most the two original unopened Truthtrance cards');
const startingDealSelection: { actor: string; position: number; from: number; card: string }[] = [];
for (const [actor, kind] of [['r', 'projectile'], ['g', 'shield'], ['a', 'poison'], ['e', 'snooper']] as const) {
const position = game.players.findIndex(player => player.id === actor);
const from = game.deck.findIndex((card, index) => index >= position && card.kind === kind);
const truth = truthFor.some(id => id === actor);
const from = game.deck.findIndex((card, index) => index >= position && (truth ? card.effect === 'truthtrance' : card.kind === kind));
assert.ok(from >= position, `The original ${actor} starting card remains unopened`);
const card = game.deck[from].id;
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
@@ -182,6 +187,7 @@ export function createDistransPrebidFixture(options: DistransPrebidOptions = {})
const give = startingDealSelection.find(row => row.actor === 'g')!.card;
const action: Action = { type: 'card', card: DISTRANS_PREBID_CARD, target: recipient, give };
return { game, advanced, capacity, holder: 'g', donor: 'g', recipient, card: DISTRANS_PREBID_CARD, give, action,
startingTruthSelection: startingDealSelection.filter(row => truthFor.some(id => id === row.actor)),
initial, setup, beforeStartingDeal, startingDealSelection, afterSetup, beforeStorm,
acquisition, acquired, firstLot, purchases, originalInventory, actions, sourceActionCount: actions.length,
staging: ['Only unopened starting Treachery/Spice positions and scalar lotteries are selected. All cache/ordinary purchases, hands, prices, clocks and continuations are native public actions.'] };
+22 -2
View File
@@ -18,6 +18,16 @@ export function actSaphoLate(game: Game, actor: string, action: Action, actions:
actions.push({ actor, action: structuredClone(action) }); return next;
}
export function nextSaphoLateStep(game: Game, stone = false): SaphoLateStep | null {
const pending = game.pendingTreacheryDiscard;
if (pending) {
const reaction = pending.reaction;
if (reaction?.stage === 'offer') {
const actor = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(actor);
return { actor: actor.id, action: { type: 'semutaPass', event: pending.batch.event } };
}
if (pending.reaction) return null; // A committed private choice needs its owner.
return { actor: game.players[0].id, action: { type: 'advanceBots' } };
}
if (!game.response && !game.phaseOpening && game.decision) {
const decision = game.decision;
if (decision.kind === 'stoneAllocation' || decision.kind === 'battleLosses') return null;
@@ -48,6 +58,11 @@ export function advanceSaphoLate(game: Game, until: (state: Game) => boolean,
}
throw Error('The original late Sapho programme did not reach its finite boundary');
}
/** Resolve the actual fresh cost without acknowledging or advancing the
* original battle opportunity that resumes after its neutral Semuta passes. */
export function settleSaphoLateDiscard(game: Game, actions: SaphoLateStep[] = []): Game {
return advanceSaphoLate(game, state => !state.pendingTreacheryDiscard, actions);
}
/** Original public host, paid cache Sapho and paid opposing Arrakeen forces.
* Stone adds a genuine first-turn paid Stone and next-turn paid Sapho, with
@@ -138,7 +153,7 @@ export function prepareSaphoLateOutcome(fixture: SaphoLateAggressorFixture, opti
} = {}) {
const useAt = options.useAt ?? 'none';
let game = structuredClone(fixture.game), used: Action | null = null, afterUse: Game | null = null;
let beforeUse: Game | null = null, acknowledged: Game | null = null;
let beforeUse: Game | null = null, afterDiscard: Game | null = null, acknowledged: Game | null = null;
const actions: SaphoLateStep[] = [];
const act = (actor: string, action: Action) => { game = actSaphoLate(game, actor, action, actions); };
act(fixture.plans[0].actor, fixture.plans[0].action);
@@ -146,6 +161,7 @@ export function prepareSaphoLateOutcome(fixture: SaphoLateAggressorFixture, opti
if (useAt === 'onePlan') {
beforeUse = structuredClone(game); used = saphoLateAggressorAction(game, fixture.owner);
act(fixture.owner, used); afterUse = structuredClone(game);
game = settleSaphoLateDiscard(game, actions); afterDiscard = structuredClone(game);
}
act(fixture.plans[1].actor, fixture.plans[1].action);
const revealed = structuredClone(game);
@@ -157,8 +173,9 @@ export function prepareSaphoLateOutcome(fixture: SaphoLateAggressorFixture, opti
}
beforeUse = structuredClone(game); used = saphoLateAggressorAction(game, fixture.owner);
act(fixture.owner, used); afterUse = structuredClone(game);
game = settleSaphoLateDiscard(game, actions); afterDiscard = structuredClone(game);
}
return { game, onePlan, revealed, opened, beforeUse, used, afterUse, acknowledged, useAt, actions };
return { game, onePlan, revealed, opened, beforeUse, used, afterUse, afterDiscard, acknowledged, useAt, actions };
}
/** Readies and later Stone/typed losses are chosen solely from actual owned
@@ -171,6 +188,9 @@ export function completeSaphoLateAggressor(fixture: SaphoLateAggressorFixture,
}[] = [];
for (let count = 0; count < 700; count++) {
if (!game.battle && saphoLateClean(game)) return { game, actions, readies, allocations };
if (game.pendingTreacheryDiscard) {
game = settleSaphoLateDiscard(game, actions); continue;
}
const decision = !game.response && !game.phaseOpening ? game.decision : null;
const window = game.battle?.outcomeWindow;
let step: SaphoLateStep | null;
+16 -2
View File
@@ -40,7 +40,8 @@ export function advanceSaphoStartedMovement(game: Game, until: (state: Game) =>
/** Original public Basic or Advanced setup, actual cache1 purchase and positive
* shipment. Only unopened original deal/Spice ordering and original scalar
* lotteries are selected; no played custody, resources, phase or queue is set. */
export function createSaphoStartedMovementFixture(options: { advanced?: boolean; guildTake?: boolean } = {}) {
export function createSaphoStartedMovementFixture(options: { advanced?: boolean; guildTake?: boolean;
truthFor?: readonly ('richese' | 'guild')[] } = {}) {
const advanced = options.advanced ?? false, guildTake = options.guildTake ?? false;
assert.ok(!guildTake || advanced, 'Flexible Guild TAKE is an original Advanced rule');
const owner = 'emperor', guild = 'guild', canceler = 'atreides';
@@ -59,10 +60,23 @@ export function createSaphoStartedMovementFixture(options: { advanced?: boolean;
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const counter = game.deck[index].id;
[game.deck[1], game.deck[index]] = [game.deck[index], game.deck[1]];
const truthFor = options.truthFor ?? [];
assert.ok(truthFor.length <= 2 && new Set(truthFor).size === truthFor.length,
'Select at most the two original unopened Truthtrance cards');
const startingTruthSelection = truthFor.map(actor => {
const position = game.players.findIndex(player => player.id === actor);
const from = game.deck.findIndex((card, index) => index >= position && card.effect === 'truthtrance');
assert.ok(position >= 0 && from >= position, 'The selected original Truth must remain in the unopened starting deal');
const card = game.deck[from].id;
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { actor, position, from, card };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
assert.ok(saphoStartedMovementPlayer(game, canceler).hand.some(card => card.id === counter));
for (const row of startingTruthSelection)
assert.ok(saphoStartedMovementPlayer(game, row.actor).hand.some(card => card.id === row.card));
const spiceInventory = game.spiceDeck.map(card => JSON.stringify(card)).sort();
for (let count = 0; count < (advanced ? 2 : 1); count++) {
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
@@ -114,7 +128,7 @@ export function createSaphoStartedMovementFixture(options: { advanced?: boolean;
const source = structuredClone(game);
return { game, source, advanced, guildTake, owner, guild, canceler, counter, karama: counter,
card: STARTED_MOVEMENT_SAPHO, current, actor: current, beforeShipment, shipment,
lobby, setup, beforeStartingDeal, afterSetup, acquired, acquisition, timing,
lobby, setup, beforeStartingDeal, startingTruthSelection, afterSetup, acquired, acquisition, timing,
originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Actual public ready/start (Advanced uses the original preview acknowledgement), original setup and paid cache1 purchase give Emperor the physical Sapho. Only an unopened original Atreides Karama deal position, one Basic/two Advanced original land Spice positions and scoped scalar lotteries are selected.',
+254
View File
@@ -0,0 +1,254 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
import { buyDistransPrebidLot, createDistransPrebidFixture, distransPrebidInventory, distransPrebidUntouched, nextDistransPrebidStep,
type DistransPrebidCapacity } from './fixture-distrans-prebid';
import { actSaphoStartedMovement, createSaphoStartedMovementFixture, nextSaphoStartedMovementStep } from './fixture-sapho-started-movement';
import { truthTurnThumperSpice, truthTurnThumperTraitors } from './fixture-truth-turn-thumper';
export type TruthTransferOrderEffect = 'distrans' | 'juiceOfSapho';
export type TruthTransferOrderLeaf = { kind: 'cardPlay'; effect: TruthTransferOrderEffect } |
{ kind: 'payment'; recipient: string; minimum: number } | { kind: 'ship'; territory: string; minimum: number };
export type TruthTransferOrderExpression = MixedActionExpression<TruthTransferOrderLeaf>;
export type TruthTransferOrderStep = { actor: string; action: Action };
export const actTruthTransferOrderPlay = actSaphoStartedMovement;
export const truthTransferOrderPlayInventory = distransPrebidInventory;
export const truthTransferOrderPlaySpice = truthTurnThumperSpice;
export const truthTransferOrderPlayTraitors = truthTurnThumperTraitors;
export const truthTransferOrderPlayPlayer = (game: Game, actor: string) => {
const player = game.players.find(candidate => candidate.id === actor); assert.ok(player); return player;
};
export const truthTransferOrderPlayClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingNullentropy && !game.pendingShipment && !game.currentAuctionSale;
export const truthTransferOrderPlayUse = (effect: TruthTransferOrderEffect): TruthTransferOrderExpression =>
({ kind: 'action', action: { kind: 'cardPlay', effect } });
export const truthTransferOrderPlayFact = (fact: TruthFact): TruthTransferOrderExpression => ({ kind: 'fact', fact });
export const truthTransferOrderPlayPayment = (recipient: string, minimum = 2): TruthTransferOrderExpression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
export const truthTransferOrderPlayShip = (territory = 'polar_sink', minimum = 1): TruthTransferOrderExpression =>
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
export const truthTransferOrderPlayGroup = (kind: 'and' | 'or', ...terms: TruthTransferOrderExpression[]): TruthTransferOrderExpression =>
({ kind, terms });
export function nextTruthTransferOrderPlayNative(game: Game): TruthTransferOrderStep | null {
if (game.truthtrance || game.pendingNullentropy) return null;
return game.players.some(player => player.id === 'r') ? nextDistransPrebidStep(game) : nextSaphoStartedMovementStep(game);
}
export function nextTruthTransferOrderPlay(game: Game): TruthTransferOrderStep | null {
if (game.truthtrance || game.pendingNullentropy) return null;
for (const player of game.players) {
const guide = viewGame(game, player.id).turnActionGuide;
if (guide) return { actor: player.id, action: guide.action };
}
return nextTruthTransferOrderPlayNative(game);
}
export function advanceTruthTransferOrderPlay(game: Game, until: (state: Game) => boolean,
actions: TruthTransferOrderStep[] = [], nativeOnly = false): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original transfer/order source ended before its requested boundary');
const step = nativeOnly ? nextTruthTransferOrderPlayNative(game) : nextTruthTransferOrderPlay(game);
assert.ok(step, 'Supply the original Truthtrance, paid private selection or sealed native plan');
game = actTruthTransferOrderPlay(game, step.actor, step.action, actions);
}
throw Error('The original transfer/order source did not reach its finite boundary');
}
export function settleTruthTransferOrderPlay(game: Game, actions: TruthTransferOrderStep[] = []): Game {
return advanceTruthTransferOrderPlay(game, truthTransferOrderPlayClean, actions, true);
}
export function finishTruthTransferOrderPlay(game: Game, actions: TruthTransferOrderStep[] = []): Game {
const turn = game.turn;
return advanceTruthTransferOrderPlay(game, state => state.turn === turn + 1, actions, true);
}
/** Real public cache purchases plus original unopened Truth deals. The card
* question does not select a transfer recipient/card or an order option. */
export function createTruthTransferOrderPlayFixture(options: {
source?: 'distrans' | 'sapho'; advanced?: boolean; capacity?: DistransPrebidCapacity; guildTake?: boolean; recoveredDistrans?: boolean;
} = {}) {
const sourceKind = options.source ?? 'distrans', advanced = options.advanced ?? false;
const distrans = sourceKind === 'distrans' ? createDistransPrebidFixture({ advanced, capacity: options.capacity, truthFor: ['r', 'a'] }) : null;
const sapho = sourceKind === 'sapho' ? createSaphoStartedMovementFixture({ advanced, guildTake: options.guildTake,
truthFor: ['richese', 'guild'] }) : null;
const base = distrans ?? sapho!;
let game = structuredClone(base.game); const actions = structuredClone(base.actions);
const target = distrans ? distrans.holder : sapho!.owner, asker = distrans ? 'a' : 'richese', recipient = asker;
const selected = base.startingTruthSelection;
const ordered = [...selected].sort((left, right) => Number(right.actor === asker) - Number(left.actor === asker));
const truths = ordered.map(row => row.card), truthHolders = ordered.map(row => row.actor);
assert.equal(truths.length, 2); assert.equal(truthHolders[0], asker);
const effect: TruthTransferOrderEffect = distrans ? 'distrans' : 'juiceOfSapho', card = base.card;
const originalCards = truthTransferOrderPlayInventory(base.setup), originalSpice = truthTransferOrderPlaySpice(base.setup);
const originalTraitors = truthTransferOrderPlayTraitors(base.setup);
let recovery: { before: Game; firstPlay: TruthTransferOrderStep; box: string; acquisition: TruthTransferOrderStep[];
beforeRecovery: Game; paid: Game; afterRecovery: Game; outgoingPurchase: ReturnType<typeof buyDistransPrebidLot>['purchase']; after: Game } | null = null;
if (options.recoveredDistrans) {
assert.ok(distrans && distrans.capacity === 'ordinary', 'Use the original ordinary Distrans source for this one real replay');
const before = structuredClone(game), firstPlay: TruthTransferOrderStep = { actor: target, action: distrans.action };
game = settleTruthTransferOrderPlay(actTruthTransferOrderPlay(game, target, firstPlay.action, actions), actions);
const turn = game.turn;
game = advanceTruthTransferOrderPlay(game, state => state.turn === turn + 1 && !state.phaseOpening && !state.response &&
state.decision?.kind === 'richeseCache', actions, true);
const box = game.richeseCache?.find(held => held.effect === 'nullentropyBox'); assert.ok(box);
const from = actions.length;
game = actTruthTransferOrderPlay(game, game.decision!.player, { type: 'decision', event: game.richeseBidding!.event,
card: box.id, method: 'onceAround', direction: 'counterclockwise' }, actions);
for (let count = 0; count < 200 && !(truthTransferOrderPlayPlayer(game, target).hand.some(held => held.id === box.id) && distransPrebidUntouched(game)); count++) {
const lot = game.richeseAuction;
const step = truthTransferOrderPlayClean(game) && lot?.cardId === box.id && !lot.outcome && lot.active === target && lot.bid === 0
? { actor: target, action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } satisfies Action }
: nextTruthTransferOrderPlayNative(game);
assert.ok(step); game = actTruthTransferOrderPlay(game, step.actor, step.action, actions);
}
assert.ok(distransPrebidUntouched(game)); assert.ok(truthTransferOrderPlayPlayer(game, target).hand.some(held => held.id === box.id));
const acquisition = structuredClone(actions.slice(from)), beforeRecovery = structuredClone(game);
assert.deepEqual(game.discard.map(held => held.id), [card]);
game = actTruthTransferOrderPlay(game, target, { type: 'card', card: box.id }, actions); const paid = structuredClone(game);
game = settleTruthTransferOrderPlay(game, actions); const afterRecovery = structuredClone(game);
assert.ok(truthTransferOrderPlayPlayer(game, target).hand.some(held => held.id === card));
assert.equal(truthTransferOrderPlayPlayer(beforeRecovery, target).spice - truthTransferOrderPlayPlayer(game, target).spice, 2);
const bought = buyDistransPrebidLot(game, target, actions); game = bought.game;
recovery = { before, firstPlay, box: box.id, acquisition, beforeRecovery, paid, afterRecovery,
outgoingPurchase: bought.purchase, after: structuredClone(game) };
}
for (const [index, truth] of truths.entries()) assert.ok(truthTransferOrderPlayPlayer(game, truthHolders[index]).hand.some(held => held.id === truth));
assert.ok(truthTransferOrderPlayPlayer(game, target).hand.some(held => held.id === card));
assert.ok(truthTransferOrderPlayClean(game)); assert.equal(!!game.semutaPreview, !advanced);
assert.equal(game.turnActionPromises?.length ?? 0, 0); assert.deepEqual(truthTransferOrderPlayInventory(game), originalCards);
assert.deepEqual(truthTransferOrderPlaySpice(game), originalSpice); assert.deepEqual(truthTransferOrderPlayTraitors(game), originalTraitors);
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTransferOrderPlayPlayer(game, target).spice };
const mixed = truthTransferOrderPlayGroup('and', truthTransferOrderPlayUse(effect), truthTransferOrderPlayPayment(recipient));
const question: TruthQuestion = { kind: 'turnActions', target, turn: game.turn, mixed };
return { game, source: structuredClone(game), sourceKind, effect, advanced, base, distrans, sapho,
target, asker, recipient, truths, truthHolders, truth: truths[0], card, spiceFact, mixed, question,
give: recovery?.outgoingPurchase.card ?? distrans?.give ?? null, current: sapho?.current ?? null, recovery,
setup: base.setup, afterSetup: base.afterSetup, beforeStartingDeal: base.beforeStartingDeal,
originalCards, originalSpice, originalTraitors,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { inherited: base.actions.length, nativeContinuation: actions.length - base.actions.length, selected: actions.length,
nativeCacheBids: actions.filter(step => step.actor === target && step.action.type === 'richeseBid' && step.action.amount === 1).length },
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
shipment: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
sourceDisclosure: base.staging.join(' ') + ' Optional original unopened starting deals supply the two physical Truths; ' +
'default base-fixture deals remain unchanged when those selectors are absent. Basic keeps its original Semuta marker and real discard acknowledgements. ' +
(distrans ? 'Guild actually purchases Distrans for1 and retains its original other card to transfer; no recipient consent or hidden recipient card is supplied. '
: 'Emperor actually purchases Sapho for1; the current foreign actor has already paid for one physical Polar Sink shipment and keeps its original ground move/end. ') +
(recovery ? 'The optional single replay source actually transfers/discards Distrans, buys the remaining original Box cache1 on turn2, pays its2-spice sole-card recovery, then buys one ordinary card1 as its new outgoing card. Both original Truths remain held until the later question. ' : '') +
'The future printed-play clause names no recipient, outgoing card, mode or order result. Only actual native accepted use counts. ' +
'No dealt hand, purse, army, phase, queue, receipt or marker is installed. Counts and inventories derive from actual arrays.',
};
}
export type TruthTransferOrderPlayFixture = ReturnType<typeof createTruthTransferOrderPlayFixture>;
export function declareTruthTransferOrderPlay(f: TruthTransferOrderPlayFixture, source = f.game, copy: 0 | 1 = 0,
actions: TruthTransferOrderStep[] = []) {
const asker = f.truthHolders[copy]; let game = actTruthTransferOrderPlay(source, asker, { type: 'card', card: f.truths[copy] }, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthTransferOrderPlay(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask'); return { game, asker, actions };
}
export function askTruthTransferOrderPlay(game: Game, asker: string, question: TruthQuestion, actions: TruthTransferOrderStep[] = []): Game {
return actTruthTransferOrderPlay(game, asker, { type: 'truthAsk', question: structuredClone(question) }, actions);
}
export function answerTruthTransferOrderPlay(game: Game, target: string, answer: 'yes' | 'no', actions: TruthTransferOrderStep[] = []): Game {
return actTruthTransferOrderPlay(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthTransferOrderPlay(f: TruthTransferOrderPlayFixture, answer: 'yes' | 'no' = 'yes', question: TruthQuestion = f.question,
source = f.game, copy: 0 | 1 = 0, actions: TruthTransferOrderStep[] = []) {
const declared = declareTruthTransferOrderPlay(f, source, copy, actions), asked = askTruthTransferOrderPlay(declared.game, declared.asker, question, actions);
return { game: answerTruthTransferOrderPlay(asked, f.target, answer, actions), declared, asked, actions };
}
export function truthTransferOrderPlayPromise(game: Game, target = game.turnActionPromises?.[0]?.player ?? game.players[0].id) {
const row = viewGame(game, target).turnActionPromises.findLast(promise => promise.player === target); assert.ok(row); return row;
}
export function truthTransferOrderDistransAction(game: Game, f: TruthTransferOrderPlayFixture, recipient = f.recipient, give = f.give): Action {
assert.equal(f.effect, 'distrans'); const quote = viewGame(game, f.target).distrans;
assert.ok(quote && !quote.blocked); const choice = quote.choices.find(choice => choice.recipient === recipient && !choice.blocked);
assert.ok(choice && choice.cards.some(card => card.id === give));
return { type: 'card', card: f.card, target: recipient, give: give! };
}
export function truthTransferOrderSaphoAction(game: Game, f: TruthTransferOrderPlayFixture, mode: 'first' | 'last'): Action {
assert.equal(f.effect, 'juiceOfSapho'); const option = viewGame(game, f.target).saphoOptions.find(option => option.scope === 'movement' && option.mode === mode);
assert.ok(option, 'Use one exact currently legal native Sapho option');
return { type: 'card', card: f.card, scope: option.scope, event: option.event, mode: option.mode };
}
/** One genuine Semuta cost composition. Original Distrans is actually spent
* before turn2; its owner then buys the still-original cached Semuta for1.
* Both unopened-deal Truths remain held for a later normal-lot answer. */
export function createTruthSemutaPrebidFixture(options: { firstBid?: boolean } = {}) {
const f = createTruthTransferOrderPlayFixture(), actions = structuredClone(f.actions), inherited = actions.length;
let game = settleTruthTransferOrderPlay(actTruthTransferOrderPlay(f.game, f.target,
truthTransferOrderDistransAction(f.game, f), actions), actions);
const earlierDistrans = structuredClone(game), turn = game.turn;
game = advanceTruthTransferOrderPlay(game, state => state.turn === turn + 1 && !state.phaseOpening && !state.response &&
state.decision?.kind === 'richeseCache', actions, true);
const semuta = game.richeseCache?.find(card => card.effect === 'semutaDrug'); assert.ok(semuta);
const acquisitionStart = actions.length, beforePurchase = structuredClone(game);
game = actTruthTransferOrderPlay(game, game.decision!.player, { type: 'decision', event: game.richeseBidding!.event,
card: semuta.id, method: 'onceAround', direction: 'counterclockwise' }, actions);
for (let count = 0; count < 200 && !(truthTransferOrderPlayPlayer(game, f.target).hand.some(card => card.id === semuta.id) && distransPrebidUntouched(game)); count++) {
const lot = game.richeseAuction;
const step = truthTransferOrderPlayClean(game) && lot?.cardId === semuta.id && !lot.outcome && lot.active === f.target && lot.bid === 0
? { actor: f.target, action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } satisfies Action }
: nextTruthTransferOrderPlayNative(game);
assert.ok(step); game = actTruthTransferOrderPlay(game, step.actor, step.action, actions);
}
assert.ok(distransPrebidUntouched(game)); assert.ok(truthTransferOrderPlayPlayer(game, f.target).hand.some(card => card.id === semuta.id));
assert.equal(truthTransferOrderPlayPlayer(beforePurchase, f.target).spice - truthTransferOrderPlayPlayer(game, f.target).spice, 1);
const acquisition = { before: beforePurchase, steps: structuredClone(actions.slice(acquisitionStart)), after: structuredClone(game) };
let bid: TruthTransferOrderStep | null = null;
if (options.firstBid) {
bid = { actor: game.auction!.active, action: { type: 'bid', amount: 1, allyPayment: 0 } };
game = actTruthTransferOrderPlay(game, bid.actor, bid.action, actions);
assert.equal(game.auction!.bid, 1); assert.equal(game.auction!.bidder, bid.actor);
}
assert.ok(truthTransferOrderPlayClean(game)); assert.equal(game.semutaPreview, true);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.ok(!truthTransferOrderPlayPlayer(game, f.target).hand.some(card => card.id === f.card));
for (const [index, truth] of f.truths.entries()) assert.ok(truthTransferOrderPlayPlayer(game, f.truthHolders[index]).hand.some(card => card.id === truth));
assert.deepEqual(truthTransferOrderPlayInventory(game), f.originalCards);
assert.deepEqual(truthTransferOrderPlaySpice(game), f.originalSpice); assert.deepEqual(truthTransferOrderPlayTraitors(game), f.originalTraitors);
const mixed = truthTransferOrderPlayUse('distrans');
const question: TruthQuestion = { kind: 'turnActions', target: f.target, turn: game.turn, mixed };
return { ...f, game, source: structuredClone(game), mixed, question, semuta: semuta.id, earlierDistrans, acquisition, bid,
spiceFact: { kind: 'spice', compare: 'eq', value: truthTransferOrderPlayPlayer(game, f.target).spice } satisfies TruthFact,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { inherited, nativeContinuation: actions.length - inherited, selected: actions.length,
nativeCacheBids: actions.filter(step => step.actor === f.target && step.action.type === 'richeseBid' && step.action.amount === 1).length },
sourceDisclosure: f.base.staging.join(' ') + ' Original unopened Richese/Atreides Truth deals remain held. ' +
'Guild actually transfers its original shield with Distrans, completes its native fresh-discard passes, then reaches the next original cache declaration. ' +
'Guild buys the original Semuta for1 and stops at the original normal lot, optionally after one actual first bid1. Distrans remains physically spent. ' +
'The later question concerns only future Distrans use; no future Semuta clause is introduced. No hand, purse, auction, phase, receipt or marker is installed. Counts derive from recorded actions.',
};
}
/** Earlier real Basic boundary, rather than an installed phase or queue.
* Retain only the exact acquisition prefix of the existing native source. */
export function createTruthEarlySaphoFixture() {
const f = createTruthTransferOrderPlayFixture({ source: 'sapho' }); assert.ok(f.sapho);
const bid = f.sapho.acquisition.bid;
const bidAt = f.actions.findIndex(step => step.actor === bid.actor && step.action.type === 'richeseBid' &&
step.action.event === bid.action.event && step.action.amount === bid.action.amount);
assert.ok(bidAt >= 0); const inherited = bidAt + f.sapho.acquisition.steps.length;
assert.deepEqual(f.actions.slice(bidAt, inherited), f.sapho.acquisition.steps);
const actions = structuredClone(f.actions.slice(0, inherited));
let game = structuredClone(f.sapho.acquired);
game = advanceTruthTransferOrderPlay(game, state => state.phase === 4 && truthTransferOrderPlayClean(state), actions, true);
assert.equal(game.advanced, false); assert.equal(game.semutaPreview, true);
assert.ok(truthTransferOrderPlayPlayer(game, f.target).hand.some(card => card.id === f.card));
assert.equal(truthTransferOrderPlayPlayer(game, f.target).shipped, false); assert.equal(truthTransferOrderPlayPlayer(game, f.target).moved, 0);
assert.ok(!actions.some(step => step.action.type === 'ship')); assert.deepEqual(truthTransferOrderPlayInventory(game), f.originalCards);
assert.deepEqual(truthTransferOrderPlaySpice(game), f.originalSpice); assert.deepEqual(truthTransferOrderPlayTraitors(game), f.originalTraitors);
return { ...f, game, source: structuredClone(game), current: null, early: true,
spiceFact: { kind: 'spice', compare: 'eq', value: truthTransferOrderPlayPlayer(game, f.target).spice } satisfies TruthFact,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { inherited, nativeContinuation: actions.length - inherited, selected: actions.length,
nativeCacheBids: actions.filter(step => step.actor === f.target && step.action.type === 'richeseBid' && step.action.amount === 1).length },
sourceDisclosure: f.sapho.staging[0] + ' Select the two original unopened Richese/Guild Truth deals and use the exact actual acquired-Sapho snapshot. ' +
'Its prefix is acquisition bid index plus actual acquisition steps; subsequent native auction controls reach quiet Basic Revival before any shipment. ' +
'The owned future movement opportunity uses its ordinary public order, with no future income, played card, phase or queue installed. Counts derive from the exact prefix and native continuation.',
};
}
+26 -1
View File
@@ -9,7 +9,7 @@ import {
} from '../game/printed-card-play';
void test('Box accepted activation counts extend the shared roles without changing earlier Hajr or Ghola baselines', () => {
assert.deepEqual(PRINTED_CARD_EFFECTS, ['hajr', 'ghola', 'nullentropyBox']);
assert.deepEqual(PRINTED_CARD_EFFECTS, ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho']);
assert.equal(printedCardPlayCount(undefined, 'richese', 3, 'nullentropyBox'), 0);
const ghola = recordPrintedCardPlay(undefined, 'richese', 3, 'ghola');
const first = recordPrintedCardPlay(ghola, 'richese', 3, 'nullentropyBox');
@@ -33,6 +33,31 @@ void test('Box activation counts retain safe overflow and strict per-player role
'richese', 4, 'nullentropyBox'), PrintedCardPlayError);
});
void test('Distrans and Sapho count separate accepted printed uses and replay without recording native choices', () => {
const original = recordPrintedCardPlay(undefined, 'richese', 2, 'nullentropyBox');
let counts = original;
const before = structuredClone(original);
for (const effect of ['distrans', 'juiceOfSapho'] as const) {
assert.equal(printedCardPlayCount(counts, 'richese', 2, effect), 0);
counts = recordPrintedCardPlay(counts, 'richese', 2, effect);
counts = recordPrintedCardPlay(counts, 'richese', 2, effect);
assert.equal(printedCardPlayCount(counts, 'richese', 2, effect), 2);
assert.equal(printedCardPlayCount(counts, 'other', 2, effect), 0);
assert.equal(printedCardPlayCount(counts, 'richese', 3, effect), 0);
const full: PrintedCardPlayCounts = { turn: 2, plays: [{ player: 'richese', effect, count: Number.MAX_SAFE_INTEGER }] };
assert.throws(() => recordPrintedCardPlay(full, 'richese', 2, effect), PrintedCardPlayError);
for (const choice of [{ recipient: 'other' }, { give: 'private-given-card' }, { scope: 'movement' }, { mode: 'first' }]) {
const invalid = { turn: 2, plays: [{ player: 'richese', effect, count: 1, ...choice }] } as PrintedCardPlayCounts;
assert.throws(() => printedCardPlayCount(invalid, 'richese', 3, effect), PrintedCardPlayError);
}
}
assert.deepEqual(before, { turn: 2, plays: [{ player: 'richese', effect: 'nullentropyBox', count: 1 }] });
assert.deepEqual(original, before);
assert.equal(printedCardPlayCount(counts, 'richese', 2, 'nullentropyBox'), 1);
assert.deepEqual(recordPrintedCardPlay(counts, 'richese', 3, 'distrans'),
{ turn: 3, plays: [{ player: 'richese', effect: 'distrans', count: 1 }] });
});
void test('missing legacy counts and other turn/player/effect baselines read as zero', () => {
assert.equal(printedCardPlayCount(undefined, 'atreides', 1, 'hajr'), 0);
const counts = recordPrintedCardPlay(undefined, 'atreides', 1, 'hajr');
+3 -1
View File
@@ -50,7 +50,9 @@ void test('genuine Basic and Advanced late Sapho changes an ordinary revealed ti
assert.equal(late.afterUse!.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(late.afterUse!).tanks, player(f.game).tanks);
const stale = saphoLateOutcomeReady(late.beforeUse!, f.opponent);
assert.throws(() => actSaphoLate(late.afterUse!, f.opponent, stale), /event|stale|window|opportunity/i);
assert.ok(!late.game.pendingTreacheryDiscard, 'The actual fresh Sapho cost finishes before testing the resumed event');
assert.equal(late.game.battle!.outcomeWindow!.event, late.afterUse!.battle!.outcomeWindow!.event);
assert.throws(() => actSaphoLate(late.game, f.opponent, stale), /event|stale|window|opportunity/i);
const changed = completeSaphoLateAggressor(f, late);
assert.equal(changed.readies.length, 2, 'Both participants must acknowledge the revised public opportunity');
assert.equal(changed.game.lastBattleContext!.winner, f.owner);
@@ -0,0 +1,308 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { treacheryDeck } from '../game/cards';
import { viewGame, type Action, type Game } from '../game/engine';
import { richeseCards } from '../game/richese-cards';
import { truthFactAnswer, type TruthQuestion } from '../game/truthtrance';
import {
actTruthTransferOrderPlay as act, advanceTruthTransferOrderPlay as advance,
answerTruthTransferOrderPlay as answer, askTruthTransferOrderPlay as ask, bindTruthTransferOrderPlay as bind,
createTruthTransferOrderPlayFixture as fixture, createTruthSemutaPrebidFixture as semutaFixture,
createTruthEarlySaphoFixture as earlySaphoFixture, declareTruthTransferOrderPlay as declare,
finishTruthTransferOrderPlay as finish, nextTruthTransferOrderPlayNative as nativeStep,
settleTruthTransferOrderPlay as settle, truthTransferOrderDistransAction as transfer,
truthTransferOrderSaphoAction as order, truthTransferOrderPlayClean as clean,
truthTransferOrderPlayFact as fact, truthTransferOrderPlayGroup as group,
truthTransferOrderPlayInventory as inventory, truthTransferOrderPlayPayment as payment,
truthTransferOrderPlayPlayer as player, truthTransferOrderPlayPromise as promise,
truthTransferOrderPlayShip as ship, truthTransferOrderPlaySpice as spice,
truthTransferOrderPlayTraitors as traitors, truthTransferOrderPlayUse as use,
type TruthTransferOrderExpression, type TruthTransferOrderPlayFixture, type TruthTransferOrderStep,
} from './fixture-truth-transfer-order-play';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function physical(game: Game, f: TruthTransferOrderPlayFixture) {
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(spice(game), f.originalSpice);
assert.deepEqual(traitors(game), f.originalTraitors);
}
const raw = (f: TruthTransferOrderPlayFixture, mixed: TruthTransferOrderExpression): TruthQuestion =>
({ kind: 'turnActions', target: f.target, turn: f.game.turn, mixed });
const pay = (game: Game, f: TruthTransferOrderPlayFixture, amount: number, actions: TruthTransferOrderStep[] = []) =>
act(game, f.target, { type: 'bribe', target: f.recipient, amount }, actions);
const stage = (game: Game, f: TruthTransferOrderPlayFixture, index = 0) => game.turnActionPromises![index].cardPlays?.[f.effect];
const nativeCard = (game: Game, f: TruthTransferOrderPlayFixture): Action => f.effect === 'distrans'
? transfer(game, f) : order(game, f, 'first');
void test('transfer and order sources retain actual cache purchases, unopened Truth deals and all original native inventories', () => {
for (const source of ['distrans', 'sapho'] as const) for (const advanced of [false, true]) {
const f = fixture({ source, advanced }); assert.equal(f.setup.status, 'setup');
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.deepEqual(f.originalCards, [...treacheryDeck(['choam']), ...richeseCards()].map(card => card.id).sort());
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.sourceCounts.selected, f.actions.length);
assert.equal(f.sourceCounts.inherited + f.sourceCounts.nativeContinuation, f.actions.length); assert.equal(f.sourceCounts.nativeCacheBids, 1);
assert.equal(!!f.game.semutaPreview, !advanced); assert.equal(player(f.game, f.target).spice, source === 'distrans' ? 4 : 9);
for (const [index, truth] of f.truths.entries()) assert.ok(player(f.game, f.truthHolders[index]).hand.some(card => card.id === truth));
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.card)); assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
if (f.sapho) {
assert.equal(f.game.active, f.sapho.current); assert.equal(player(f.game, f.sapho.current).shipped, true);
assert.equal(player(f.game, f.sapho.current).moved, 0); assert.equal(player(f.game, f.target).shipped, false);
assert.ok(player(f.game, f.sapho.canceler).hand.some(card => card.id === f.sapho!.counter));
}
physical(f.game, f);
}
});
void test('Basic Distrans AND frozen fact and payment spends no transfer fee and preserves the original untouched ordinary lot', () => {
const f = fixture(), actions: TruthTransferOrderStep[] = [];
let game = bind(f, 'yes', raw(f, group('and', fact(f.spiceFact), use(f.effect), payment(f.recipient, 4))), f.game, 0, actions).game;
game = settle(game, actions); assert.ok(actions.some(step => step.action.type === 'semutaPass'));
const lot = structuredClone(game.auction), donorForces = structuredClone(player(game, f.target).forces);
game = pay(game, f, 4, actions); assert.equal(player(game, f.target).spice, 0);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
const action = transfer(game, f); game = settle(act(game, f.target, action, actions), actions);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 4);
assert.ok(player(game, f.recipient).hand.some(card => card.id === f.give));
assert.ok(!player(game, f.target).hand.some(card => card.id === f.give || card.id === f.card));
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); assert.deepEqual(game.auction, lot);
assert.deepEqual(player(game, f.target).forces, donorForces); assert.equal(stage(game, f)?.played, true);
assert.equal(promise(game).fulfilled, true); assert.equal(game.semutaPreview, true); physical(game, f);
});
void test('Distrans printed use names no recipient and can choose another public-capacity route when the original target is genuinely full', () => {
const f = fixture({ capacity: 'recipientFull' }); assert.ok(f.distrans);
const fullRecipient = f.distrans.recipient, fullHandCount = player(f.game, fullRecipient).hand.length;
const copy = f.truthHolders.findIndex(holder => holder !== fullRecipient); assert.ok(copy === 0 || copy === 1);
let game = settle(bind(f, 'yes', raw(f, use(f.effect)), f.game, copy).game);
const quoted = viewGame(game, f.target).distrans; assert.ok(quoted);
assert.ok(quoted.choices.find(choice => choice.recipient === fullRecipient)?.blocked);
rejected(game, f.target, { type: 'card', card: f.card, target: fullRecipient, give: f.give! });
rejected(game, f.target, { type: 'card', card: f.card, target: f.asker, give: f.card });
const alternative = quoted.choices.find(choice => !choice.blocked && choice.cards.some(card => card.id === f.give)); assert.ok(alternative);
const cash = player(game, f.target).spice;
game = settle(act(game, f.target, transfer(game, f, alternative.recipient)));
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).spice, cash);
assert.equal(player(game, fullRecipient).hand.length, fullHandCount);
assert.ok(player(game, alternative.recipient).hand.some(card => card.id === f.give)); assert.ok(clean(game)); physical(game, f);
});
void test('free Distrans, a later Guild shipment and payments share the same four owned spice', () => {
const f = fixture({ advanced: true });
const unfunded = raw(f, group('and', use(f.effect), payment(f.recipient, 4), ship()));
const asked = ask(declare(f).game, f.asker, unfunded);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const funded = raw(f, group('and', use(f.effect), payment(f.recipient, 3), ship()));
let game = settle(bind(f, 'yes', funded).game); game = pay(game, f, 3);
game = settle(act(game, f.target, transfer(game, f))); assert.equal(stage(game, f)?.played, true);
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), [], true);
game = settle(act(game, f.target, f.shipment)); assert.equal(player(game, f.target).spice, 0);
assert.equal(player(game, f.target).forces['polar_sink:0'], 1); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.recipient).bribes, 3); assert.equal(player(game, f.target).spice, 0); physical(game, f);
});
void test('Sapho AND frozen fact and payment accepts either real queued mode while preserving the current completed shipment and unsplit turn', () => {
const cases = [{ advanced: false, guildTake: false, mode: 'first' }, { advanced: false, guildTake: false, mode: 'last' },
{ advanced: true, guildTake: false, mode: 'first' }, { advanced: true, guildTake: false, mode: 'last' },
{ advanced: true, guildTake: true, mode: 'first' }] as const;
for (const { advanced, guildTake, mode } of cases) {
const f = fixture({ source: 'sapho', advanced, guildTake }); assert.ok(f.sapho);
const actions: TruthTransferOrderStep[] = [];
let game = settle(bind(f, 'yes', raw(f, group('and', fact(f.spiceFact), use(f.effect), payment(f.recipient))), f.game, 0, actions).game, actions);
game = pay(game, f, 2, actions); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
const current = f.sapho.current, beforeCurrent = structuredClone(player(game, current)), nativeOrder = [...game.order];
const native = viewGame(game, f.target).saphoOptions.find(option => option.scope === 'movement' && option.mode === mode); assert.ok(native);
assert.equal((native as typeof native & { afterCurrent?: string }).afterCurrent, current);
if (guildTake) {
assert.equal(current, f.sapho.guild);
assert.equal(viewGame(game, f.target).saphoOptions.some(option => option.scope === 'movement' && option.mode === 'last'), false);
rejected(game, f.target, { ...order(game, f, 'first'), mode: 'last' });
assert.deepEqual(player(game, current), beforeCurrent); assert.equal(stage(game, f)?.played, false);
}
game = settle(act(game, f.target, order(game, f, mode), actions), actions);
assert.equal(game.active, current); assert.deepEqual(player(game, current), beforeCurrent);
assert.deepEqual(game.order, nativeOrder); assert.equal(player(game, f.target).spice, 7);
assert.equal(stage(game, f)?.played, true); assert.equal(promise(game).fulfilled, true);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
const from = actions.length; game = advance(game, state => state.phase !== 5, actions, true);
const ended = actions.slice(from).filter(step => step.action.type === 'endMovement').map(step => step.actor);
assert.equal(ended[0], current); assert.equal(ended.filter(actor => actor === f.target).length, 1);
assert.equal(new Set(ended).size, ended.length); assert.equal(stage(game, f)?.played, true); physical(game, f);
}
});
void test('a genuinely purchased Semuta claims the original Truth cost before or after the first normal bid without changing its standing auction', () => {
for (const firstBid of [false, true]) {
const f = semutaFixture({ firstBid });
assert.ok(f.acquisition.steps.some(step => step.actor === f.target && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.ok(player(f.game, f.target).hand.some(card => card.id === f.semuta));
assert.equal(f.game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(f.game.auction!.bid, firstBid ? 1 : 0);
const position = (game: Game) => structuredClone({ status: game.status, turn: game.turn, phase: game.phase,
active: game.active, order: game.order, ready: game.ready, auction: game.auction,
richeseAuction: game.richeseAuction, richeseBidding: game.richeseBidding, richeseFunding: game.richeseFunding,
currentAuctionSale: game.currentAuctionSale, deck: game.deck,
players: game.players.map(candidate => ({ ...candidate, hand: undefined })) });
const standing = position(f.game), declared = declare(f), asked = ask(declared.game, declared.asker, f.question);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
let game = answer(asked, f.target, 'no');
const pending = game.pendingTreacheryDiscard; assert.ok(pending); assert.equal(pending.reaction?.stage, 'offer');
assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, discardedBy: entry.discardedBy })),
[{ card: f.truth, discardedBy: f.asker }]);
assert.equal(viewGame(game, f.target).semutaReaction?.canCommit, true); assert.deepEqual(position(game), standing);
const event = pending.batch.event; game = act(game, f.target, { type: 'semutaCommit', event });
assert.ok(!game.pendingTreacheryDiscard); assert.deepEqual(position(game), standing);
assert.ok(player(game, f.target).hand.some(card => card.id === f.truth));
assert.ok(!player(game, f.target).hand.some(card => card.id === f.semuta));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); assert.equal(game.discard.filter(card => card.id === f.truth).length, 0);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); assert.equal(stage(game, f)?.played, false);
assert.equal(game.resolvedTreacheryDiscardSequence, pending.sequence); assert.equal(promise(game).answer, false);
rejected(game, f.target, { type: 'semutaCommit', event }); physical(game, f);
}
});
void test('genuine early Basic Sapho can promise its free upcoming native movement play after paying all current cash', () => {
const f = earlySaphoFixture(); assert.equal(f.game.phase, 4); assert.equal(f.game.advanced, false);
assert.ok(!f.actions.some(step => step.action.type === 'ship')); assert.equal(f.sourceCounts.selected, f.actions.length);
assert.equal(f.sourceCounts.inherited + f.sourceCounts.nativeContinuation, f.actions.length);
const cash = player(f.game, f.target).spice;
const question = raw(f, group('and', fact(f.spiceFact), use(f.effect), payment(f.recipient, cash)));
const declared = declare(f), asked = ask(declared.game, declared.asker, question);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['yes', 'no']);
let game = settle(answer(asked, f.target, 'yes')); game = pay(game, f, cash);
assert.equal(player(game, f.target).spice, 0); assert.equal(stage(game, f)?.played, false);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
const earlyGuide = viewGame(game, f.target).turnActionGuide;
assert.ok(!earlyGuide || earlyGuide.action.type !== 'card' || earlyGuide.action.card !== f.card,
'The future movement option is not a currently executable Basic Revival card packet');
game = advance(game, state => state.phase === 5 && clean(state) && viewGame(state, f.target).saphoOptions.some(option => option.scope === 'movement'), [], true);
const before = game.players.map(candidate => ({ id: candidate.id, spice: candidate.spice, forces: structuredClone(candidate.forces),
reserves: candidate.reserves, tanks: candidate.tanks, shipped: candidate.shipped, moved: candidate.moved }));
const view = viewGame(game, f.target), guide = view.turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card);
assert.ok(view.saphoOptions.some(option => option.scope === guide.action.scope && option.event === guide.action.event && option.mode === guide.action.mode));
game = settle(act(game, f.target, guide.action));
assert.deepEqual(game.players.map(candidate => ({ id: candidate.id, spice: candidate.spice, forces: candidate.forces,
reserves: candidate.reserves, tanks: candidate.tanks, shipped: candidate.shipped, moved: candidate.moved })), before);
assert.equal(stage(game, f)?.played, true); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, cash); physical(game, f);
});
void test('whole OR keeps both manual printed use and late ordinary payment routes for transfer and order', () => {
for (const source of ['distrans', 'sapho'] as const) {
const f = fixture({ source, advanced: true }), question = raw(f, group('or', use(f.effect), payment(f.recipient)));
const bound = settle(bind(f, 'yes', question).game);
const played = settle(act(bound, f.target, nativeCard(bound, f)));
assert.equal(promise(played).fulfilled, true); assert.equal(player(played, f.recipient).bribes, 0);
let paid = advance(bound, state => state.phase === 8 && clean(state), [], true);
assert.equal(stage(paid, f)?.played, false); assert.ok(!promise(paid).released && !promise(paid).fulfilled);
paid = pay(paid, f, 2); assert.equal(promise(paid).fulfilled, true);
assert.ok(player(paid, f.target).hand.some(card => card.id === f.card)); physical(played, f); physical(paid, f);
}
});
void test('whole AND No allows one printed branch or its payment but cannot complete both conditions', () => {
for (const source of ['distrans', 'sapho'] as const) {
const f = fixture({ source, advanced: true }), bound = settle(bind(f, 'no').game), packet = nativeCard(bound, f);
let played = settle(act(bound, f.target, packet)); assert.equal(stage(played, f)?.played, true);
played = pay(played, f, 1); rejected(played, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
let paid = pay(bound, f, 2); rejected(paid, f.target, packet); assert.equal(stage(paid, f)?.played, false);
played = finish(played); paid = finish(paid); assert.equal(promise(played).fulfilled, true); assert.equal(promise(paid).fulfilled, true);
assert.ok(!promise(played).released && !promise(paid).released); physical(played, f); physical(paid, f);
}
});
void test('whole OR No preserves cumulative caps and rejects both printed roles until the actual turn end', () => {
for (const source of ['distrans', 'sapho'] as const) {
const f = fixture({ source, advanced: true }), originalPacket = nativeCard(f.game, f);
let game = settle(bind(f, 'no', raw(f, group('or', use(f.effect), payment(f.recipient)))).game);
const forbidden = viewGame(game, f.target);
if (source === 'distrans') assert.match(forbidden.distrans?.blocked ?? '', /Truthtrance/);
else assert.deepEqual(forbidden.saphoOptions, []);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.target); view.players.find(player => player.id === f.target)!.bot = difficulty;
assert.ok(!botActions(view).some(action => action.type === 'card' && action.card === f.card));
}
rejected(game, f.target, originalPacket); game = pay(game, f, 1);
game = advance(game, state => state.phase === 8 && clean(state), [], true);
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
assert.ok(!promise(game).released); game = finish(game);
assert.equal(promise(game).fulfilled, true); assert.equal(stage(game, f)?.played, false); assert.equal(stage(game, f)?.closed, true);
assert.ok(player(game, f.target).hand.some(card => card.id === f.card)); physical(game, f);
}
});
void test('earlier native Distrans and Sapho use without an owned recovery route leaves only No for a new future interval', () => {
for (const source of ['distrans', 'sapho'] as const) {
const f = fixture({ source, advanced: true }); const earlier = settle(act(f.game, f.target, nativeCard(f.game, f)));
assert.equal(earlier.discard.filter(card => card.id === f.card).length, 1);
const declared = declare(f, earlier), asked = ask(declared.game, declared.asker, raw(f, use(f.effect)));
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const game = settle(answer(asked, f.target, 'no')); assert.equal(stage(game, f)?.played, false); physical(game, f);
}
});
void test('an actual turn2 Box recovery alone is not a Distrans play but a later native replay with an earned outgoing card fulfills', () => {
const f = fixture({ recoveredDistrans: true }); assert.ok(f.recovery);
assert.equal(f.game.turn, f.recovery.before.turn + 1);
assert.ok(player(f.recovery.afterRecovery, f.target).hand.some(card => card.id === f.card));
assert.equal(f.recovery.afterRecovery.discard.filter(card => card.id === f.recovery!.box).length, 1);
assert.ok(f.recovery.acquisition.some(step => step.actor === f.target && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.equal(f.recovery.outgoingPurchase.card, f.give); assert.equal(f.recovery.outgoingPurchase.amount, 1);
const cash = player(f.game, f.target).spice; let game = settle(bind(f, 'yes', raw(f, use(f.effect))).game);
assert.equal(stage(game, f)?.played, false); assert.ok(!promise(game).fulfilled);
game = settle(act(game, f.target, transfer(game, f)));
assert.equal(stage(game, f)?.played, true); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, cash); assert.ok(player(game, f.recipient).hand.some(card => card.id === f.give));
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
});
void test('exact transfer/order guide packets preserve native choices and reject stale owner, promise and Sapho event through JSON', () => {
for (const source of ['distrans', 'sapho'] as const) {
const f = fixture({ source, advanced: true }); let game = settle(bind(f, 'yes', raw(f, use(f.effect))).game);
const view = viewGame(game, f.target), guide = view.turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'card');
assert.equal(guide.action.card, f.card); assert.equal(guide.event, promise(game).event);
assert.equal(guide.action.turnActionPromiseEvent, promise(game).event); assert.equal(guide.action.turnActionPromisePhase, game.phase);
if (source === 'distrans') {
const native = view.distrans?.choices.find(choice => choice.recipient === guide.action.target && !choice.blocked); assert.ok(native);
assert.ok(native.cards.some(card => card.id === guide.action.give)); assert.notEqual(guide.action.give, f.card);
} else {
assert.ok(view.saphoOptions.some(option => option.scope === guide.action.scope && option.event === guide.action.event && option.mode === guide.action.mode));
rejected(game, f.target, { ...guide.action, event: 'stale-native-sapho-event' });
}
rejected(game, f.asker, guide.action); rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-printed-role-event' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 8 });
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, packet));
assert.equal(promise(game).fulfilled, true); assert.equal(stage(game, f)?.played, true); rejected(game, f.target, packet); physical(game, f);
}
});
void test('all four minimal profiles use real transfer/order guides and native Basic Semuta continuation without extra costs', () => {
for (const source of ['distrans', 'sapho'] as const) for (const advanced of [false, true]) {
const f = fixture({ source, advanced }), declared = declare(f), asked = ask(declared.game, declared.asker, f.question);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthTransferOrderStep[] = [];
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, `${source}/${advanced}/${difficulty}`);
assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
for (let count = 0; count < 100 && (!promise(game).fulfilled || !clean(game)); count++) {
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
if (view.turnActionGuide) {
const guide = view.turnActionGuide;
const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.action.turnActionPromiseEvent);
assert.ok(action, `${source}/${advanced}/${difficulty}`); assert.deepEqual(action, guide.action);
game = act(game, f.target, action, actions);
} else {
const step = nativeStep(game); assert.ok(step); game = act(game, step.actor, step.action, actions);
}
}
assert.equal(promise(game).fulfilled, true); assert.ok(clean(game)); assert.equal(stage(game, f)?.played, true);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.card).length, 1);
assert.equal(player(game, f.target).spice, player(f.game, f.target).spice - 2); assert.equal(player(game, f.recipient).bribes, 2);
if (!advanced) assert.ok(actions.some(step => step.action.type === 'semutaPass'));
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f);
}
}
});
+145 -5
View File
@@ -7,6 +7,7 @@ import { TurnActionAnswers, TurnActionGuideButton, TurnActionPromises } from '..
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { BattleClaimFields } from '../components/battle-promises';
import { baseDeck, ixSpecialCards } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView, type Plan } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { MixedBattleActionClaim } from '../game/mixed-battle-question';
@@ -31,7 +32,7 @@ const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum =
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
const finalAlliance = (partner = 'r'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
const printedCard = (effect: 'hajr' | 'ghola' | 'nullentropyBox' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
const printedCard = (effect: 'hajr' | 'ghola' | 'nullentropyBox' | 'distrans' | 'juiceOfSapho' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
const shipmentPriceSource = (recipient = 'p', cunning?: string) => JSON.stringify(['karamaShipment', 2, recipient,
...(cunning ? ['cunning', cunning] : ['original'])]);
const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow'
@@ -149,6 +150,25 @@ function boxPlay(phase = 7): GameView {
game.turnActionPromises[0].mixed = { kind: 'and', terms: [printedCard('nullentropyBox'), payment()] };
return game;
}
function distransPlay(): GameView {
const card = richeseCards().find(card => card.effect === 'distrans'), given = baseDeck().find(card => card.name === 'Shield');
assert.ok(card); assert.ok(given);
const game = projected(2, { type: 'card', card: card.id, target: 'r', give: given.id });
const me = game.players.find(player => player.id === 'p')!; me.hand = [card, given]; me.handLimit = 4;
const recipient = game.players.find(player => player.id === 'r')!; recipient.handCount = 1; recipient.handLimit = 4;
game.active = 'a'; game.distrans = { card, blocked: null, choices: [{ recipient: 'r', blocked: null, cards: [given], unavailable: [] }] };
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard('distrans'), payment()] };
return game;
}
function saphoPlay(phase = 5, option: NonNullable<GameView['saphoOptions']>[number] =
{ scope: 'movement', event: 'controlled-movement-order', mode: 'last', afterCurrent: 'a' }): GameView {
const card = richeseCards().find(card => card.effect === 'juiceOfSapho'); assert.ok(card);
const game = projected(phase, { type: 'card', card: card.id, scope: option.scope, event: option.event, mode: option.mode });
const me = game.players.find(player => player.id === 'p')!; me.hand = [card]; me.handLimit = 4;
game.active = 'a'; game.saphoOptions = [option];
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard('juiceOfSapho'), payment()] };
return game;
}
function nexus(next: Action = { type: 'alliance', target: 'r' }): GameView {
const game = projected(1, next);
game.nexus = true; game.spiceWindow = null; game.active = 'a';
@@ -1261,14 +1281,21 @@ void test('turn-action printed-role editor uses public names without holdings or
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} authoring inspection`); }, configurable: true });
Object.defineProperty(game, 'ghola', { get() { throw Error('No private Ghola authoring inspection'); }, configurable: true });
Object.defineProperty(game, 'nullentropy', { get() { throw Error('No private Box authoring inspection'); }, configurable: true });
Object.defineProperty(game, 'distrans', { get() { throw Error('No private Distrans authoring inspection'); }, configurable: true });
Object.defineProperty(game, 'saphoOptions', { get() { throw Error('No private Sapho authoring inspection'); }, configurable: true });
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private card-play stage'); } });
const e = editor(physical(), false, game); select(e.tree(), '1', 'action', 'cardPlay');
assert.deepEqual(e.value, printedCard()); assert.equal(e.error(), null);
const choices = elements(find(e.tree(), 'id', 'truth-turn-action-1-effect')).filter(element => element.type === 'option');
assert.deepEqual(choices.map(choice => choice.props.value), ['hajr', 'ghola', 'nullentropyBox']);
assert.deepEqual(choices.map(choice => choice.props.value), ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho']);
select(e.tree(), '1', 'effect', 'ghola'); assert.deepEqual(e.value, printedCard('ghola')); assert.equal(e.error(), null);
select(e.tree(), '1', 'effect', 'karama'); assert.deepEqual(e.value, printedCard('ghola'));
select(e.tree(), '1', 'effect', 'nullentropyBox'); assert.deepEqual(e.value, printedCard('nullentropyBox')); assert.equal(e.error(), null);
for (const effect of ['distrans', 'juiceOfSapho'] as const) {
select(e.tree(), '1', 'effect', effect); assert.deepEqual(e.value, printedCard(effect)); assert.equal(e.error(), null);
for (const extra of [{ target: 'r' }, { give: 'private-card' }, { scope: 'movement' }, { event: 'private-source' }, { mode: 'first' }])
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect, ...extra } }, game, 'p'));
}
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'ghola', card: 'private-card-id' } }, game, 'p'));
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'karama' } }, game, 'p'));
for (const extra of [{ card: 'private-box-id' }, { selected: 'private-recovery' }, { amount: 2 }])
@@ -1280,12 +1307,15 @@ void test('turn-action printed-role editor uses public names without holdings or
assert.match(html, /Play Tleilaxu Ghola in its printed role after this answer this turn/);
assert.match(html, /Activate Nullentropy Box and pay 2 spice after this answer this turn/);
assert.match(html, /The selected recovered card stays private and is not promised/);
assert.match(html, /Play Distrans in its printed role after this answer this turn/);
assert.match(html, /Play Juice of Sapho in its printed role after this answer this turn/);
assert.match(html, /not a particular recipient, transferred card or order outcome/);
assert.match(html, /Hajr counts when played even if its extra movement is unused/);
assert.match(html, /an actual later replay after Box recovery does/); assert.doesNotMatch(html, /private-card-id|controlled-ghola|controlled-hajr/);
const full = editor({ kind: 'or', terms: [printedCard(), printedCard('nullentropyBox')] }, false, game);
for (let count = 3; count <= 16; count++) {
click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'cardPlay');
select(full.tree(), `1.${count}`, 'effect', 'nullentropyBox');
select(full.tree(), `1.${count}`, 'effect', count % 2 ? 'distrans' : 'juiceOfSapho');
}
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
assert.equal(control(full.tree(), 'add-action-1').props.disabled, true);
@@ -1428,17 +1458,127 @@ void test('printed Box activation accepts no private selection or extra native p
assert.equal(guide({ game, busy: false, act() { assert.fail('A turn-end Box activation requires its matching raw role'); } }).props.disabled, true);
});
void test('printed Distrans guide forwards its exact owned transfer quote without inspecting foreign hands', () => {
const game = distransPlay(), sent: Action[] = [];
for (const player of game.players.filter(player => player.id !== 'p'))
Object.defineProperty(player, 'hand', { get() { throw Error('Distrans uses the native capacity quote, not foreign hands'); }, configurable: true });
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Distrans receipt inspection'); } });
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
assert.deepEqual(sent[0], { type: 'card', card: game.distrans!.card.id, target: 'r', give: game.distrans!.choices[0].cards[0].id,
turnActionPromiseEvent: event, turnActionPromisePhase: 2 });
assert.match(markup(game), /Give Shield to Recipient with Distrans/);
});
void test('printed Distrans callbacks reject stale availability, physical cards, capacity and raw source', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.distrans = null; },
game => { game.distrans = { ...game.distrans! }; },
game => { game.distrans!.blocked = 'Finish the current native interaction.'; },
game => { game.distrans!.choices[0].blocked = 'The recipient is full.'; },
game => { game.distrans!.choices[0].cards = []; },
game => { game.players.find(player => player.id === 'p')!.hand!.pop(); },
game => { game.players.find(player => player.id === 'r')!.handCount = 4; },
game => { game.players.find(player => player.id === 'r')!.handLimit = 3; },
game => { game.turnActionPromises[0].mixed = payment(); },
game => { game.phase = 3; },
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
game => { game.turnActionGuide!.action.give = 'different-held-card'; },
];
for (const change of changes) {
const game = distransPlay(), sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
}
for (const change of [
(game: GameView) => { game.turnActionGuide!.action.target = 'p'; },
(game: GameView) => { game.turnActionGuide!.action.target = 'a'; },
(game: GameView) => { game.turnActionGuide!.action.give = game.distrans!.card.id; },
(game: GameView) => { game.turnActionGuide!.action.give = 'unquoted-card'; },
]) {
const game = distransPlay(); change(game);
const button = guide({ game, busy: false, act() { assert.fail('Distrans must use its current quoted recipient and other held card'); } });
assert.equal(button.props.disabled, true); press(button);
}
});
void test('printed Sapho guides forward every quoted native scope and retain display metadata outside the action', () => {
const options: [number, NonNullable<GameView['saphoOptions']>[number]][] = [
[3, { scope: 'onceAround', event: 'controlled-once-around', mode: 'first' }],
[3, { scope: 'cyclicAuction', event: 'controlled-cyclic', mode: 'last' }],
[4, { scope: 'revival', event: 'controlled-revival', mode: 'last', afterCurrent: 'a' }],
[5, { scope: 'movement', event: 'controlled-movement', mode: 'first', afterCurrent: 'a' }],
[6, { scope: 'battleOrder', event: 'controlled-battle-order', mode: 'last' }],
[6, { scope: 'battleAggressor', event: 'controlled-aggressor', mode: 'aggressor' }],
[8, { scope: 'extortion', event: 'controlled-extortion-order', mode: 'first' }],
];
for (const [phase, option] of options) {
const game = saphoPlay(phase, option), sent: Action[] = [];
if (option.scope === 'extortion') game.decision = { kind: 'moritaniExtortion', player: 'p', event: 'controlled-native-extortion' };
for (const player of game.players.filter(player => player.id !== 'p'))
Object.defineProperty(player, 'hand', { get() { throw Error('Sapho uses the native option, not foreign hands'); }, configurable: true });
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Sapho receipt inspection'); } });
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
assert.deepEqual(sent[0], { type: 'card', card: 'richese-juice-of-sapho', scope: option.scope, event: option.event, mode: option.mode,
turnActionPromiseEvent: event, turnActionPromisePhase: phase });
assert.equal(Object.hasOwn(sent[0], 'afterCurrent'), false);
assert.match(markup(game), /Play Juice of Sapho:/);
}
});
void test('printed Sapho callbacks and both role guides reject stale quotes or modified packets without generic fallback', () => {
for (const change of [
(game: GameView) => { game.saphoOptions = []; },
(game: GameView) => { game.saphoOptions = [...game.saphoOptions]; },
(game: GameView) => { game.saphoOptions[0].event = 'different-native-source'; },
(game: GameView) => { game.saphoOptions[0].mode = 'first'; },
(game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; },
(game: GameView) => { game.players.find(player => player.id === 'p')!.handLimit = 5; },
(game: GameView) => { game.turnActionPromises[0].mixed = payment(); },
(game: GameView) => { game.phaseOpening = { passed: [] }; },
(game: GameView) => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
]) {
const game = saphoPlay(), sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
}
for (const change of [
(game: GameView) => {
const current = game.decision; assert.ok(current?.kind === 'moritaniExtortion');
game.decision = { ...current, event: 'next-native-extortion' };
},
(game: GameView) => { game.decision = null; },
]) {
const game = saphoPlay(8, { scope: 'extortion', event: 'controlled-extortion-order', mode: 'first' });
game.decision = { kind: 'moritaniExtortion', player: 'p', event: 'controlled-native-extortion' };
const saved = guide({ game, busy: false, act() { assert.fail('A changed Extortion source cannot submit an earlier Sapho option'); } });
assert.equal(saved.props.disabled, false); change(game); press(saved);
}
for (const create of [distransPlay, () => saphoPlay(4, { scope: 'revival', event: 'controlled-revival', mode: 'last' })]) {
for (const extra of [{ amount: 1 }, { mode: 'shipment' }, { target: 'a' }, { give: 'unquoted-card' },
{ scope: 'unsupported' }, { event: 'different-source' }, { afterCurrent: 'a' }]) {
const game = create(); Object.assign(game.turnActionGuide!.action, extra);
const button = guide({ game, busy: false, act() { assert.fail('Printed-role guides forward only the exact native action fields'); } });
assert.equal(button.props.disabled, true); press(button);
}
const game = create(), sent: Action[] = [], props = { game, busy: false, act(action: Action) { sent.push(action); } }, button = guide(props);
assert.equal(button.props.disabled, false); props.busy = true; press(button); assert.deepEqual(sent, []);
}
});
void test('printed-card history retains only raw aggregate conditions and their distinct native deadlines', () => {
const game = projected(8); game.turnActionGuide = null;
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard(), { kind: 'and', terms: [fact(), printedCard('ghola'), printedCard('nullentropyBox')] }] };
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard(), { kind: 'and', terms: [fact(), printedCard('ghola'), printedCard('nullentropyBox'), printedCard('distrans'), printedCard('juiceOfSapho')] }] };
game.turnActionPromises[0].answer = false;
for (const key of ['cardPlays', 'compiled', 'combat', 'preparation'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
const html = markup(game);
assert.match(html, /Hajr/); assert.match(html, /Tleilaxu Ghola/); assert.match(html, /You answered Yes/);
assert.match(html, /Hajr/); assert.match(html, /Tleilaxu Ghola/); assert.match(html, /You answered No/);
assert.match(html, /Hajr counts even if its extra movement is unused/);
assert.match(html, /Earlier plays, discards, sales, Karama roles and recovery alone do not count/);
assert.match(html, /actual later Box replay does/); assert.match(html, /Ghola is separate from ordinary Revival/);
assert.match(html, /you activate Nullentropy Box/); assert.match(html, /Box conditions close at the actual turn end/);
assert.match(html, /Distrans/); assert.match(html, /Juice of Sapho/);
assert.match(html, /The deadline is the actual turn end, including for a whole No answer/);
assert.match(html, /remains open until the actual turn ends/); assert.doesNotMatch(html, /cardPlays|compiled|controlled-hajr|controlled-ghola/);
});
+21
View File
@@ -55,6 +55,27 @@ void test('Box raw question parsing needs no hidden possession and eagerly rejec
}
});
void test('Distrans and Sapho questions name only actual printed use this turn without transfer or order choices', () => {
const game = fixture();
for (const effect of ['distrans', 'juiceOfSapho'] as const) {
assert.ok(!game.players.find(player => player.id === 'e')!.hand.some(card => card.effect === effect));
const action = { kind: 'cardPlay' as const, effect };
const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2,
mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
{ kind: 'action', action }] } };
assert.doesNotThrow(() => validateTruthQuestionReceipt(game, 'a', question));
const text = truthQuestionText(question, id => id, id => id);
assert.match(text, effect === 'distrans' ? /complete a printed Distrans transfer and discard Distrans after this answer this turn/ :
/play Juice of Sapho in its printed role after this answer this turn/);
assert.doesNotMatch(text, /Ghola|first|last|aggressor|private-card|recipient/);
for (const extra of [{ recipient: 'h' }, { give: 'private-card' }, { scope: 'movement' }, { mode: 'first' }, { outcome: true }]) {
const invalid = { ...question, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } },
{ kind: 'action', action: { ...action, ...extra } }] } } as TruthQuestion;
assert.throws(() => validateTruthQuestionReceipt(game, 'a', invalid));
}
}
});
function fixture() {
const g = createGame('TRUTH22', newPlayer('a', 'Atreides', 'atreides'));
g.players.push(
+75 -3
View File
@@ -103,8 +103,9 @@ function cards(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActi
hajr: { played: false, closed: false }, ghola: { played: false, closed: false },
} };
const visit = (node: Expression): void => {
if (node.kind === 'action' && node.action.kind === 'cardPlay' && node.action.effect === 'nullentropyBox')
row.cardPlays!.nullentropyBox = { played: false, closed: false };
if (node.kind === 'action' && node.action.kind === 'cardPlay' &&
node.action.effect !== 'hajr' && node.action.effect !== 'ghola')
row.cardPlays![node.action.effect] = { played: false, closed: false };
else if (node.kind !== 'action' && node.kind !== 'fact') node.terms.forEach(visit);
};
visit(mixed);
@@ -936,7 +937,7 @@ void test('printed card-play leaves contain only the printed effect and every ra
assert.throws(() => turnActionValue(promise(group('or', fact(), card(effect)))), TurnActionError,
'Compiler folding never removes the original raw interval requirement');
}
for (const effect of ['', 'Hajr', 'karama', 'thumper', null])
for (const effect of ['', 'Hajr', 'karama', 'thumper', 'semutaDrug', null])
assert.throws(() => parseTurnActionLeaf({ kind: 'cardPlay', effect }), TurnActionError);
validateTurnActionPromises([cards(group('and', card(), card('ghola')))], seats);
});
@@ -1689,3 +1690,74 @@ void test('optional Box stage preserves legacy bytes and strict raw folded metad
for (const extra of [{ selected: 'Shield' }, { card: 'physical-box' }, { minimum: 2 }, { recipient: 'other' }])
assert.throws(() => parseTurnActionLeaf({ kind: 'cardPlay', effect: 'nullentropyBox', ...extra }), TurnActionError);
});
void test('each optional printed stage subset preserves legacy and Box bytes while rejecting unknown choice metadata', () => {
const base = cards(card('ghola'), false);
const optional = ['nullentropyBox', 'distrans', 'juiceOfSapho'] as const;
for (let mask = 0; mask < 8; mask++) {
const cardPlays = { ...base.cardPlays! };
optional.forEach((effect, index) => { if (mask & (1 << index)) cardPlays[effect] = null; });
const row = { ...base, cardPlays }, before = JSON.stringify(row);
validateTurnActionPromises([row], seats);
assert.equal(recordTurnActionCardPlay([row], 'respondent', 1, 'distrans')[0], row);
assert.equal(JSON.stringify(row), before);
const closed = finishTurnActionTurn([row], 'respondent', 1)[0];
assert.deepEqual(Object.keys(closed.cardPlays!).sort(), Object.keys(cardPlays).sort());
}
for (const effect of ['distrans', 'juiceOfSapho'] as const) {
const row = cards(card(effect)), stage = row.cardPlays![effect]!;
for (const extra of [{ recipient: 'other' }, { give: 'private-card' }, { scope: 'movement' }, { mode: 'last' }, { outcome: true }]) {
assert.throws(() => parseTurnActionLeaf({ kind: 'cardPlay', effect, ...extra }), TurnActionError);
const invalid = { ...row, cardPlays: { ...row.cardPlays!, [effect]: { ...stage, ...extra } } };
assert.throws(() => turnActionValue(invalid), TurnActionError);
}
}
const unsupported = { ...base, cardPlays: { ...base.cardPlays!, semutaDrug: null } } as TurnActionPromise;
assert.throws(() => turnActionValue(unsupported), TurnActionError);
});
void test('Distrans and Sapho whole alternatives share only actual later native receipts without selecting a transfer or mode', () => {
const joint = cards(group('and', card('distrans'), card('juiceOfSapho'), pay()));
const alternative = cards(group('or', card('distrans'), card('juiceOfSapho')), true, 'turn-action:2');
assert.deepEqual(branches(joint), [[{ kind: 'cardPlay', effect: 'distrans', answer: true },
{ kind: 'cardPlay', effect: 'juiceOfSapho', answer: true }, signedPay()]]);
assert.equal(recordTurnActionCardPlay([joint], 'other', 1, 'distrans')[0], joint);
assert.equal(recordTurnActionCardPlay([joint], 'respondent', 2, 'distrans')[0], joint);
const transferred = recordTurnActionCardPlay([joint, alternative], 'respondent', 1, 'distrans');
assert.equal(transferred[1].fulfilled, true);
assert.deepEqual(branches(transferred[0]), [[{ kind: 'cardPlay', effect: 'juiceOfSapho', answer: true }, signedPay()]]);
const used = recordTurnActionCardPlay(transferred, 'respondent', 1, 'juiceOfSapho');
assert.deepEqual(branches(used[0]), [[signedPay()]]);
assert.equal(recordTurnActionPayment(used, 'respondent', 1, payment())[0].fulfilled, true);
const fresh = cards(card('distrans'), true, 'turn-action:3');
assert.equal(fresh.cardPlays!.distrans!.played, false);
const replay = recordTurnActionCardPlay([used[0], fresh], 'respondent', 1, 'distrans');
assert.equal(replay[0], used[0]);
assert.equal(replay[1].fulfilled, true);
assert.equal(joint.cardPlays!.distrans!.played, false);
validateTurnActionPromises(used, seats);
});
void test('Distrans and Sapho No retain turn deadlines through Movement and payment closure and fail atomically on actual use', () => {
for (const effect of ['distrans', 'juiceOfSapho'] as const) {
const yes = cards(group('and', card(effect), pay()));
const no = cards(card(effect), false, 'turn-action:2');
const before = structuredClone([yes, no]);
assert.throws(() => recordTurnActionCardPlay([yes, no], 'respondent', 1, effect), TurnActionError);
assert.deepEqual([yes, no], before);
const transport = closeTurnActionTransport([no], 'respondent', 1);
const closedPayments = closeTurnActionPayments(transport, 'respondent', 1);
assert.deepEqual(closedPayments[0].cardPlays![effect], { played: false, closed: false });
assert.equal(closedPayments[0].fulfilled, false);
const expired = finishTurnActionTurn(closedPayments, 'respondent', 1)[0];
assert.equal(expired.fulfilled, true);
assert.deepEqual(expired.cardPlays![effect], { played: false, closed: true });
assert.equal(recordTurnActionCardPlay([expired], 'respondent', 1, effect)[0], expired);
assert.equal(finishTurnActionTurn([yes], 'respondent', 1)[0].released, true);
const wholeNo = cards(group('and', card(effect), pay()), false, 'turn-action:3');
const used = recordTurnActionCardPlay([wholeNo], 'respondent', 1, effect);
assert.throws(() => recordTurnActionPayment(used, 'respondent', 1, payment()), TurnActionError);
assert.deepEqual(used[0].payments, []);
validateTurnActionPromises([expired], seats);
}
});