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.