Bind completed Semuta exchanges to Truthtrance promises

This commit is contained in:
Quintine committed 2026-10-27 08:27:53 +11:00
1 parent 4c4bebe85e
commit b02839ce2a
24 files changed
+1014 -40

No files matched your search

+3 -1
View File
@@ -2088,7 +2088,9 @@ export function GameTable({
{g.status === 'playing' && <MixedBattlePromises game={g} />}
{g.status === 'playing' && <MixedTraitorPromises game={g} />}
{g.status === 'playing' && <TravelPromises game={g} busy={busy} act={act} />}
{g.status === 'playing' && <TurnActionPromises game={g} busy={busy} act={act} fillPlan={fillBattlePlan} />}
{g.status === 'playing' && <TurnActionPromises game={g} busy={g.semutaReaction &&
['semutaCommit', 'semutaSelect', 'semutaPass'].includes(g.turnActionGuide?.action.type ?? '')
? reactionBusy || reactionOwnsControls : busy} act={act} fillPlan={fillBattlePlan} />}
{g.status === 'playing' && g.phase === 3 && g.auction && (
<AuctionCountdown clock={g.auctionClock}
playerName={g.players.find(player => player.id === g.auction!.active)?.name ?? 'The current bidder'}
+3 -2
View File
@@ -26,15 +26,16 @@ export function SemutaReaction({
<>
<p>A Treachery card was just discarded. Continue when you are ready.</p>
{reaction.blocked && <p className="fine">{reaction.blocked}</p>}
{reaction.passBlocked && <p className="fine">{reaction.passBlocked}</p>}
{reaction.passed ? (
<p aria-live="polite">You continued. Waiting for other seats.</p>
) : (
<div className="flex flex-wrap gap-2">
<Button
className="game-action min-h-11 whitespace-normal"
disabled={busy}
disabled={busy || !!reaction.passBlocked}
onClick={() => {
if (!busy) act({ type: 'semutaPass', event: reaction.event });
if (!busy && !reaction.passBlocked) act({ type: 'semutaPass', event: reaction.event });
}}
>
Continue
+8 -4
View File
@@ -76,7 +76,7 @@ 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, Nullentropy Box, Distrans or Juice of Sapho in its printed role after this answer this turn.
Printed-card conditions ask you to play Hajr, Tleilaxu Ghola, Nullentropy Box, Distrans, Juice of Sapho or Semuta Drug 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.
@@ -85,6 +85,9 @@ const config: MixedActionConfig<TurnActionLeaf> = {
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.
Semuta Drug counts only after its native transfer and disposal complete after this answer.
Committing to inspect multiple candidates, an earlier play or recovery alone does not count; the selected card is not promised.
A committed private choice cannot be canceled. The condition, including a whole No answer, has the actual turn-end deadline.
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.
@@ -171,7 +174,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, Nullentropy Box, Distrans or Juice of Sapho in its printed role</option>
<option value="cardPlay">Play Hajr, Tleilaxu Ghola, Nullentropy Box, Distrans, Juice of Sapho or Semuta Drug 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>
@@ -187,15 +190,16 @@ 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={!['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho'].includes(value.effect)} onChange={event => {
aria-invalid={!['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug'].includes(value.effect)} onChange={event => {
const effect = event.currentTarget.value;
if (effect === 'hajr' || effect === 'ghola' || effect === 'nullentropyBox' || effect === 'distrans' || effect === 'juiceOfSapho') onChange({ kind: 'cardPlay', effect });
if (effect === 'hajr' || effect === 'ghola' || effect === 'nullentropyBox' || effect === 'distrans' || effect === 'juiceOfSapho' || effect === 'semutaDrug') 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>
<option value="semutaDrug">Complete a printed Semuta Drug transfer and disposal after this answer this turn</option>
</select>
</> : value.kind === 'windowCardPlay' ? <>
<label htmlFor={`${id}-window-effect`}>Printed role in the original window</label>
+37 -7
View File
@@ -36,7 +36,7 @@ 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'
const namesPrintedPlay = (mixed: MixedActionExpression<TurnActionLeaf>, effect: 'distrans' | 'juiceOfSapho' | 'semutaDrug'): boolean => mixed.kind === 'action'
? mixed.action.kind === 'cardPlay' && mixed.action.effect === effect
: mixed.kind !== 'fact' && mixed.terms.some(term => namesPrintedPlay(term, effect));
@@ -78,6 +78,17 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const box = game.nullentropy, search = box?.search;
const windowPromise = game.turnActionPromises.find(promise => promise.event === event && promise.player === actor && promise.turn === turn);
const windowQuestion = windowPromise && JSON.stringify(windowPromise.mixed);
const semutaAction = action.type === 'semutaCommit' || action.type === 'semutaSelect' || action.type === 'semutaPass';
const semuta = semutaAction ? game.semutaReaction : null;
const semutaSource = (reaction: GameView['semutaReaction']) => reaction && JSON.stringify({
event: reaction.event, stage: reaction.stage, passed: reaction.passed, canCommit: reaction.canCommit, blocked: reaction.blocked,
passBlocked: reaction.passBlocked,
...(reaction.stage === 'select' ? { candidates: reaction.candidates.map(card => ({ id: card.id, kind: card.kind, effect: card.effect })) } : {}),
});
const semutaSignature = semutaAction ? semutaSource(semuta) : null;
const semutaContext = (current: GameView) => JSON.stringify({ truth: current.truthtrance, response: current.response,
decision: current.decision, opening: current.phaseOpening, automatic: current.automaticContinuationPending });
const semutaContextSignature = semutaAction ? semutaContext(game) : null;
const boxRoleNamed = !!windowPromise && namesBoxPlay(windowPromise.mixed);
const opening = game.phaseOpening, openingSignature = JSON.stringify(opening);
const currentAmalWindow = windowCardOpportunity(game, 'amal');
@@ -158,6 +169,15 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const hajrSignature = heldCard?.effect === 'hajr' ? hajrSource(game) : null;
const canAct = () => {
const current = props.game, me = current.players.find(player => player.id === actor);
const semutaPacket = semutaAction && Object.keys(action).every(key => ['type', 'event', 'turnActionPromiseEvent', 'turnActionPromisePhase',
...(action.type === 'semutaSelect' ? ['card'] : [])].includes(key));
const nativeSemuta = semutaPacket && !!semuta && current.semutaReaction === semuta && semutaSource(current.semutaReaction) === semutaSignature &&
semutaContext(current) === semutaContextSignature && action.event === semuta.event && !!windowPromise &&
current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion &&
namesPrintedPlay(windowPromise.mixed, 'semutaDrug') &&
(semuta.stage === 'offer' ? !semuta.passed && (action.type === 'semutaPass' && !semuta.passBlocked ||
action.type === 'semutaCommit' && semuta.canCommit && semuta.blocked === null)
: action.type === 'semutaSelect' && typeof action.card === 'string' && semuta.candidates.some(card => card.id === action.card));
const ownPhysicalCard = (action.type === 'card' || printedInvoiceAction) && !!heldCard && !!me && me === cardOwner &&
me.hand?.find(card => card.id === action.card) === heldCard &&
JSON.stringify({ id: heldCard.id, kind: heldCard.kind, effect: heldCard.effect }) === cardSignature;
@@ -250,7 +270,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
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' && heldCard.effect !== 'distrans' && heldCard.effect !== 'juiceOfSapho' && !isWindowEffect(heldCard.effect);
!!heldCard && heldCard.effect !== 'ghola' && heldCard.effect !== 'hajr' && heldCard.effect !== 'distrans' && heldCard.effect !== 'juiceOfSapho' && heldCard.effect !== 'semutaDrug' && !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 &&
@@ -333,7 +353,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 = printedDistrans || printedSapho || printedBox || openingReady || earlyWindowReady || earlyWindowBox || printedWindow || ownedPower || payment || paidBox || printedHajr || printedGhola || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2
const opportunity = nativeSemuta || 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
@@ -351,8 +371,9 @@ 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 || saphoDecision || earlyWindowBox && ownInvoiceSource) &&
(!current.phaseOpening || allowedOpeningAction) && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
!!me && !me.autopilot && (!current.semutaReaction || nativeSemuta) && (!current.truthtrance || nativeSemuta) && (!current.response || nativeSemuta) &&
(!current.decision || nativeSemuta || paidBox || invoice || saphoDecision || earlyWindowBox && ownInvoiceSource) &&
(!current.phaseOpening || nativeSemuta || allowedOpeningAction) && (!current.automaticContinuationPending || nativeSemuta) && !current.roomControl?.paused && !current.roomControl?.closed;
};
const amount = action.forces && typeof action.forces === 'object'
? Object.values(action.forces as Record<string, number>).reduce((sum, count) => sum + count, 0)
@@ -366,6 +387,10 @@ export function TurnActionGuideButton(props: TurnActionProps) {
: 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 === 'semutaCommit' ? 'Commit Semuta Drug to this fresh discard while honoring your whole answer'
: action.type === 'semutaSelect' ? `Complete Semuta Drug by claiming ${semuta?.stage === 'select'
? semuta.candidates.find(card => card.id === action.card)?.name ?? 'the quoted card' : 'the quoted card'} while honoring your whole answer`
: action.type === 'semutaPass' ? 'Continue without committing Semuta Drug to this fresh discard while preserving your whole answer'
: action.type === 'card' && heldCard?.effect === 'hajr' ? 'Play Hajr in its printed role while honoring your whole answer'
: action.type === 'card' && heldCard?.effect === 'ghola' ? action.leader !== undefined
? `Play Tleilaxu Ghola to return ${gholaLeader} while honoring your whole answer`
@@ -468,7 +493,7 @@ 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, Nullentropy Box, Distrans or Juice of Sapho plays in their printed role after the answer this turn.
Printed-card conditions count actual future Hajr, Tleilaxu Ghola, Nullentropy Box, Distrans, Juice of Sapho or Semuta Drug 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.
@@ -477,6 +502,9 @@ export function TurnActionPromises(props: TurnActionProps) {
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.
Semuta Drug counts only when its native transfer and disposal actually complete after the answer.
Committing to inspect multiple candidates, an earlier play or recovery alone does not count; the selected card is not promised.
A committed private choice cannot be canceled. Its deadline, including for a whole No answer, is the actual turn end.
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.
@@ -536,10 +564,12 @@ 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, Nullentropy Box, Distrans and Juice of Sapho conditions require actual later printed-role plays, not earlier play, disposal or recovery alone.
Hajr, Tleilaxu Ghola, Nullentropy Box, Distrans, Juice of Sapho and Semuta Drug 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.
Semuta Drug counts the completed native transfer and disposal, not a multi-choice inspection commitment or recovery alone.
Its selected card is not promised; a committed private choice cannot be canceled. Its whole No deadline is the actual turn end.
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.
+32
View File
@@ -20,6 +20,38 @@ open mode gates.
Continue remaining Basic functions before Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Truthtrance / printed Semuta exchange — 27 October. Prototyped / Partial.**
Whole questions now count actual exchange/disposal after answering, with no
selected-card predicate or credit for inspection alone. Current fresh events,
the exact declared foreign Truth cost, shared payments, native capacity and
No/remaining-completion preflight connect. Exact human guides and four legal
profiles preserve the native suffix; a required Yes cannot pass its sole known
trigger, while OR retains later alternatives.
Frozen **22 affected test files, 396/396**, types/lint/build and five
runtime groups/six programmes pass. Five programmes use genuine earned cache
Semuta and original Truth deals; one clearly labelled conserved-acquisition
supplement generates its later multi-card batch through real battle controls.
Runtime includes 30 GameTable SSR renders, 6 actual answer callbacks,
8 whole-guide callbacks and 1 native Semuta callback.
Source/further counts derive from actual arrays; inherited supplement history
is explicitly unavailable. No new native acquisition is claimed for that setup.
Focused cases include whole No AND/OR, cumulative payment caps, exact stale/JSON
packets, both actual multi-card choices, prior-use exclusion and genuine later
Box1/search2 recovery followed by a new original-Truth replay. A detached opaque
quote supplement permits generic Distrans transfer while retaining the specific-
effect prohibition. Initial feedback repairs an optional projection type,
fixture excess-property typing, a const declaration and a test that double-spent
a standing bid reservation. The native reservation remains authoritative.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-semuta-play-proof-v4/`;
journeys/HTML: adjacent `20261027-truth-semuta-play-runtime/run/`.
No HTTP/storage boundary, saved-game/database, restart or deployed operation
changes. Broader future-trigger calendars/funding, combinations, complete modes
and final assurance/browser/deployed acceptance remain open.
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-semuta-exchange--27-october-2026).
**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,
+24
View File
@@ -1,5 +1,29 @@
# Dune implementation status
## 27 October 2026 — Printed Semuta exchange commitments
Prototyped / Partial. Completed physical exchange/disposal counts after the
answer; inspection alone does not. Current native events and the exact declared
foreign Truth cost precede later shared-purse actions. Immediate No and surviving
completion checks precede inspection, and actual result checks precede the typed
suffix. Exact human guides and four minimal profiles preserve native controls.
Frozen **22 affected test files, 396/396**, types/lint/build and five
runtime groups/six programmes pass. Five programmes use genuine earned cache
Semuta and original Truth deals; one clearly labelled conserved-acquisition
supplement generates its later multi-card batch through real battle controls.
Runtime includes 30 GameTable SSR renders, 6 actual answer callbacks,
8 whole-guide callbacks and 1 native Semuta callback.
Source/further counts derive from actual arrays; inherited supplement history
is explicitly unavailable. No new native acquisition is claimed for that setup.
Tests also exercise genuine paid Box recovery/new-interval replay; a detached
opaque quote supplement permits only generic Distrans fuel, not a specific
unseen effect. Optional projection/fixture types, a const and a double-spent standing-bid
test assumption are repaired after initial feedback; native reservations remain. No full AI, saved-game operation, restart or deployment occurs.
Broader future calendars/combinations and all final acceptance remain open.
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-semuta-exchange--27-october-2026).
Private proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-semuta-play-proof-v4/`.
## 27 October 2026 — Printed transfer/order commitments
Prototyped / Partial. Distrans and Sapho actual use share native costs/options
+13
View File
@@ -1,5 +1,18 @@
# Rule decision index
## Truthtrance printed Semuta use — 27 October 2026
Prototyped / Partial. Count the completed physical exchange and Semuta disposal,
not its irreversible private-inspection commitment. The selected card is outside
the predicate, and No closes at turn end. Quote only the current supported fresh
window or the exact already-declared foreign Truth cost; do not invent later
foreign discards. Immediate No and completion preflight precede inspection;
selection checks the real result before its native suffix. Unknown acquisitions
cannot supply specific effects, but may supply Distrans's generic other-card
transfer with existing reservations/capacity. This composes existing printed
contracts without a new publisher priority ruling or full-mode certificate.
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-semuta-exchange--27-october-2026).
## Truthtrance printed transfer/order use — 27 October 2026
Prototyped / Partial. Future Distrans and Sapho predicates concern actual native
+12
View File
@@ -2,6 +2,18 @@
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.
## Printed-use Truthtrance promises — 27 October 2026
The combined current-turn question can name the actual completed Semuta exchange
and disposal without selecting its recovered card. Inspection commitment alone
does not count. Reuse native fresh-event admission, physical exchange and typed
continuations; check immediate No and a surviving completion before inspection.
The current declared foreign Truth cost is a known trigger, while unknown future
opponent discards are not forecast. No lasts through the original turn end.
This is bounded composition, with original cache/Truth singleton sources and
separately labelled controlled multi-card supplements; full acceptance stays open.
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-semuta-exchange--27-october-2026).
## Completed ordinary-auction Truth/Distrans costs — 27 October 2026
The native preview now preserves an ordinary lot while offering a completed
+45
View File
@@ -1,5 +1,50 @@
# Truthtrance combined current-turn actions
## Printed Semuta exchange — 27 October 2026
**Prototyped / Partial.** `cardPlay/semutaDrug` means the completed printed
exchange and disposal after answering this turn. A multi-card inspection
commitment is irreversible but does not itself satisfy the predicate. The
selected card, event and inspection are not question fields. Earlier use,
recovery alone and other roles do not count; the No interval lasts through
the original turn's Mentat.
The exact already-declared foreign Truthtrance cost supplies a guaranteed fresh
singleton only where the original native Semuta admission permits it. The
completion quote exchanges the actual physical card before later payment or
preparation, sharing the real purse without a fee, income or another queued
question's disposal. Current native fresh windows also quote their original
offer or owned selection. No arbitrary future opponent discard is invented.
An uncommitted hidden acquisition supplies no specific effect. It can supply
the generic other physical card needed by Distrans, retaining native transfer
reservations and capacity. Exact current guides are recomputed after acquisition.
Immediate No and remaining-completion checks precede an inspection commitment.
A required Yes cannot pass its only modelled fresh trigger; an OR alternative
can pass and complete later. Actual selection records the play and checks the
result before the original typed suffix resumes. Neutral foreign acknowledgments,
native reservations, net-zero hand exchange, original auction/battle controls
and other players' rights remain. Persistent human guides bind the whole answer
and exact native event; four minimal profiles consume those same guides.
This is bounded composition of the existing Truthtrance and printed Semuta
contracts, not a publisher worked example. Native earned cache/Truth singleton
sources and explicitly controlled multi-card supplements are distinguished.
Broader future-trigger calendars, full combinations/modes and final assurance,
browser/hydration and deployed acceptance remain open.
Frozen **22 affected test files, 396/396**, types/lint/build and five
runtime groups/six programmes pass. Five programmes use genuine earned cache
Semuta and original Truth deals; one clearly labelled conserved-acquisition
supplement generates its later multi-card batch through real battle controls.
Runtime includes 30 GameTable SSR renders, 6 actual answer callbacks,
8 whole-guide callbacks and 1 native Semuta callback.
Source/further counts derive from actual arrays; inherited supplement history
is explicitly unavailable. No new native acquisition is claimed for that setup.
Private proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-semuta-play-proof-v4/`;
journeys/HTML: adjacent `20261027-truth-semuta-play-runtime/run/`.
This is local native/SSR/callback evidence, not hydration or deployed acceptance.
## Printed transfer and order cards — 27 October 2026
**Prototyped / Partial.** `cardPlay/distrans` and `cardPlay/juiceOfSapho`
+1
View File
@@ -24,5 +24,6 @@ export function botSemutaActions(g: GameView): Action[] {
);
if (reaction.canCommit && !reaction.blocked && hasSemuta)
return [{ type: 'semutaCommit', event: reaction.event }];
if (reaction.passBlocked) return [];
return [{ type: 'semutaPass', event: reaction.event }];
}
+150 -9
View File
@@ -5036,8 +5036,8 @@ function savedTransferResponses(g: Game) {
canceled(g.pendingRicheseGift?.resume.pendingKarama),
];
}
function transferCardBlock(g: Game, owner: Player, card: Card) {
if ((g as TurnActionCardProbe)._turnActionUnknownCards?.includes(card.id))
function transferCardBlock(g: Game, owner: Player, card: Card, opaqueTransfer = false) {
if (!opaqueTransfer && (g as TurnActionCardProbe)._turnActionUnknownCards?.includes(card.id))
return 'An unseen future acquired card cannot fund this completion.';
if (isPortableSnooper(card) && homeworldSavedDecisions(g).some((d) => d.kind === 'homeworldDefense' && d.player === owner.id))
return 'Resolve the Homeworld late-defense choice before transferring its Portable Snooper.';
@@ -5191,7 +5191,9 @@ function validateDistrans(
error instanceof Error ? error.message : 'Invalid Distrans transfer.',
);
}
const reserved = transferCardBlock(g, owner, result.transferred);
// Distrans needs another physical card, not knowledge of that card's effect.
// Detached opaque acquisitions may supply this generic transfer only.
const reserved = transferCardBlock(g, owner, result.transferred, true);
requireRule(!reserved, reserved ?? 'This card is already committed.');
validateTransferCompletion(
g,
@@ -8995,7 +8997,10 @@ function semutaSpendingBlock(g: Game, owner: Player, card: Card): string | null
: preventedGift ? {...g,...gift!.resume,pendingRicheseGift:null} : g,owner,card);
}
function projectedSemutaReaction(g: Game, me: Player) {
function projectedSemutaReaction(g: Game, me: Player): {
event: string; stage: 'select' | 'offer'; passed: boolean; canCommit: boolean;
blocked: string | null; candidates: Card[]; passBlocked?: string;
} | null {
const pending = g.pendingTreacheryDiscard;
const reaction = pending?.reaction;
if (!pending || !reaction) return null;
@@ -9010,15 +9015,20 @@ function projectedSemutaReaction(g: Game, me: Player) {
richeseCardDefinition(card)?.card.effect === 'semutaDrug');
const held = !!semuta;
const blocked = !held || passed ? null
: semutaSpendingBlock(g, me, semuta!) ??
: semutaSpendingBlock(g, me, semuta!) ?? printedRolePlayBlock(g, me.id, 'semutaDrug') ??
(me.hand.length > handLimit(me)
? 'Resolve your excess hand cards before using Semuta Drug.'
: pending.batch.entries.every(entry => entry.discardedBy === me.id)
? 'No other player discarded a card in this fresh event.'
: null);
: hasTurnActionSemutaStage(g, me.id) && !turnActionSemutaCompletion(g, me.id, 'use')
? 'Retain a legal completion of your whole Truthtrance answer before committing Semuta.' : null);
const passBlocked = !passed && hasTurnActionSemutaStage(g, me.id) &&
!turnActionSemutaCompletion(g, me.id, 'pass')
? 'Your whole Truthtrance answer still needs this fresh Semuta exchange.' : null;
return {
event: pending.batch.event, stage: 'offer' as const, passed,
canCommit: held && !passed && !blocked, blocked, candidates: [] as Card[],
...(passBlocked ? { passBlocked } : {}),
};
}
@@ -9086,6 +9096,11 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
g.players.map(p => p.id), owner.id, pending.batch.entries.filter(entry => entry.discardedBy !== owner.id), claim!.card));
rememberMovementDiscards(g, pending.batch.entries.filter(entry => entry.discardedBy === owner.id)
.map(entry => ({ ...entry, publicFace: false })));
recordNativePrintedCardPlay(g, owner.id, 'semutaDrug');
// Check the completed exchange before its typed suffix can draw, pay or
// offer another player's decision. The suffix itself stays authoritative.
const quoted = turnActionFreshDiscardTail(g, next);
guardTurnActionPrintedCardCost(quoted, getPlayer(quoted, owner.id), result.usedCard);
log(g, `${owner.name} played Semuta Drug and recovered one freshly discarded Treachery Card. The acquired card stays private.`,
{ faction: owner.faction, name: 'Semuta Drug' });
}
@@ -9327,6 +9342,9 @@ function applySemutaReaction(g: Game, id: string, action: Action) {
if (action.type === 'semutaPass') {
requireRule(Object.keys(action).sort().join(',') === 'event,type',
'Pass only the current fresh discard.');
if (hasTurnActionSemutaStage(g, id))
requireRule(!!turnActionSemutaCompletion(g, id, 'pass'),
'Retain a legal completion of your whole Truthtrance answer before passing this fresh discard.');
if (reaction.passed.length + 1 === g.players.length)
finishTreacheryDiscard(g, undefined, id);
else reaction.passed.push(id);
@@ -9335,6 +9353,11 @@ function applySemutaReaction(g: Game, id: string, action: Action) {
requireRule(action.type === 'semutaCommit' &&
Object.keys(action).sort().join(',') === 'event,type',
'Choose whether to commit Semuta to this discard.');
const forbidden = printedRolePlayBlock(g, id, 'semutaDrug');
requireRule(!forbidden, forbidden ?? 'Retain your whole Truthtrance answer.');
if (hasTurnActionSemutaStage(g, id))
requireRule(!!turnActionSemutaCompletion(g, id, 'use'),
'This Semuta commitment would remove the last legal completion of your whole Truthtrance answer.');
const candidates = semutaCandidates(g, player).map(card => card.id);
pending.reaction = { stage: 'select', player: id, candidates };
if (candidates.length === 1) finishTreacheryDiscard(g, { player: id, card: candidates[0] });
@@ -9347,6 +9370,9 @@ function applySemutaReaction(g: Game, id: string, action: Action) {
id === reaction.player && typeof action.card === 'string' &&
reaction.candidates.includes(action.card),
'Choose one committed Semuta card from this fresh discard.');
if (hasTurnActionSemutaStage(g, id))
requireRule(!!turnActionSemutaCompletion(g, id, 'use', action.card),
'Choose a fresh card that retains a legal completion of your whole Truthtrance answer.');
finishTreacheryDiscard(g, { player: id, card: action.card });
}
function place(p: Player, t: string, s: number, n: number, elite = 0) {
@@ -19008,6 +19034,7 @@ type TurnActionCardProbe = Game & {
_turnActionPriceCursor?: { order: string[]; index: number };
_turnActionShipmentReceipt?: { player: string; event?: { territory: string; amount: number } | null };
_turnActionThumperForecast?: true;
_turnActionFreshDiscard?: { batch: FreshDiscardBatch; continuation: TruthDiscardContinuation };
};
function printedCardRule<T>(operation: () => T): T {
try { return operation(); }
@@ -19037,6 +19064,9 @@ function turnActionCardsMatch(g: Game, actor: string, requirements: readonly Tur
}
function* nativePrintedCardActions(g: Game, actor: string, effect: PrintedCardEffect): Generator<Action> {
const player = getPlayer(g, actor);
// Semuta belongs only to its exact fresh-discard window, projected before
// ordinary payment/card preparation rather than as a generic card action.
if (effect === 'semutaDrug') return;
if (effect === 'distrans') {
const quote = distransView(g, player);
if (quote && !quote.blocked) for (const choice of quote.choices) if (!choice.blocked)
@@ -19751,7 +19781,7 @@ function turnActionAllianceCanClose(g: Game, player: Player): boolean {
return !!turnActionCompletion(source, player.id);
} catch (error) { if (error instanceof RuleError) return false; throw error; }
}
type TurnActionCompletion = MovementCompletion & { payments: Action[]; paymentsAfterPreparations?: boolean; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action; cardPreparations?: Action[]; battlePower?: Action };
type TurnActionCompletion = MovementCompletion & { payments: Action[]; paymentsAfterPreparations?: boolean; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action; cardPreparations?: Action[]; battlePower?: Action; freshDiscard?: Action };
/** Forecast deterministic opening work only. Auction/deck draws and foreign
* choices stay at the real table; fixed Mentat receipts arrive after Amal. */
function projectTurnActionOpeningEnd(g: Game) {
@@ -19815,6 +19845,82 @@ function guardTurnActionOpening(g: Game, actor: string, pass: boolean) {
/** Whole-answer alternatives stay private. Only detached native rows quote a
* route, sharing actual interval progress and the remaining physical purse. */
function turnActionCompletion(source: Game, actor: string, unknownConsent?: { value: boolean }): TurnActionCompletion | null {
if (hasTurnActionSemutaStage(source, actor) &&
(source.pendingTreacheryDiscard?.reaction || (source as TurnActionCardProbe)._turnActionFreshDiscard))
return turnActionSemutaCompletion(source, actor, 'either', undefined, unknownConsent);
return turnActionCompletionAfterFreshDiscard(source, actor, unknownConsent);
}
function hasTurnActionSemutaStage(g: Game, actor: string): boolean {
return liveTurnActionPromises(g.turnActionPromises ?? [], actor, g.turn)
.some(promise => !!promise.cardPlays?.semutaDrug);
}
/** Remove only this reaction from a detached continuation quote. Native
* suffixes, hidden draws, queued questions and opposing choices remain live. */
function turnActionFreshDiscardTail(source: Game,
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation']): Game {
const game = structuredClone(source);
game.pendingTreacheryDiscard = null;
delete (game as TurnActionCardProbe)._turnActionFreshDiscard;
game.truthtrance = null;
if ('resume' in continuation) {
game.response = structuredClone(continuation.resume.response);
game.decision = structuredClone(continuation.resume.decision);
game.pendingKarama = structuredClone(continuation.resume.pendingKarama);
game.phaseOpening = structuredClone(continuation.resume.phaseOpening);
}
return game;
}
/** Fresh use precedes every later witness. An uncommitted hidden face cannot
* fund a specific effect: only public cards, or the entitled selector's actual
* candidates, are known. One opaque alternative suffices for generic custody. */
function turnActionSemutaCompletion(source: Game, actor: string, mode: 'use' | 'pass' | 'either',
selected?: string, unknownConsent?: { value: boolean }): TurnActionCompletion | null {
const pending = source.pendingTreacheryDiscard, forecast = (source as TurnActionCardProbe)._turnActionFreshDiscard;
const batch = pending?.batch ?? forecast?.batch;
const continuation = pending?.continuation ?? forecast?.continuation;
if (!batch || !continuation) return null;
const reaction = pending?.reaction;
const selecting = reaction?.stage === 'select';
if (selecting && reaction.player !== actor) return null;
const owner = getPlayer(source, actor);
const tail = turnActionFreshDiscardTail(source, continuation);
if (mode !== 'pass' && (!reaction || selecting || !reaction.passed.includes(actor))) {
const semuta = owner.hand.find(card => card.id === SEMUTA_DRUG_ID && card.effect === 'semutaDrug');
if (semuta && owner.hand.length <= handLimit(owner) && !semutaSpendingBlock(source, owner, semuta) &&
!printedRolePlayBlock(source, actor, 'semutaDrug')) {
let usedOpaque = false;
for (const entry of batch.entries) {
if (entry.discardedBy === actor || selected !== undefined && entry.card.id !== selected ||
selecting && !reaction.candidates.includes(entry.card.id)) continue;
const known = selecting || entry.publicFace;
if (!known && usedOpaque) continue;
if (!known) usedOpaque = true;
try {
const result = resolveSemutaDrug({ batch, event: batch.event, turn: source.turn, phase: source.phase,
owner: actor, ownerHand: owner.hand, discard: source.discard, semutaId: SEMUTA_DRUG_ID,
handLimit: handLimit(owner), incomingReservedSlots: 0, reservedTargetIds: [] }, entry.card.id);
const game = structuredClone(tail), player = getPlayer(game, actor);
player.hand = result.ownerHand; game.discard = result.discard;
if (!known) (game as TurnActionCardProbe)._turnActionUnknownCards = [
...((game as TurnActionCardProbe)._turnActionUnknownCards ?? []), result.claimedCard.id,
];
recordNativePrintedCardPlay(game, actor, 'semutaDrug');
setMovementDiscardKnowledge(game, recordMovementClaimKnowledge(game.movementDiscardKnowledge,
game.players.map(p => p.id), actor, batch.entries.filter(candidate => candidate.discardedBy !== actor), entry.card.id));
const completion = turnActionCompletionAfterFreshDiscard(game, actor, unknownConsent);
if (completion) return { ...completion, freshDiscard: selecting
? { type: 'semutaSelect', event: batch.event, card: entry.card.id }
: { type: 'semutaCommit', event: batch.event } };
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
}
}
if (mode === 'use' || selecting) return null;
const completion = turnActionCompletionAfterFreshDiscard(tail, actor, unknownConsent);
return completion ? { ...completion, ...(reaction?.stage === 'offer' && !reaction.passed.includes(actor)
? { freshDiscard: { type: 'semutaPass', event: batch.event } } : {}) } : null;
}
function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unknownConsent?: { value: boolean }): TurnActionCompletion | null {
const records = [
...liveTravelPromises(source.travelPromises ?? [], actor, source.turn).map(promise =>
() => travelRule(() => travelBranches(promise))),
@@ -20060,7 +20166,8 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
g.turn, combat.event, combat.territory, turnActionPlanValues(g, p.id, [claim.mixed])))[0];
return promise;
}
function turnActionAnswerSource(g: Game, player: string, alliance = false, window = false): Game {
function turnActionAnswerSource(g: Game, player: string, alliance = false, window = false,
fresh?: { claim: TurnActionClaim; answer: boolean }): Game {
const source = g.phase === 0 && window || g.phase === 1 && (alliance || window) || g.phase === 2 || g.phase === 4 && !g.revivalOrder || g.phase === 8 ||
g.phase === 7 && bribesCloseBeforeNextMentat(g)
? { ...g, ready: g.ready.filter(id => id !== player) } : g;
@@ -20074,6 +20181,24 @@ function turnActionAnswerSource(g: Game, player: string, alliance = false, windo
// answer. Quote it once, without playing another queued card or a reaction.
const card = discard(projected, owner, consumed.card);
rememberMovementDiscards(projected, [{ card, discardedBy: owner.id, publicFace: true }]);
if (fresh) {
const continuation: TruthDiscardContinuation = { kind: 'truthtranceDiscard', consumed,
historyIndex: g.truthHistory?.length ?? 0,
record: { turn: g.turn, phase: g.phase, asker: consumed.player,
question: { kind: 'turnActions', target: player, ...fresh.claim }, answer: fresh.answer ? 'yes' : 'no' },
remaining: g.truthtrance!.queue.length > 1 ? { stage: 'ask', queue: g.truthtrance!.queue.slice(1),
passed: g.truthtrance!.passed, question: null } : null,
resume: { response: projected.response, decision: projected.decision,
pendingKarama: projected.pendingKarama, phaseOpening: projected.phaseOpening },
promise: null, parentSignature: '' };
const entries = [{ card, discardedBy: owner.id, publicFace: true }];
const admission = { ...projected, truthtrance: null, response: null, decision: null,
pendingKarama: null, phaseOpening: null };
if (semutaOfferSupported(admission, continuation, entries))
(projected as TurnActionCardProbe)._turnActionFreshDiscard = { continuation,
batch: { event: 'turn-action:declared-truth-discard', turn: g.turn, phase: g.phase,
cause: 'truthtrance', entries } };
}
return projected;
}
function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): TruthAnswer[] {
@@ -20084,7 +20209,7 @@ function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): Tru
try { promise = turnActionPromise(g, p, claim, answer, 'candidate', g.players.find(other => other.id !== p.id)!.id); }
catch (error) { if (error instanceof RuleError) continue; throw error; }
if (promise.compiled.kind === 'constant' && promise.compiled.value !== answer) continue;
const trial = { ...turnActionAnswerSource(g, p.id, !!promise.alliance, !!promise.windowCards),
const trial = { ...turnActionAnswerSource(g, p.id, !!promise.alliance, !!promise.windowCards, { claim, answer }),
turnActionPromises: [...(g.turnActionPromises ?? []), promise], travelPromiseOrder: undefined };
if (turnActionCompletion(trial, p.id, answer && promise.alliance ? consent : undefined)) answers.push(answer ? 'yes' : 'no');
}
@@ -20182,6 +20307,14 @@ function projectedTurnActionPromises(g: Game, player: string) {
}
function projectedTurnActionGuide(g: Game, player: Player): { event: string; action: Action } | null {
const promise = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn)[0];
if (g.pendingTreacheryDiscard?.reaction) {
const row = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn)
.find(row => !!row.cardPlays?.semutaDrug);
if (!row) return null;
const action = turnActionCompletion(g, player.id)?.freshDiscard;
return action ? { event: row.event, action: { ...action,
turnActionPromiseEvent: row.event, turnActionPromisePhase: g.phase } } : null;
}
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' &&
@@ -38087,6 +38220,14 @@ export function applyAction(state: Game, id: string, action: Action): Game {
getPlayer(state, id);
if (state.pendingTreacheryDiscard.reaction &&
['semutaPass', 'semutaCommit', 'semutaSelect'].includes(action?.type)) {
if (action.turnActionPromiseEvent !== undefined || action.turnActionPromisePhase !== undefined) {
const guide = projectedTurnActionGuide(state, getPlayer(state, id));
requireRule(guide && action.turnActionPromisePhase === state.phase && movementGuideActionMatches(action, guide.action),
'This turn-action guide is stale or belongs to another fresh discard.');
action = { ...action };
delete action.turnActionPromiseEvent;
delete action.turnActionPromisePhase;
}
const g = structuredClone(state);
applySemutaReaction(g, id, action);
treacheryDiscardIntegrity(g);
+3 -2
View File
@@ -1,4 +1,4 @@
export const PRINTED_CARD_EFFECTS = ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho'] as const;
export const PRINTED_CARD_EFFECTS = ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug'] as const;
export type PrintedCardEffect = typeof PRINTED_CARD_EFFECTS[number];
export type PrintedCardPlayCounts = {
turn: number;
@@ -55,7 +55,8 @@ export function printedCardPlayCount(counts: PrintedCardPlayCounts | undefined,
}
/** Call once for each actually accepted printed-role play, never for an effect,
* sale, discard, recovery or role conversion. Genuine reacquisition may replay. */
* sale, discard, recovery or role conversion. Semuta requires its physical
* exchange, not commitment or candidate inspection. Genuine reacquisition may replay. */
export function recordPrintedCardPlay(counts: PrintedCardPlayCounts | undefined, player: string,
turn: number, cardEffect: PrintedCardEffect): PrintedCardPlayCounts {
validateRequest(player, turn, cardEffect);
+4 -3
View File
@@ -3231,6 +3231,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'A printed Semuta Drug clause counts the completed exchange and disposal after answering, not a multi-card inspection commitment. The selected card is not promised. Its No interval lasts through Mentat. Current fresh windows and the already-declared foreign Truth cost retain native event, reservations and physical exchange; no later foreign discard is invented. Immediate No and completion checks precede inspection, and a required Yes cannot pass its sole modelled trigger. OR can preserve a later alternative. Unknown acquisitions supply no specific effect, but can supply Distrans’s generic other-card transfer with native reservations.',
'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.',
@@ -3246,11 +3247,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse phase-opening Amal with native payment order, printed Thumper with known-encounter quotation, Karama shipment pricing and current-lot costs, original-window plays, native power declarations, printed Hajr/Ghola, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/window-card-play.ts', 'game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse phase-opening Amal with native payment order, printed Thumper with known-encounter quotation, Karama shipment pricing and current-lot costs, original-window plays, native power declarations, printed Hajr/Ghola/Box/Distrans/Sapho and completed Semuta exchanges, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/window-card-play.ts', 'game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
{ 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: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed roles, exact fresh Semuta controls 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-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'] },
{ 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-semuta-play.ts', 'tests/truth-semuta-play-runtime.test.ts', 'tests/semuta-reaction-controls.test.tsx', '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'] },
],
},
{
+1
View File
@@ -756,6 +756,7 @@ export function truthQuestionText(
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';
if (action.effect === 'semutaDrug') return 'you complete a printed Semuta Drug exchange and discard Semuta Drug 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`;
}
+2 -1
View File
@@ -698,7 +698,8 @@ export function closeTurnActionBattlePower<T extends TurnActionPromise>(promises
}
/** Accepted printed-role play only. Native callers separately own effect
* success, disposal, recovery and real card custody; none count as this event. */
* success, disposal, recovery and real card custody; none alone count as this
* event. Semuta requires its physical exchange, not commitment or inspection. */
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 supported printed-card play.');
+3
View File
@@ -66,6 +66,9 @@ void test('offer never inspects an uncommitted candidate or rival custody; unabl
reaction.blocked = null;
reaction.canCommit = false;
assert.deepEqual(botSemutaActions(v), [{ type: 'semutaPass', event }]);
reaction.passBlocked = 'Retain the required whole-answer exchange.';
assert.deepEqual(botSemutaActions(v), []);
delete reaction.passBlocked;
reaction.passed = true;
assert.deepEqual(botSemutaActions(v), []);
v.semutaReaction = null;
+214
View File
@@ -0,0 +1,214 @@
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 { semutaBattleKaramaFixture } from './fixture-semuta-battle-karama';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import {
actTruthTransferOrderPlay, createTruthSemutaPrebidFixture, nextTruthTransferOrderPlayNative,
truthTransferOrderPlayInventory, truthTransferOrderPlayPlayer, truthTransferOrderPlaySpice,
truthTransferOrderPlayTraitors,
} from './fixture-truth-transfer-order-play';
export type TruthSemutaPlayLeaf = { kind: 'cardPlay'; effect: 'semutaDrug' } |
{ kind: 'payment'; recipient: string; minimum: number };
export type TruthSemutaPlayExpression = MixedActionExpression<TruthSemutaPlayLeaf>;
export type TruthSemutaPlayStep = { actor: string; action: Action };
export const actTruthSemutaPlay = actTruthTransferOrderPlay;
export const truthSemutaPlayPlayer = truthTransferOrderPlayPlayer;
export const truthSemutaPlayInventory = truthTransferOrderPlayInventory;
export const truthSemutaPlaySpice = truthTransferOrderPlaySpice;
export const truthSemutaPlayTraitors = truthTransferOrderPlayTraitors;
export const truthSemutaPlayClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingNullentropy && !game.pendingShipment && !game.currentAuctionSale;
export const truthSemutaPlayUse = (): TruthSemutaPlayExpression => ({ kind: 'action', action: { kind: 'cardPlay', effect: 'semutaDrug' } });
export const truthSemutaPlayFact = (fact: TruthFact): TruthSemutaPlayExpression => ({ kind: 'fact', fact });
export const truthSemutaPlayPayment = (recipient: string, minimum = 1): TruthSemutaPlayExpression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
export const truthSemutaPlayGroup = (kind: 'and' | 'or', ...terms: TruthSemutaPlayExpression[]): TruthSemutaPlayExpression => ({ kind, terms });
export type TruthSemutaPlaySource = {
game: Game; source: Game; sourceKind: 'nativeSingleton' | 'controlledMulti'; nativeAcquisition: boolean;
target: string; asker: string; recipient: string; truths: string[]; truthHolders: string[]; truth: string;
semuta: string; card: string; effect: 'semutaDrug'; spiceFact: TruthFact; mixed: TruthSemutaPlayExpression; question: TruthQuestion;
setup: Game; originalCards: string[]; originalSpice: string[]; originalTraitors: string[];
actions: TruthSemutaPlayStep[]; sourceActions: TruthSemutaPlayStep[]; sourceActionCount: number;
sourceCounts: Record<string, number | null>; sourceDisclosure: string;
};
/** Native neutral controls never volunteer the promised Semuta action. The
* caller explicitly uses its guide, passes a permitted offer or selects. */
export function nextTruthSemutaPlayNative(game: Game): TruthSemutaPlayStep | null {
if (game.truthtrance || game.pendingNullentropy || game.pendingTreacheryDiscard?.reaction?.stage === 'select') return null;
return game.players.some(player => player.id === 'g') ? nextTruthTransferOrderPlayNative(game) : nextRicheseStrongholdsNativeStep(game);
}
export function advanceTruthSemutaPlay(game: Game, until: (state: Game) => boolean, actions: TruthSemutaPlayStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextTruthSemutaPlayNative(game); assert.ok(step, 'Supply the actual Truth answer or committed private Semuta choice');
game = actTruthSemutaPlay(game, step.actor, step.action, actions);
}
throw Error('The finite native Semuta continuation did not reach its boundary');
}
export function passTruthSemutaOffer(game: Game, actions: TruthSemutaPlayStep[] = []): Game {
const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.reaction?.stage === 'offer');
const event = pending.batch.event;
return advanceTruthSemutaPlay(game, state => state.pendingTreacheryDiscard?.batch.event !== event, actions);
}
export function settleTruthSemutaPlay(game: Game, actions: TruthSemutaPlayStep[] = []): Game {
return advanceTruthSemutaPlay(game, truthSemutaPlayClean, actions);
}
export function finishTruthSemutaPlay(game: Game, actions: TruthSemutaPlayStep[] = []): Game {
const turn = game.turn;
return advanceTruthSemutaPlay(game, state => state.turn === turn + 1, actions);
}
/** Actual turn1 Distrans use and turn2 cache Semuta1 acquisition. A later
* physical Truth answer supplies its original foreign singleton discard. */
export function createTruthSemutaPlayFixture(options: { firstBid?: boolean } = {}) {
const base = createTruthSemutaPrebidFixture(options), game = structuredClone(base.game), actions = structuredClone(base.actions);
const effect = 'semutaDrug' as const, mixed = truthSemutaPlayGroup('and', truthSemutaPlayUse(),
truthSemutaPlayPayment(base.recipient, viewGame(game, base.target).bribeOptions.available));
const question: TruthQuestion = { kind: 'turnActions', target: base.target, turn: game.turn, mixed };
const source = { ...base, game, source: structuredClone(game), sourceKind: 'nativeSingleton' as const, nativeAcquisition: true,
effect, card: base.semuta, mixed, question, base,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceDisclosure: base.sourceDisclosure + ' This adapter asks only about a future completed printed Semuta use after the actual answer. ' +
'Committing a multi-card search alone is not completion; no selected-card predicate or hypothetical future discard is added. ' +
'The current declared foreign Truth supplies the genuine fresh singleton. All source counts derive from actual arrays.',
};
return source satisfies TruthSemutaPlaySource;
}
/** Explicit unit supplement: inherit the existing bare Advanced conserved
* battle setup, then conserve only original hands before the real question.
* Acquisition is NOT native evidence. No fresh batch/event is installed. */
export function createTruthSemutaMultiFixture() {
const inherited = semutaBattleKaramaFixture('capture'), game = structuredClone(inherited.staged);
const originalCards = truthSemutaPlayInventory(game), originalSpice = truthSemutaPlaySpice(game), originalTraitors = truthSemutaPlayTraitors(game);
const target = inherited.holder, asker = inherited.winner, recipient = asker, opponent = inherited.loser;
for (const actor of [asker, opponent]) game.deck.push(...truthSemutaPlayPlayer(game, actor).hand.splice(0));
const move = (id: string, holder: string) => {
for (const zone of [game.deck, game.discard, ...(game.richeseCache ? [game.richeseCache] : []), ...game.players.map(player => player.hand)]) {
const at = zone.findIndex(card => card.id === id);
if (at >= 0) { truthSemutaPlayPlayer(game, holder).hand.push(zone.splice(at, 1)[0]); return id; }
}
throw Error('Conserve the original physical card for this explicitly controlled supplement');
};
const truths = game.deck.filter(card => card.effect === 'truthtrance').map(card => card.id); assert.equal(truths.length, 2);
move(truths[0], asker); move(truths[1], opponent);
const weapons = game.deck.filter(card => card.kind === 'projectile').slice(0, 2).map(card => card.id);
const defenses = game.deck.filter(card => card.kind === 'shield').slice(0, 2).map(card => card.id);
assert.equal(weapons.length, 2); assert.equal(defenses.length, 2);
const winnerCards = [move(weapons[0], asker), move(defenses[0], asker)], loserCards = [move(weapons[1], opponent), move(defenses[1], opponent)];
assert.ok(!game.battle && !game.pendingTreacheryDiscard && truthSemutaPlayClean(game));
assert.ok(truthSemutaPlayPlayer(game, target).hand.some(card => card.id === inherited.semuta));
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards);
assert.deepEqual(truthSemutaPlayInventory(game), originalCards);
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthSemutaPlayPlayer(game, target).spice };
const mixed = truthSemutaPlayGroup('or', truthSemutaPlayUse(), truthSemutaPlayPayment(recipient));
const question: TruthQuestion = { kind: 'turnActions', target, turn: game.turn, mixed };
const actions: TruthSemutaPlayStep[] = [];
const source = { game, source: structuredClone(game), sourceKind: 'controlledMulti' as const, nativeAcquisition: false,
target, asker, recipient, truth: truths[0], truths, truthHolders: [asker, opponent], semuta: inherited.semuta,
card: inherited.semuta, effect: 'semutaDrug' as const, spiceFact, mixed, question,
setup: structuredClone(inherited.staged), originalCards, originalSpice, originalTraitors,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { selected: actions.length, inheritedRecordedActions: null },
producer: { actor: game.active!, opponent, territory: 'arrakeen', winner: asker, loser: opponent, winnerCards, loserCards },
sourceDisclosure: 'CONTROLLED CONSERVED UNIT SUPPLEMENT, not native acquisition/history evidence. ' +
'Inherit semutaBattleKaramaFixture(capture).staged, whose hands, wallets, armies, phase and turn were explicitly positioned by that older unit fixture. ' +
'Conserve its original hands into separate projectile/Shield pairs and two original foreign Truths. ' +
'The actual Truth declaration/answer, permitted first-offer passes, chooseBattle, plans, Traitor declines and original outcome acknowledgements later generate a genuine two-card loser-disposal event. ' +
'No live battle, fresh discard batch, event, selection, receipt or promise is installed. The inherited fixture records no action array; its action count is explicitly unavailable. Only new actual controls are counted.',
};
return source satisfies TruthSemutaPlaySource;
}
export type TruthSemutaMultiFixture = ReturnType<typeof createTruthSemutaMultiFixture>;
export function declareTruthSemutaPlay(f: TruthSemutaPlaySource, source = f.game, copy: 0 | 1 = 0, actions: TruthSemutaPlayStep[] = []) {
const asker = f.truthHolders[copy]; let game = actTruthSemutaPlay(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 = actTruthSemutaPlay(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask'); return { game, asker, actions };
}
export function askTruthSemutaPlay(game: Game, asker: string, question: TruthQuestion, actions: TruthSemutaPlayStep[] = []): Game {
return actTruthSemutaPlay(game, asker, { type: 'truthAsk', question: structuredClone(question) }, actions);
}
export function answerTruthSemutaPlay(game: Game, target: string, answer: 'yes' | 'no', actions: TruthSemutaPlayStep[] = []): Game {
return actTruthSemutaPlay(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthSemutaPlay(f: TruthSemutaPlaySource, answer: 'yes' | 'no' = 'yes', question: TruthQuestion = f.question,
source = f.game, copy: 0 | 1 = 0, actions: TruthSemutaPlayStep[] = []) {
const declared = declareTruthSemutaPlay(f, source, copy, actions), asked = askTruthSemutaPlay(declared.game, declared.asker, question, actions);
return { game: answerTruthSemutaPlay(asked, f.target, answer, actions), declared, asked, actions };
}
export function truthSemutaPlayPromise(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 openTruthSemutaMultiBatch(f: TruthSemutaMultiFixture, source: Game, actions: TruthSemutaPlayStep[] = []): Game {
let game = source;
if (game.pendingTreacheryDiscard) game = passTruthSemutaOffer(game, actions);
assert.ok(truthSemutaPlayClean(game));
game = actTruthSemutaPlay(game, f.producer.actor, { type: 'chooseBattle', territory: f.producer.territory,
target: f.producer.actor === f.producer.winner ? f.producer.loser : f.producer.winner }, actions);
game = advanceTruthSemutaPlay(game, state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && truthSemutaPlayClean(state), actions);
const strongest = truthSemutaPlayPlayer(game, f.producer.winner).leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => b.strength - a.strength)[0];
const weakest = truthSemutaPlayPlayer(game, f.producer.loser).leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => a.strength - b.strength)[0];
assert.ok(strongest && weakest && strongest.strength + 1 > weakest.strength);
game = actTruthSemutaPlay(game, f.producer.winner, { type: 'battlePlan', dial: 1, support: 1, leader: strongest.id,
weapon: f.producer.winnerCards[0], defense: f.producer.winnerCards[1] }, actions);
game = actTruthSemutaPlay(game, f.producer.loser, { type: 'battlePlan', dial: 0, support: 0, leader: weakest.id,
weapon: f.producer.loserCards[0], defense: f.producer.loserCards[1] }, actions);
game = advanceTruthSemutaPlay(game, state => !!state.pendingTreacheryDiscard && state.pendingTreacheryDiscard.batch.entries.length >= 2, actions);
assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id).sort(), [...f.producer.loserCards].sort());
assert.equal(game.pendingTreacheryDiscard!.reaction?.stage, 'offer'); return game;
}
export function commitTruthSemutaPlay(game: Game, target: string, actions: TruthSemutaPlayStep[] = []): Game {
const offer = game.pendingTreacheryDiscard; assert.ok(offer && offer.reaction?.stage === 'offer');
return actTruthSemutaPlay(game, target, { type: 'semutaCommit', event: offer.batch.event }, actions);
}
export function selectTruthSemutaPlay(game: Game, target: string, selected: string, actions: TruthSemutaPlayStep[] = []): Game {
const offer = game.pendingTreacheryDiscard; assert.ok(offer && offer.reaction?.stage === 'select');
return actTruthSemutaPlay(game, target, { type: 'semutaSelect', event: offer.batch.event, card: selected }, actions);
}
/** One actual recovery producer, rather than installed recovered custody.
* Basic Guild retains its original3 after free Semuta use, then spends the
* next original Box cache1 and printed search2 before choosing Semuta. */
export function recoverTruthSemutaWithNativeBox(f: TruthSemutaPlaySource, source: Game, actions: TruthSemutaPlayStep[] = []) {
assert.equal(f.sourceKind, 'nativeSingleton'); assert.equal(source.advanced, false);
assert.equal(source.discard.filter(card => card.id === f.semuta).length, 1);
let game = source; const turn = game.turn;
game = advanceTruthSemutaPlay(game, state => state.turn === turn + 1 && !state.phaseOpening && !state.response &&
state.decision?.kind === 'richeseCache', actions);
const beforePurchase = structuredClone(game), box = game.richeseCache?.find(card => card.effect === 'nullentropyBox'); assert.ok(box);
const from = actions.length;
game = actTruthSemutaPlay(game, game.decision!.player, { type: 'decision', event: game.richeseBidding!.event,
card: box.id, method: 'onceAround', direction: 'counterclockwise' }, actions);
for (let count = 0; count < 200 && !(truthSemutaPlayPlayer(game, f.target).hand.some(card => card.id === box.id) &&
truthSemutaPlayClean(game) && !game.richeseAuction && !!game.auction && game.auction.bid === 0 && game.auction.passed.length === 0); count++) {
const lot = game.richeseAuction;
const step = truthSemutaPlayClean(game) && lot?.cardId === box.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 }
: nextTruthSemutaPlayNative(game);
assert.ok(step); game = actTruthSemutaPlay(game, step.actor, step.action, actions);
}
assert.ok(truthSemutaPlayPlayer(game, f.target).hand.some(card => card.id === box.id));
assert.equal(truthSemutaPlayPlayer(beforePurchase, f.target).spice - truthSemutaPlayPlayer(game, f.target).spice, 1);
const purchase = { before: beforePurchase, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
const beforeRecovery = structuredClone(game);
game = actTruthSemutaPlay(game, f.target, { type: 'card', card: box.id }, actions); const paid = structuredClone(game);
if (game.pendingNullentropy) {
const search = viewGame(game, f.target).nullentropy?.search; assert.ok(search && search.cards.some(card => card.id === f.semuta));
game = actTruthSemutaPlay(game, f.target, { type: 'decision', event: search.event, card: f.semuta }, actions);
}
game = settleTruthSemutaPlay(game, actions);
assert.ok(truthSemutaPlayPlayer(game, f.target).hand.some(card => card.id === f.semuta));
assert.equal(truthSemutaPlayPlayer(beforeRecovery, f.target).spice - truthSemutaPlayPlayer(game, f.target).spice, 2);
assert.deepEqual(truthSemutaPlayInventory(game), f.originalCards);
return { game, box: box.id, purchase, beforeRecovery, paid, afterRecovery: structuredClone(game), actions };
}
+19 -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', 'distrans', 'juiceOfSapho']);
assert.deepEqual(PRINTED_CARD_EFFECTS, ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug']);
assert.equal(printedCardPlayCount(undefined, 'richese', 3, 'nullentropyBox'), 0);
const ghola = recordPrintedCardPlay(undefined, 'richese', 3, 'ghola');
const first = recordPrintedCardPlay(ghola, 'richese', 3, 'nullentropyBox');
@@ -58,6 +58,24 @@ void test('Distrans and Sapho count separate accepted printed uses and replay wi
{ turn: 3, plays: [{ player: 'richese', effect: 'distrans', count: 1 }] });
});
void test('Semuta counts actual physical resolutions separately from other roles without accepting commitment or selected-card metadata', () => {
const original = recordPrintedCardPlay(undefined, 'richese', 4, 'distrans');
assert.equal(printedCardPlayCount(original, 'richese', 4, 'semutaDrug'), 0);
const used = recordPrintedCardPlay(original, 'richese', 4, 'semutaDrug');
const replay = recordPrintedCardPlay(used, 'richese', 4, 'semutaDrug');
assert.equal(printedCardPlayCount(replay, 'richese', 4, 'semutaDrug'), 2);
assert.equal(printedCardPlayCount(replay, 'other', 4, 'semutaDrug'), 0);
assert.equal(printedCardPlayCount(replay, 'richese', 5, 'semutaDrug'), 0);
assert.deepEqual(original, { turn: 4, plays: [{ player: 'richese', effect: 'distrans', count: 1 }] });
for (const extra of [{ committed: true }, { stage: 'select' }, { selected: 'private-fresh-card' }, { event: 'discard:4:2:1' }]) {
const invalid = { turn: 4, plays: [{ player: 'richese', effect: 'semutaDrug', count: 1, ...extra }] } as PrintedCardPlayCounts;
assert.throws(() => printedCardPlayCount(invalid, 'richese', 4, 'semutaDrug'), PrintedCardPlayError);
}
const full: PrintedCardPlayCounts = { turn: 4, plays: [{ player: 'richese', effect: 'semutaDrug', count: Number.MAX_SAFE_INTEGER }] };
assert.throws(() => recordPrintedCardPlay(full, 'richese', 4, 'semutaDrug'), PrintedCardPlayError);
assert.equal(printedCardPlayCount(full, 'richese', 4, 'semutaDrug'), Number.MAX_SAFE_INTEGER);
});
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');
+4
View File
@@ -76,4 +76,8 @@ void test('passed, blocked, busy and waiting seats hide or disable unavailable c
assert.match(markup(offer(), true), /disabled=""[^>]*>Continue/);
assert.match(markup(offer({ stage: 'select', candidates: [first, second] }), true),
/disabled=""[^>]*>Claim Projectile/);
const required = markup(offer({ canCommit: true, passBlocked: 'Keep this required fresh exchange.' }));
assert.match(required, /Keep this required fresh exchange/);
assert.match(required, /disabled=""[^>]*>Continue/);
assert.doesNotMatch(required, /disabled=""[^>]*>Commit Semuta Drug/);
});
+17
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { viewGame, type Game } from '../game/engine';
import { createDistransPrebidFixture } from './fixture-distrans-prebid';
import {
askTruthTurnCombat as ask,
createTruthTurnCombatFixture as fixture,
@@ -80,3 +81,19 @@ void test('detached opaque weapon forecast retains an independent positive dial-
assert.equal(Object.hasOwn(asked, '_turnActionUnknownCards'), false);
physical(asked, f); physical(opaque, f); physical(f.game, f);
});
void test('detached opaque acquisition supplies generic Distrans transfer, but cannot activate Distrans itself', () => {
const f = createDistransPrebidFixture(), original = structuredClone(f.game);
const recipient = f.action.target as string, given = f.action.give as string;
assert.ok(viewGame(f.game, f.holder).distrans?.choices.some(choice =>
choice.recipient === recipient && choice.cards.some(card => card.id === given)));
const opaque = opaqueQuote(f.game, given), before = structuredClone(opaque);
assert.ok(viewGame(opaque, f.holder).distrans?.choices.some(choice =>
choice.recipient === recipient && choice.cards.some(card => card.id === given)),
'A generic other-card transfer does not require an unseen specific effect');
assert.deepEqual(opaque, before);
const unknownRole = opaqueQuote(f.game, f.card);
assert.match(viewGame(unknownRole, f.holder).distrans?.blocked ?? '', /unseen future acquired card/i);
assert.deepEqual(f.game, original);
assert.equal(Object.hasOwn(f.game, '_turnActionUnknownCards'), false);
});
+235
View File
@@ -0,0 +1,235 @@
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 { printedCardPlayCount } from '../game/printed-card-play';
import { richeseCards } from '../game/richese-cards';
import { truthFactAnswer, type TruthQuestion } from '../game/truthtrance';
import {
actTruthSemutaPlay as act, advanceTruthSemutaPlay as advance, answerTruthSemutaPlay as answer,
askTruthSemutaPlay as ask, bindTruthSemutaPlay as bind, commitTruthSemutaPlay as commit,
createTruthSemutaPlayFixture as fixture, createTruthSemutaMultiFixture as multiFixture,
declareTruthSemutaPlay as declare, finishTruthSemutaPlay as finish, nextTruthSemutaPlayNative as nativeStep,
openTruthSemutaMultiBatch as multiBatch, passTruthSemutaOffer as passOffer,
recoverTruthSemutaWithNativeBox as recover, selectTruthSemutaPlay as select, settleTruthSemutaPlay as settle,
truthSemutaPlayClean as clean, truthSemutaPlayFact as fact, truthSemutaPlayGroup as group,
truthSemutaPlayInventory as inventory, truthSemutaPlayPayment as payment, truthSemutaPlayPlayer as player,
truthSemutaPlayPromise as promise, truthSemutaPlaySpice as spice, truthSemutaPlayTraitors as traitors,
truthSemutaPlayUse as use, type TruthSemutaPlayExpression, type TruthSemutaPlaySource, type TruthSemutaPlayStep,
} from './fixture-truth-semuta-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: TruthSemutaPlaySource) {
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(spice(game), f.originalSpice);
assert.deepEqual(traitors(game), f.originalTraitors);
}
const raw = (f: TruthSemutaPlaySource, mixed: TruthSemutaPlayExpression, turn = f.game.turn): TruthQuestion =>
({ kind: 'turnActions', target: f.target, turn, mixed });
const stage = (game: Game, index = 0) => game.turnActionPromises![index].cardPlays?.semutaDrug;
const count = (game: Game, f: TruthSemutaPlaySource) => printedCardPlayCount(game.printedCardPlays, f.target, game.turn, 'semutaDrug');
const pay = (game: Game, f: TruthSemutaPlaySource, amount: number, actions: TruthSemutaPlayStep[] = []) =>
act(game, f.target, { type: 'bribe', target: f.recipient, amount }, actions);
void test('Semuta sources distinguish genuine cache acquisition from the explicitly conserved multi-card producer supplement', () => {
for (const firstBid of [false, true]) {
const f = fixture({ firstBid }); assert.equal(f.nativeAcquisition, true); assert.equal(f.sourceKind, 'nativeSingleton');
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.game.auction!.bid, firstBid ? 1 : 0); assert.equal(f.game.semutaPreview, true);
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(player(f.game, f.target).spice, 3);
for (const [index, truth] of f.truths.entries()) assert.ok(player(f.game, f.truthHolders[index]).hand.some(card => card.id === truth));
assert.equal(count(f.game, f), 0); assert.equal(f.game.turnActionPromises?.length ?? 0, 0); physical(f.game, f);
}
const supplement = multiFixture(); assert.equal(supplement.nativeAcquisition, false); assert.equal(supplement.sourceKind, 'controlledMulti');
assert.match(supplement.sourceDisclosure, /CONTROLLED CONSERVED UNIT SUPPLEMENT/);
assert.equal(supplement.sourceCounts.inheritedRecordedActions, null); assert.equal(supplement.sourceActionCount, supplement.actions.length);
assert.ok(!supplement.game.battle && !supplement.game.pendingTreacheryDiscard);
assert.ok(!supplement.game.homeworlds && !supplement.game.nexusCards && !supplement.game.leaderSkills);
physical(supplement.game, supplement);
});
void test('genuine Semuta AND frozen fact and current-budget payment uses the actual foreign Truth cost for free before paying', () => {
for (const firstBid of [false, true]) {
const f = fixture({ firstBid }), cash = player(f.game, f.target).spice,
budget = viewGame(f.game, f.target).bribeOptions.available;
assert.equal(budget, cash - Number(firstBid));
if (firstBid) {
const declared = declare(f), unfunded = ask(declared.game, declared.asker,
raw(f, group('and', fact(f.spiceFact), use(), payment(f.recipient, cash))));
assert.deepEqual(viewGame(unfunded, f.target).truthTurnActionAnswers, ['no']);
rejected(unfunded, f.target, { type: 'truthAnswer', answer: 'yes' });
}
const mixed = group('and', fact(f.spiceFact), use(), payment(f.recipient, budget));
let game = bind(f, 'yes', raw(f, mixed)).game;
const event = game.pendingTreacheryDiscard!.batch.event, lot = structuredClone(game.auction);
assert.equal(game.pendingTreacheryDiscard!.batch.entries[0].card.id, f.truth);
assert.equal(stage(game)?.played, false); assert.equal(count(game, f), 0); rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: budget });
game = commit(game, f.target); assert.ok(!game.pendingTreacheryDiscard);
assert.equal(player(game, f.target).spice, cash); assert.equal(count(game, f), 1); assert.equal(stage(game)?.played, true);
assert.ok(player(game, f.target).hand.some(card => card.id === f.truth)); assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'yes'); assert.deepEqual(game.auction, lot);
rejected(game, f.target, { type: 'semutaCommit', event }); game = pay(game, f, budget);
assert.equal(player(game, f.target).spice, cash - budget); assert.equal(player(game, f.recipient).bribes, budget);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(promise(game).mixed, mixed); assert.equal(count(game, f), 1); physical(game, f);
}
});
void test('required Yes cannot voluntarily Pass its only known Semuta trigger while foreign neutral passes retain their native rights', () => {
const f = fixture(); let game = bind(f, 'yes', raw(f, use())).game;
const event = game.pendingTreacheryDiscard!.batch.event;
rejected(game, f.target, { type: 'semutaPass', event });
assert.ok(viewGame(game, f.target).semutaReaction?.passBlocked);
for (const actor of game.players.filter(candidate => candidate.id !== f.target).map(candidate => candidate.id))
game = act(game, actor, { type: 'semutaPass', event });
assert.equal(game.pendingTreacheryDiscard?.batch.event, event); assert.equal(stage(game)?.played, false);
rejected(game, f.target, { type: 'semutaPass', event });
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'semutaCommit');
game = act(game, f.target, guide.action); assert.equal(promise(game).fulfilled, true); assert.equal(count(game, f), 1);
assert.equal(player(game, f.target).spice, 3); physical(game, f);
});
void test('whole OR permits passing Semuta and retaining the physical card for a later actual Mentat payment route', () => {
const f = fixture(), budget = player(f.game, f.target).spice;
let game = bind(f, 'yes', raw(f, group('or', use(), payment(f.recipient, budget)))).game;
const event = game.pendingTreacheryDiscard!.batch.event;
game = act(game, f.target, { type: 'semutaPass', event }); game = passOffer(game);
assert.equal(stage(game)?.played, false); assert.equal(count(game, f), 0);
assert.ok(player(game, f.target).hand.some(card => card.id === f.semuta));
game = advance(game, state => state.phase === 8 && clean(state));
assert.ok(!promise(game).fulfilled && !promise(game).released); game = pay(game, f, budget);
assert.equal(promise(game).fulfilled, true); assert.equal(stage(game)?.played, false); assert.equal(count(game, f), 0);
assert.ok(player(game, f.target).hand.some(card => card.id === f.semuta)); physical(game, f);
});
void test('whole OR No blocks Semuta commitment before inspection and retains its cumulative payment cap through the turn', () => {
const f = fixture(); let game = bind(f, 'no', raw(f, group('or', use(), payment(f.recipient, 2)))).game;
const reaction = viewGame(game, f.target).semutaReaction; assert.ok(reaction);
assert.equal(reaction.canCommit, false); assert.deepEqual(reaction.candidates, []);
rejected(game, f.target, { type: 'semutaCommit', event: reaction.event }); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
game = passOffer(game); game = pay(game, f, 1);
game = advance(game, state => state.phase === 8 && clean(state));
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)?.played, false); assert.equal(stage(game)?.closed, true);
assert.ok(player(game, f.target).hand.some(card => card.id === f.semuta)); physical(game, f);
});
void test('whole AND No permits the real Semuta exchange but forbids the later payment that would complete both terms', () => {
const f = fixture(); let game = bind(f, 'no', raw(f, group('and', use(), payment(f.recipient, 2)))).game;
game = commit(game, f.target); assert.equal(count(game, f), 1); assert.equal(stage(game)?.played, true);
game = pay(game, f, 1); 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)?.played, true); assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); physical(game, f);
});
void test('current offer guide packets retain exact native event and phase through JSON and reject stale owner or preselection', () => {
const f = fixture({ firstBid: true }); let game = bind(f, 'yes', raw(f, use())).game;
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'semutaCommit'); assert.equal(guide.event, promise(game).event);
assert.equal(guide.action.event, game.pendingTreacheryDiscard!.batch.event);
assert.equal(guide.action.turnActionPromiseEvent, promise(game).event); assert.equal(guide.action.turnActionPromisePhase, 3);
rejected(game, f.asker, guide.action); rejected(game, f.target, { ...guide.action, event: 'stale-fresh-semuta-source' });
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-whole-answer' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 4 }); rejected(game, f.target, { ...guide.action, card: f.truth });
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, packet);
assert.equal(promise(game).fulfilled, true); assert.equal(count(game, f), 1); rejected(game, f.target, packet); physical(game, f);
});
void test('controlled native two-card batches count neither commitment nor inspection, and either completed choice or its exact selection guide fulfills', () => {
const f = multiFixture();
for (const route of ['first', 'second', 'guide'] as const) {
const actions: TruthSemutaPlayStep[] = []; let game = bind(f, 'yes', f.question, f.game, 0, actions).game;
game = multiBatch(f, game, actions); const event = game.pendingTreacheryDiscard!.batch.event;
assert.deepEqual(viewGame(game, f.target).semutaReaction?.candidates, []); assert.equal(count(game, f), 0);
game = commit(game, f.target, actions); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'select');
assert.equal(count(game, f), 0); assert.equal(stage(game)?.played, false); assert.ok(!promise(game).fulfilled);
assert.ok(player(game, f.target).hand.some(card => card.id === f.semuta)); assert.equal(game.discard.filter(card => card.id === f.semuta).length, 0);
const candidates = viewGame(game, f.target).semutaReaction!.candidates; assert.equal(candidates.length, 2);
assert.deepEqual(candidates.map(card => card.id).sort(), [...f.producer.loserCards].sort());
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'semutaSelect');
assert.equal(guide.action.event, event); assert.equal(guide.action.turnActionPromiseEvent, promise(game).event);
assert.equal(guide.action.turnActionPromisePhase, 6); assert.ok(candidates.some(card => card.id === guide.action.card));
rejected(game, f.target, { type: 'semutaPass', event }); rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
rejected(game, f.asker, guide.action); rejected(game, f.target, { ...guide.action, event: 'stale-multi-source' });
rejected(game, f.target, { type: 'semutaSelect', event, card: f.truth });
const chosen = route === 'guide' ? String(guide.action.card) : candidates[route === 'first' ? 0 : 1].id;
game = route === 'guide' ? act(game, f.target, JSON.parse(JSON.stringify(guide.action)) as Action, actions)
: select(game, f.target, chosen, actions);
assert.equal(count(game, f), 1); assert.equal(stage(game)?.played, true); assert.equal(promise(game).fulfilled, true);
assert.ok(player(game, f.target).hand.some(card => card.id === chosen)); assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
const receipt = structuredClone(stage(game)); game = settle(game, actions); assert.deepEqual(stage(game), receipt);
assert.equal(count(game, f), 1); assert.equal(f.nativeAcquisition, false); assert.ok(actions.some(step => step.action.type === 'chooseBattle'));
assert.ok(actions.filter(step => step.action.type === 'battlePlan').length === 2); physical(game, f);
}
});
void test('a controlled native multi-card offer still rejects whole No commitment before exposing either fresh choice', () => {
const f = multiFixture(); let game = bind(f, 'no', raw(f, group('or', use(), payment(f.recipient, 2)))).game;
game = multiBatch(f, game); const event = game.pendingTreacheryDiscard!.batch.event;
const view = viewGame(game, f.target); assert.equal(view.semutaReaction?.canCommit, false); assert.deepEqual(view.semutaReaction?.candidates, []);
rejected(game, f.target, { type: 'semutaCommit', event }); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(count(game, f), 0); assert.equal(stage(game)?.played, false);
game = settle(passOffer(game)); assert.ok(player(game, f.target).hand.some(card => card.id === f.semuta)); physical(game, f);
});
void test('an earlier completed physical Semuta use cannot satisfy a second original Truth future-play interval', () => {
const f = fixture(); let game = bind(f, 'yes', raw(f, use())).game; game = commit(game, f.target);
assert.equal(promise(game).fulfilled, true); assert.equal(count(game, f), 1);
const prior = structuredClone(viewGame(game, f.target).turnActionPromises[0]), declared = declare(f, game, 1);
const asked = ask(declared.game, declared.asker, raw(f, use())); assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); game = settle(answer(asked, f.target, 'no'));
assert.equal(stage(game, 1)?.played, false); assert.equal(count(game, f), 1);
assert.deepEqual(viewGame(game, f.target).turnActionPromises[0], prior); physical(game, f);
});
void test('real paid Box recovery of the original Semuta is not another play, while a later original Truth cost permits genuine replay', () => {
const f = fixture(); let game = bind(f, 'yes', raw(f, use())).game; game = commit(game, f.target);
assert.equal(player(game, f.target).spice, 3); assert.equal(count(game, f), 1);
const actions: TruthSemutaPlayStep[] = [], recovered = recover(f, game, actions); game = recovered.game;
assert.equal(game.turn, f.game.turn + 1); assert.equal(player(game, f.target).spice, 0); assert.equal(count(game, f), 0);
assert.ok(recovered.purchase.steps.some(step => step.actor === f.target && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.ok(recovered.paid.pendingNullentropy); assert.ok(actions.some(step => step.action.type === 'decision' && step.action.card === f.semuta));
assert.ok(player(game, f.target).hand.some(card => card.id === f.semuta));
const question = raw(f, use(), game.turn); game = bind(f, 'yes', question, game, 1).game;
assert.equal(stage(game, 1)?.played, false); assert.equal(count(game, f), 0);
game = commit(game, f.target); assert.equal(stage(game, 1)?.played, true); assert.equal(promise(game).fulfilled, true);
assert.equal(count(game, f), 1); assert.equal(player(game, f.target).spice, 0);
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); physical(game, f);
});
void test('all four minimal profiles execute native Semuta offer and committed selection guides through physical completion', () => {
const f = fixture(), declared = declare(f), asked = ask(declared.game, declared.asker, f.question);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthSemutaPlayStep[] = [];
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); assert.equal(response.answer, 'yes');
game = act(game, f.target, response, actions);
for (let step = 0; step < 80 && (!promise(game).fulfilled || !clean(game)); step++) {
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
if (view.turnActionGuide) {
const guide = view.turnActionGuide, action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.action.turnActionPromiseEvent);
assert.ok(action); assert.deepEqual(action, guide.action); game = act(game, f.target, action, actions);
} else {
const next = nativeStep(game); assert.ok(next); game = act(game, next.actor, next.action, actions);
}
}
assert.equal(promise(game).fulfilled, true); assert.ok(clean(game)); assert.equal(count(game, f), 1);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 3); physical(game, f);
}
const supplement = multiFixture();
for (const difficulty of DIFFICULTIES) {
let game = bind(supplement).game; game = multiBatch(supplement, game); game = commit(game, supplement.target);
assert.equal(count(game, supplement), 0);
const view = viewGame(game, supplement.target); view.players.find(candidate => candidate.id === supplement.target)!.bot = difficulty;
const guide = view.turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'semutaSelect');
const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.action.turnActionPromiseEvent);
assert.ok(action); assert.deepEqual(action, guide.action); game = settle(act(game, supplement.target, action));
assert.equal(promise(game).fulfilled, true); assert.equal(count(game, supplement), 1); physical(game, supplement);
}
});
+116 -6
View File
@@ -32,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' | 'distrans' | 'juiceOfSapho' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
const printedCard = (effect: 'hajr' | 'ghola' | 'nullentropyBox' | 'distrans' | 'juiceOfSapho' | 'semutaDrug' = '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'
@@ -169,6 +169,18 @@ function saphoPlay(phase = 5, option: NonNullable<GameView['saphoOptions']>[numb
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard('juiceOfSapho'), payment()] };
return game;
}
function semutaPlay(stage: 'offer' | 'select' = 'offer', next?: Action): GameView {
const card = richeseCards().find(card => card.effect === 'semutaDrug'), candidate = baseDeck().find(card => card.name === 'Shield');
assert.ok(card); assert.ok(candidate);
const reactionEvent = 'controlled-fresh-discard';
const game = projected(3, next ?? (stage === 'offer' ? { type: 'semutaCommit', event: reactionEvent }
: { type: 'semutaSelect', event: reactionEvent, card: candidate.id }));
game.players.find(player => player.id === 'p')!.hand = [card]; game.active = 'a';
game.semutaReaction = { event: reactionEvent, stage, passed: stage === 'select', canCommit: stage === 'offer', blocked: null,
candidates: stage === 'select' ? [candidate] : [] };
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard('semutaDrug'), 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';
@@ -1283,15 +1295,16 @@ void test('turn-action printed-role editor uses public names without holdings or
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, 'semutaReaction', { get() { throw Error('No private Semuta 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', 'distrans', 'juiceOfSapho']);
assert.deepEqual(choices.map(choice => choice.props.value), ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug']);
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) {
for (const effect of ['distrans', 'juiceOfSapho', 'semutaDrug'] 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'));
@@ -1309,17 +1322,19 @@ void test('turn-action printed-role editor uses public names without holdings or
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, /Complete a printed Semuta Drug transfer and disposal after this answer this turn/);
assert.match(html, /Committing to inspect multiple candidates, an earlier play or recovery alone does not count/);
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', count % 2 ? 'distrans' : 'juiceOfSapho');
select(full.tree(), `1.${count}`, 'effect', count % 3 === 0 ? 'semutaDrug' : 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);
const deep = editor({ kind: 'and', terms: [fact(), printedCard('nullentropyBox')] }, false, game);
const deep = editor({ kind: 'and', terms: [fact(), printedCard('semutaDrug')] }, false, game);
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
const frozen = editor(printedCard(), true, game); select(frozen.tree(), '1', 'effect', 'ghola');
@@ -1565,9 +1580,102 @@ void test('printed Sapho callbacks and both role guides reject stale quotes or m
}
});
void test('Semuta offer guides forward exact native commit or pass without reading candidates or holdings', () => {
for (const type of ['semutaCommit', 'semutaPass']) {
const game = semutaPlay('offer', { type, event: 'controlled-fresh-discard' }), sent: Action[] = [];
if (type === 'semutaPass') {
game.semutaReaction!.canCommit = false; game.semutaReaction!.blocked = 'The physical card is unavailable.';
game.turnActionPromises[0].answer = false;
}
Object.defineProperty(game.semutaReaction!, 'candidates', { get() { throw Error('Offer guides cannot inspect private candidates'); } });
for (const player of game.players)
Object.defineProperty(player, 'hand', { get() { throw Error('The native Semuta quote already owns custody'); }, configurable: true });
Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Semuta receipt inspection'); } });
game.decision = { kind: 'auctionPayment', player: 'a' };
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, event: 'controlled-fresh-discard', turnActionPromiseEvent: event, turnActionPromisePhase: 3 });
assert.match(markup(game), type === 'semutaCommit' ? /Commit Semuta Drug to this fresh discard/ : /Continue without committing Semuta Drug/);
}
});
void test('Semuta selection guides use only the actual current private candidate and cannot pass or commit again', () => {
const game = semutaPlay('select'), sent: Action[] = [];
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.match(markup(game), /Complete Semuta Drug by claiming Shield/);
assert.match(markup(game), /Committing to inspect multiple candidates, an earlier play or recovery alone does not count/);
for (const type of ['semutaPass', 'semutaCommit']) {
const choosing = semutaPlay('select', { type, event: 'controlled-fresh-discard' });
const disabled = guide({ game: choosing, busy: false, act() { assert.fail('A committed private choice cannot be canceled'); } });
assert.equal(disabled.props.disabled, true); press(disabled);
}
for (const change of [
(state: GameView) => { state.semutaReaction!.candidates = []; },
(state: GameView) => { state.turnActionGuide!.action.card = 'foreign-or-unquoted-card'; },
]) {
const choosing = semutaPlay('select'); change(choosing);
const disabled = guide({ game: choosing, busy: false, act() { assert.fail('Only the actual selector receives a legal current candidate'); } });
assert.equal(disabled.props.disabled, true); press(disabled);
}
});
void test('Semuta whole guides reject stale native stages, events, ownership, choices and parent modals', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.semutaReaction = null; },
game => { game.semutaReaction = { ...game.semutaReaction! }; },
game => { game.semutaReaction!.event = 'next-fresh-discard'; },
game => { game.semutaReaction!.stage = 'select'; },
game => { game.semutaReaction!.passed = true; },
game => { game.semutaReaction!.canCommit = false; },
game => { game.semutaReaction!.blocked = 'The physical card became reserved.'; },
game => { game.semutaReaction!.passBlocked = 'The original whole answer now requires this exchange.'; },
game => { game.turnActionPromises[0].mixed = payment(); },
game => { game.me = 'a'; },
game => { game.phase = 4; },
game => { game.turnActionGuide!.action.event = 'old-offer-event'; },
game => { game.decision = { kind: 'auctionPayment', player: 'a' }; },
];
for (const change of changes) {
const game = semutaPlay(), 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.semutaReaction!.candidates = []; },
(game: GameView) => { game.semutaReaction!.candidates[0].kind = 'worthless'; },
]) {
const choosing = semutaPlay('select'), sent: Action[] = [], saved = guide({ game: choosing, busy: false, act(action) { sent.push(action); } });
assert.equal(saved.props.disabled, false); change(choosing); press(saved); assert.deepEqual(sent, []);
}
const props = { game: semutaPlay(), busy: false, act() { assert.fail('Busy Semuta callbacks cannot send'); } }, busy = guide(props);
assert.equal(busy.props.disabled, false); props.busy = true; press(busy);
for (const field of ['paused', 'closed'] as const) {
const game = semutaPlay(); game.roomControl = { paused: false, joinLocked: false, revision: 1, updatedAt: null, [field]: true };
assert.equal(guide({ game, busy: false, act() { assert.fail('Room controls retain priority'); } }).props.disabled, true);
}
});
void test('Semuta guides reject extra fields, offer-stage selection and fake card-role actions without fallback', () => {
for (const type of ['semutaCommit', 'semutaPass', 'semutaSelect']) for (const extra of [{ amount: 1 }, { target: 'a' }, { mode: 'claim' }, { give: 'private-card' }, { card: 'unopened-candidate' }]) {
const game = semutaPlay(type === 'semutaSelect' ? 'select' : 'offer', { type, event: 'controlled-fresh-discard',
...(type === 'semutaSelect' ? { card: baseDeck().find(card => card.name === 'Shield')!.id } : {}), ...extra });
const button = guide({ game, busy: false, act() { assert.fail('Only the exact native Semuta packet is permitted'); } });
assert.equal(button.props.disabled, true); press(button);
}
const offer = semutaPlay('offer', { type: 'semutaSelect', event: 'controlled-fresh-discard', card: 'unopened-candidate' });
Object.defineProperty(offer.semutaReaction!, 'candidates', { get() { throw Error('Offer-stage selection cannot inspect candidates'); } });
assert.equal(guide({ game: offer, busy: false, act() { assert.fail('A private choice has not opened'); } }).props.disabled, true);
const physical = richeseCards().find(card => card.effect === 'semutaDrug'); assert.ok(physical);
const forged = projected(2, { type: 'card', card: physical.id }); forged.players.find(player => player.id === 'p')!.hand = [physical];
forged.turnActionPromises[0].mixed = printedCard('semutaDrug');
assert.equal(guide({ game: forged, busy: false, act() { assert.fail('Semuta is played through a real fresh-discard reaction'); } }).props.disabled, true);
const unrelated = projected(); unrelated.semutaReaction = semutaPlay().semutaReaction;
assert.equal(guide({ game: unrelated, busy: false, act() { assert.fail('A pending fresh discard owns its native controls'); } }).props.disabled, true);
});
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'), printedCard('distrans'), printedCard('juiceOfSapho')] }] };
game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard(), { kind: 'and', terms: [fact(), printedCard('ghola'), printedCard('nullentropyBox'), printedCard('distrans'), printedCard('juiceOfSapho'), printedCard('semutaDrug')] }] };
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 });
@@ -1579,6 +1687,8 @@ void test('printed-card history retains only raw aggregate conditions and their
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, /Semuta Drug counts only when its native transfer and disposal actually complete after the answer/);
assert.match(html, /A committed private choice cannot be canceled/);
assert.match(html, /remains open until the actual turn ends/); assert.doesNotMatch(html, /cardPlays|compiled|controlled-hajr|controlled-ghola/);
});
+18
View File
@@ -76,6 +76,24 @@ void test('Distrans and Sapho questions name only actual printed use this turn w
}
});
void test('Semuta future-use questions require completed physical exchange without naming a fresh target or inspection', () => {
const game = fixture();
assert.ok(!game.players.find(player => player.id === 'e')!.hand.some(card => card.effect === 'semutaDrug'));
const action = { kind: 'cardPlay' as const, effect: 'semutaDrug' as const };
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, /complete a printed Semuta Drug exchange and discard Semuta Drug after this answer this turn/);
assert.doesNotMatch(text, /inspect|commit|private-fresh-card|Ghola|Sapho/);
for (const extra of [{ selected: 'private-fresh-card' }, { committed: true }, { stage: 'select' }, { event: 'discard:2:4:1' }]) {
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(
+50 -4
View File
@@ -937,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', 'semutaDrug', null])
for (const effect of ['', 'Hajr', 'karama', 'thumper', 'Semuta Drug', null])
assert.throws(() => parseTurnActionLeaf({ kind: 'cardPlay', effect }), TurnActionError);
validateTurnActionPromises([cards(group('and', card(), card('ghola')))], seats);
});
@@ -1693,8 +1693,8 @@ void test('optional Box stage preserves legacy bytes and strict raw folded metad
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 optional = ['nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug'] as const;
for (let mask = 0; mask < (1 << optional.length); mask++) {
const cardPlays = { ...base.cardPlays! };
optional.forEach((effect, index) => { if (mask & (1 << index)) cardPlays[effect] = null; });
const row = { ...base, cardPlays }, before = JSON.stringify(row);
@@ -1712,7 +1712,7 @@ void test('each optional printed stage subset preserves legacy and Box bytes whi
assert.throws(() => turnActionValue(invalid), TurnActionError);
}
}
const unsupported = { ...base, cardPlays: { ...base.cardPlays!, semutaDrug: null } } as TurnActionPromise;
const unsupported = { ...base, cardPlays: { ...base.cardPlays!, unknownPrintedRole: null } } as TurnActionPromise;
assert.throws(() => turnActionValue(unsupported), TurnActionError);
});
@@ -1761,3 +1761,49 @@ void test('Distrans and Sapho No retain turn deadlines through Movement and paym
validateTurnActionPromises([expired], seats);
}
});
void test('Semuta whole branches track only physical resolution in the future answer interval without storing inspected fresh cards', () => {
const joint = cards(group('and', card('semutaDrug'), pay()));
const alternative = cards(group('or', card('semutaDrug'), card('nullentropyBox')), true, 'turn-action:2');
assert.deepEqual(branches(joint), [[{ kind: 'cardPlay', effect: 'semutaDrug', answer: true }, signedPay()]]);
for (const extra of [{ committed: true }, { stage: 'select' }, { selected: 'private-fresh-card' }, { event: 'discard:1:2:1' }]) {
assert.throws(() => parseTurnActionLeaf({ kind: 'cardPlay', effect: 'semutaDrug', ...extra }), TurnActionError);
const invalid = { ...joint, cardPlays: { ...joint.cardPlays!, semutaDrug: { played: false, closed: false, ...extra } } };
assert.throws(() => turnActionValue(invalid), TurnActionError);
}
assert.equal(recordTurnActionCardPlay([joint], 'other', 1, 'semutaDrug')[0], joint);
assert.equal(recordTurnActionCardPlay([joint], 'respondent', 2, 'semutaDrug')[0], joint);
assert.equal(recordTurnActionCardPlay([joint], 'respondent', 1, 'nullentropyBox')[0], joint);
assert.equal(joint.cardPlays!.semutaDrug!.played, false);
const used = recordTurnActionCardPlay([joint, alternative], 'respondent', 1, 'semutaDrug');
assert.deepEqual(branches(used[0]), [[signedPay()]]);
assert.equal(used[1].fulfilled, true);
const fresh = cards(card('semutaDrug'), true, 'turn-action:3');
assert.equal(fresh.cardPlays!.semutaDrug!.played, false);
const replay = recordTurnActionCardPlay([used[0], fresh], 'respondent', 1, 'semutaDrug');
assert.equal(replay[0], used[0]);
assert.equal(replay[1].fulfilled, true);
assert.equal(recordTurnActionPayment([used[0]], 'respondent', 1, payment())[0].fulfilled, true);
validateTurnActionPromises(used, seats);
});
void test('Semuta No remains binding through other deadlines and physical use or late correlated payment fails atomically', () => {
const yes = cards(group('and', card('semutaDrug'), pay()));
const no = cards(card('semutaDrug'), false, 'turn-action:2');
const before = structuredClone([yes, no]);
assert.throws(() => recordTurnActionCardPlay([yes, no], 'respondent', 1, 'semutaDrug'), TurnActionError);
assert.deepEqual([yes, no], before);
const waiting = closeTurnActionPayments(closeTurnActionTransport([no], 'respondent', 1), 'respondent', 1);
assert.deepEqual(waiting[0].cardPlays!.semutaDrug, { played: false, closed: false });
assert.equal(waiting[0].fulfilled, false);
const expired = finishTurnActionTurn(waiting, 'respondent', 1)[0];
assert.equal(expired.fulfilled, true);
assert.deepEqual(expired.cardPlays!.semutaDrug, { played: false, closed: true });
assert.equal(recordTurnActionCardPlay([expired], 'respondent', 1, 'semutaDrug')[0], expired);
assert.equal(finishTurnActionTurn([yes], 'respondent', 1)[0].released, true);
const wholeNo = cards(group('and', card('semutaDrug'), pay()), false, 'turn-action:3');
const used = recordTurnActionCardPlay([wholeNo], 'respondent', 1, 'semutaDrug');
assert.throws(() => recordTurnActionPayment(used, 'respondent', 1, payment()), TurnActionError);
assert.deepEqual(used[0].payments, []);
validateTurnActionPromises([expired], seats);
});