From 6a0fba860dd4305be741e08fdd8203248b46607a Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:49:45 +1100 Subject: [PATCH] Connect printed flight and poison plays to Truthtrance --- components/turn-action-fields.tsx | 14 +- components/turn-action-promises.tsx | 66 +++++- docs/CURRENT_STATUS.md | 32 +++ docs/IMPLEMENTATION_STATUS.md | 28 +++ docs/RULE_DECISIONS.md | 15 ++ docs/TRUTHTRANCE_TURN_ACTIONS.md | 47 ++++ game/engine.ts | 68 ++++-- game/printed-card-play.ts | 6 +- game/reference.ts | 5 +- game/truthtrance.ts | 2 + game/turn-action-promises.ts | 3 +- tests/fixture-truth-flight-poison.ts | 214 ++++++++++++++++++ tests/printed-card-play.test.ts | 22 +- tests/truth-flight-poison-runtime.test.ts | 260 ++++++++++++++++++++++ tests/truth-turn-action-controls.test.tsx | 144 +++++++++++- tests/truthtrance.test.ts | 20 ++ tests/turn-action-promises.test.ts | 58 ++++- 17 files changed, 963 insertions(+), 41 deletions(-) create mode 100644 tests/fixture-truth-flight-poison.ts create mode 100644 tests/truth-flight-poison-runtime.test.ts diff --git a/components/turn-action-fields.tsx b/components/turn-action-fields.tsx index fe229d53..9166efe2 100644 --- a/components/turn-action-fields.tsx +++ b/components/turn-action-fields.tsx @@ -76,7 +76,7 @@ const config: MixedActionConfig = { or requested Prescience field in this battle. They count before later Karama prevention. A requested field is not the opponent'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, Juice of Sapho or Semuta Drug 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, Semuta Drug, Ornithopter or Residual Poison 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. @@ -88,6 +88,10 @@ const config: MixedActionConfig = { 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. + Ornithopter counts its actual physical flight declaration after this answer, when the card enters its played zone. + Later groups and disposal do not count again. This condition closes at your own Movement turn end; it names no flight mode or destination. + Residual Poison counts the actual printed play, random leader death and disposal after this answer, not a selected victim. + Its deadline, including for a whole No answer, is the original turn end. Earlier play, disposal or recovery alone does not count. 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. @@ -174,7 +178,7 @@ const config: MixedActionConfig = { if (kind === 'ship' || kind === 'move') onChange({ kind, territory: value.kind === 'ship' || value.kind === 'move' ? value.territory : 'carthag', minimum }); }}> - + @@ -190,9 +194,9 @@ const config: MixedActionConfig = { {value.kind === 'cardPlay' ? <> : value.kind === 'windowCardPlay' ? <> diff --git a/components/turn-action-promises.tsx b/components/turn-action-promises.tsx index 017ce352..761264de 100644 --- a/components/turn-action-promises.tsx +++ b/components/turn-action-promises.tsx @@ -7,6 +7,7 @@ import { nativePromiseScopeSupported } from '@/game/native-promise-scope'; import { quoteNexusAlliance } from '@/game/nexus-alliance'; import { ordinaryCardAvailability } from '@/game/card-availability'; import { gholaForcePreparations } from '@/game/ghola-force-preparation'; +import { richeseCardDefinition } from '@/game/richese-cards'; import { nativeBattlePowerOpportunity, type NativeBattlePower } from '@/game/battle-power-question'; import { battleCardLabel, VOICE_KINDS } from '@/game/battle-cards'; import { WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play'; @@ -36,7 +37,7 @@ const namesWindow = (mixed: MixedActionExpression, effect: Windo const namesBoxPlay = (mixed: MixedActionExpression): boolean => mixed.kind === 'action' ? mixed.action.kind === 'cardPlay' && mixed.action.effect === 'nullentropyBox' : mixed.kind !== 'fact' && mixed.terms.some(namesBoxPlay); -const namesPrintedPlay = (mixed: MixedActionExpression, effect: 'distrans' | 'juiceOfSapho' | 'semutaDrug'): boolean => mixed.kind === 'action' +const namesPrintedPlay = (mixed: MixedActionExpression, effect: 'distrans' | 'juiceOfSapho' | 'semutaDrug' | 'residualPoison'): boolean => mixed.kind === 'action' ? mixed.action.kind === 'cardPlay' && mixed.action.effect === effect : mixed.kind !== 'fact' && mixed.terms.some(term => namesPrintedPlay(term, effect)); @@ -144,13 +145,32 @@ export function TurnActionGuideButton(props: TurnActionProps) { const owner = current.players.find(player => player.id === actor); return JSON.stringify({ hand: owner?.hand?.map(card => ({ id: card.id, kind: card.kind, effect: card.effect })), limit: owner?.handLimit }); }; - const printedHandSignature = heldCard?.effect === 'distrans' || heldCard?.effect === 'juiceOfSapho' ? printedHandSource(game) : null; + const printedHandSignature = heldCard?.effect === 'distrans' || heldCard?.effect === 'juiceOfSapho' || heldCard?.effect === 'residualPoison' ? printedHandSource(game) : null; const distransCapacitySource = (current: GameView) => { const recipient = current.players.find(player => player.id === action.target); return JSON.stringify(recipient && { id: recipient.id, count: recipient.handCount, limit: recipient.handLimit }); }; const distransCapacitySignature = heldCard?.effect === 'distrans' ? distransCapacitySource(game) : null; const saphoDecisionSignature = heldCard?.effect === 'juiceOfSapho' ? JSON.stringify(decision) : null; + const residual = heldCard?.effect === 'residualPoison' ? game.residualPoison : null; + const residualSource = (current: GameView) => JSON.stringify({ quote: current.residualPoison, + battle: publicBattleContext(current), preLeader: current.battle?.preLeader }); + const residualSignature = heldCard?.effect === 'residualPoison' ? residualSource(game) : null; + const flightFields = ['movementCard', 'ornithopter', 'ornithopterEvent']; + const flightTagged = action.type === 'move' && flightFields.some(key => Object.hasOwn(action, key)); + const flightNeeded = action.type === 'move' && (flightTagged || !!game.ornithopter?.active); + const flight = flightNeeded ? game.ornithopter : null, flightSignature = flightNeeded ? JSON.stringify(flight) : null; + const flightOwner = flightNeeded ? game.players.find(player => player.id === actor) : undefined; + const flightInitial = action.type === 'move' && Object.hasOwn(action, 'movementCard'); + const flightCard = flightInitial && typeof action.movementCard === 'string' + ? flightOwner?.hand?.find(card => card.id === action.movementCard) : undefined; + const flightOwnerSource = (current: GameView) => { + const owner = current.players.find(player => player.id === actor); + return JSON.stringify({ forces: owner?.forces, elites: owner?.elites?.forces, advisors: owner?.advisors, + moved: owner?.moved, movesAllowed: owner?.movesAllowed, shipped: owner?.shipped, + ...(flightInitial ? { hand: owner?.hand?.map(card => ({ id: card.id, kind: card.kind, effect: card.effect })) } : {}) }); + }; + const flightOwnerSignature = flightNeeded ? flightOwnerSource(game) : null; const auctionCards = game.karamaAuctionCards, auctionCardsSignature = JSON.stringify(auctionCards); const shipmentCards = game.karamaShipmentCards, shipmentCardsSignature = JSON.stringify(shipmentCards); const thumperCards = game.thumperCards, thumperCardsSignature = JSON.stringify(thumperCards); @@ -178,6 +198,22 @@ export function TurnActionGuideButton(props: TurnActionProps) { (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 flightPacket = Object.keys(action).every(key => ['type', 'from', 'amount', 'elite', 'forces', 'eliteForces', 'territory', 'sector', + 'fighters', 'noField', 'event', 'planetologist', 'turnActionPromiseEvent', 'turnActionPromisePhase', + ...(flightInitial ? ['movementCard', 'ornithopter'] : ['ornithopterEvent'])].includes(key)); + const flightSource = flightNeeded && phase === 5 && current.active === actor && !!me && me === flightOwner && !!flight && + current.ornithopter === flight && JSON.stringify(current.ornithopter) === flightSignature && flightOwnerSource(current) === flightOwnerSignature && + flight.blocked === null && flightPacket && !!windowPromise && current.turnActionPromises.includes(windowPromise) && + JSON.stringify(windowPromise.mixed) === windowQuestion; + const nativeFlight = flightSource && !!flight && (flightInitial ? !flight.active && !!flightCard && + me?.hand?.find(card => card.id === action.movementCard) === flightCard && flightCard.kind === 'special' && + richeseCardDefinition(flightCard)?.card.effect === 'ornithopter' && flight.card.id === flightCard.id && + richeseCardDefinition(flight.card)?.card.effect === 'ornithopter' && + (action.ornithopter === 'range3' || action.ornithopter === 'twoGroups') && + flight.modes.some(mode => mode.mode === action.ornithopter && mode.blocked === null) + : !!flight.active && Number.isSafeInteger(flight.active.remaining) && flight.active.remaining > 0 && + action.ornithopterEvent === flight.active.event); + const movementPacket = action.type !== 'move' || (!flightTagged && !current.ornithopter?.active) || nativeFlight; 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; @@ -267,10 +303,17 @@ export function TurnActionGuideButton(props: TurnActionProps) { Object.keys(action).every(key => ['type', 'card', 'scope', 'event', 'mode', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key)); const saphoDecision = printedSapho && phase === 8 && action.scope === 'extortion' && current.decision === decision && current.decision?.kind === 'moritaniExtortion' && JSON.stringify(current.decision) === saphoDecisionSignature; + const printedResidual = printedRoleSource && heldCard?.effect === 'residualPoison' && namesPrintedPlay(windowPromise!.mixed, 'residualPoison') && + richeseCardDefinition(heldCard)?.card.effect === 'residualPoison' && !!residual && current.residualPoison === residual && + residualSource(current) === residualSignature && residual.blocked === null && residual.card.id === heldCard.id && + richeseCardDefinition(residual.card)?.card.effect === 'residualPoison' && phase === 6 && !!battle && current.battle === battle && + !battle.revealed && [battle.attacker, battle.defender].includes(actor) && action.event === battle.event && action.event === residual.event && + action.target === residual.target && action.target === (battle.attacker === actor ? battle.defender : battle.attacker) && + Object.keys(action).every(key => ['type', 'card', 'event', 'target', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key)); 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' && heldCard.effect !== 'semutaDrug' && !isWindowEffect(heldCard.effect); + !!heldCard && heldCard.effect !== 'ghola' && heldCard.effect !== 'hajr' && heldCard.effect !== 'distrans' && heldCard.effect !== 'juiceOfSapho' && heldCard.effect !== 'semutaDrug' && heldCard.effect !== 'ornithopter' && heldCard.effect !== 'residualPoison' && !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 && @@ -353,7 +396,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 = nativeSemuta || 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 || printedResidual || 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 @@ -367,7 +410,7 @@ export function TurnActionGuideButton(props: TurnActionProps) { return !props.busy && props.act === act && current === game && nativePromiseScopeSupported(current) && current.status === 'playing' && Number.isSafeInteger(phase) && phase >= 0 && phase <= 8 && current.phase === phase && current.turn === turn && current.me === actor && current.turnActionGuide === guide && guide.event === event && guide.action === action && - action.turnActionPromiseEvent === event && action.turnActionPromisePhase === phase && JSON.stringify(action) === signature && opportunity && + action.turnActionPromiseEvent === event && action.turnActionPromisePhase === phase && JSON.stringify(action) === signature && opportunity && movementPacket && current.turnActionPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn && !promise.fulfilled && !promise.released) && (!amalReadyAction || openingReady) && @@ -401,6 +444,8 @@ export function TurnActionGuideButton(props: TurnActionProps) { : action.type === 'card' && heldCard?.effect === 'distrans' ? `Give ${distrans?.choices.find(choice => choice.recipient === action.target)?.cards.find(card => card.id === action.give)?.name ?? 'the quoted card'} to ${recipient} with Distrans while honoring your whole answer` : action.type === 'card' && heldCard?.effect === 'juiceOfSapho' ? `Play Juice of Sapho: ${action.scope === 'battleAggressor' ? 'become this battle’s aggressor' : `${saphoScope}, ${String(action.mode)}`} while honoring your whole answer` + : action.type === 'card' && heldCard?.effect === 'residualPoison' ? `Play Residual Poison against ${recipient} while honoring your whole answer; the native leader death is random` + : action.type === 'move' && flightInitial ? `Declare Ornithopter ${action.ornithopter === 'range3' ? 'three-territory' : 'two-group'} flight and move ${amount} physical forces to ${destination}${sector} while honoring your whole answer` : action.type === 'card' && windowEffect ? `Play ${windowRoleNames[windowEffect]} in its original native window${windowEffect === 'weather' ? ` with storm distance ${amount}` : ''} while honoring your whole answer` : action.type === 'battlePlan' ? 'Fill the quoted battle plan for review before sealing' : action.type === 'voice' ? `Declare native Voice: opponent ${action.must ? 'must' : 'cannot'} play ${battleCardLabel(String(action.kind))}` @@ -493,7 +538,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, Juice of Sapho or Semuta Drug 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, Semuta Drug, Ornithopter or Residual Poison 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. @@ -505,6 +550,10 @@ export function TurnActionPromises(props: TurnActionProps) { 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. + Ornithopter counts its actual physical flight declaration after the answer, when the card enters its played zone. + Later groups and disposal do not count again. Its deadline is your own Movement turn end; no flight mode or destination is promised. + Residual Poison counts the actual printed play, random leader death and disposal after the answer; no victim is promised. + Its whole No deadline is the original turn end. Earlier play, disposal or recovery alone does not count. 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. @@ -564,12 +613,15 @@ 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, Juice of Sapho and Semuta Drug conditions require actual later printed-role plays, not earlier play, disposal or recovery alone. + Printed-card conditions require the actual later named printed use, 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. + Ornithopter counts its physical flight declaration once, before later groups or disposal, and closes at your own Movement turn end. + Residual Poison counts its actual printed play, random leader death and disposal. No mode, destination or victim is promised; + its whole No deadline is the original 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. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index e8b1bdb4..519ff62e 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 flight and pre-leader cards — 27 October. Prototyped / Partial.** +Ornithopter counts its accepted initial physical declaration, with actual +Movement-end closure and no credit for later groups/disposal. Residual Poison +counts actual native random death/disposal through turn end. Neither predicate +selects a flight mode/destination or victim. Shared payment/action funding, +No guards, exact human controls and four legal profiles connect. + +Frozen **25 affected files, 367/367**, types/lint/build and four runtime +groups/seven genuine native programmes pass. Actual Basic/Advanced setup, +original Truth deals, cache1 purchases, funded shipments and pre-leader battle +supply every role. Runtime includes 38 GameTable SSR renders, +2 standalone native-component renders, 7 actual answer callbacks, +8 whole-guide callbacks, 1 native flight callback and 1 native +poison callback. Source/further counts derive from recorded action arrays; +no played state, victim or future action receipt is installed. + +Fourteen new cases cover both actual flight modes/groups, active-flight new +intervals, own closure/leader-commitment rejection, foreign-plan release, full +budget/frozen facts, No AND/OR caps, stale/JSON packets and legal profiles. +Poison quotation neither samples RNG nor creates hypothetical Tanks/bounty. +Initial feedback repairs the new driver: decline native Prescience before +acknowledging shared readiness, retaining the actual open pre-leader source. + +Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-flight-poison-proof-v2/`; +journeys/HTML: adjacent `20261027-truth-flight-poison-runtime/run/`. +No HTTP/storage boundary, saved-game/database or deployed operation changes. +The local dev process/listener was absent at the health probe. It was started +and the root returned HTTP200; no existing process was interrupted. This is +readiness evidence, not a saved-seat restoration certificate. Broader future calendars/preparations, combinations, complete modes +and final assurance/browser/deployed acceptance remain open. +[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-flight-and-pre-leader-cards--27-october-2026). + **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, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 6639a333..839b0823 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,33 @@ # Dune implementation status +## 27 October 2026 — Printed flight and pre-leader commitments + +Prototyped / Partial. First native Ornithopter declaration and actual Residual +Poison use connect to whole answers, shared funding and exact current controls. +Movement-end versus turn-end intervals remain separate. Later flight groups or +disposal supply no replay credit; a poison witness does not sample a victim or +grant hypothetical casualties/bounty. Immediate No and deliberate own costs +retain remaining commitments. Four minimal profiles use the same native guides. + +Frozen **25 affected files, 367/367**, types/lint/build and four runtime +groups/seven genuine native programmes pass. Actual Basic/Advanced setup, +original Truth deals, cache1 purchases, funded shipments and pre-leader battle +supply every role. Runtime includes 38 GameTable SSR renders, +2 standalone native-component renders, 7 actual answer callbacks, +8 whole-guide callbacks, 1 native flight callback and 1 native +poison callback. Source/further counts derive from recorded action arrays; +no played state, victim or future action receipt is installed. +Fourteen new cases and the actual callbacks cover new intervals, full-budget +payments, frozen facts, No caps, current source/leader boundaries and both modes. +The new fixture's native Prescience-decline order is repaired after focused +feedback, without installing a pre-leader window or modifying product timing. +The absent local dev process was started and its root returned HTTP200; no +running server was interrupted. This does not certify saved-seat restoration. +No full AI, saved-game operation or deployment occurs. Wider preparations, +calendars, combinations and all final acceptance remain open. +[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-flight-and-pre-leader-cards--27-october-2026). +Private proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-flight-poison-proof-v2/`. + ## 27 October 2026 — Printed Semuta exchange commitments Prototyped / Partial. Completed physical exchange/disposal counts after the diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 8e0995f1..cea4f59e 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,20 @@ # Rule decision index +## Truthtrance printed flight/pre-leader use — 27 October 2026 + +Prototyped / Partial. Ornithopter counts accepted initial physical placement in +its native flight, not each movement or disposal, and closes at owner Movement +end. Residual Poison counts actual printed random death/disposal without a +victim predicate, through the original turn end. Reuse native legality/costs; +private completion neither samples a victim nor grants hypothetical casualties. +Current actions bind the real held card, movement or battle event and actual +opponent. Immediate No and own expenditure retain the whole answer. This is +bounded composition of existing printed contracts, not a new publisher timing +ruling or complete-mode certificate. +[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-flight-and-pre-leader-cards--27-october-2026), +[flight source](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md), +[poison source](RICHESE_RESIDUAL_POISON_ENGINE_AUDIT.md). + ## Truthtrance printed Semuta use — 27 October 2026 Prototyped / Partial. Count the completed physical exchange and Semuta disposal, diff --git a/docs/TRUTHTRANCE_TURN_ACTIONS.md b/docs/TRUTHTRANCE_TURN_ACTIONS.md index e5d3f636..5c98332f 100644 --- a/docs/TRUTHTRANCE_TURN_ACTIONS.md +++ b/docs/TRUTHTRANCE_TURN_ACTIONS.md @@ -1,5 +1,52 @@ # Truthtrance combined current-turn actions +## Printed flight and pre-leader cards — 27 October 2026 + +**Prototyped / Partial.** `cardPlay/ornithopter` counts the first accepted +native declaration placing the physical card into its played flight after +answering. It does not name a mode, group, destination or completed number of +moves. Subsequent groups and eventual disposal do not count again. A previously +active flight supplies no later-play credit. Its interval closes at the actual +owner's Movement end; later payment alternatives retain their own deadlines. + +Native movement validation still owns the first group, range, stance, routes, +original cohort, remaining ordinary/Hajr entitlement and later arrival windows. +The shared completion search can use either printed mode and owned earlier +preparation, sharing the real purse with other actions. No new move, force, +income or foreign continuation is granted merely by asking. Immediate No also +blocks the native card choices. Current guides use the actual held card or +existing flight event; a future calendar witness is not sent early. + +`cardPlay/residualPoison` counts actual printed use with the native random death +and physical disposal after answering this turn. No victim, selected outcome or +leader field is accepted. The current native pre-leader validator retains its +actual opponent, event, available pool and leader-commitment restrictions. +The private witness spends/counts the card without sampling a victim or adding +hypothetical Tanks for Ghola or revival. Deliberate cost preflight precedes the +real random draw; the actual result is checked before its original Semuta or +preparation suffix. The No interval lasts through the full turn, including later +native opportunities; a particular unknown future battle is not invented. + +Human editors, persistent text, exact guides and four minimally legal profiles +reuse native controls. Earlier use, recovery alone and another role supply no +credit. Whole AND/OR/No, frozen facts and remaining payment/transport/battle +constraints remain shared. This is bounded composition of the existing printed +card contracts, not a new publisher worked example. Wider future calendars, +preparations/combinations, full modes and final assurance/deployed acceptance +remain open. + +Frozen **25 affected files, 367/367**, types/lint/build and four runtime +groups/seven genuine native programmes pass. Actual Basic/Advanced setup, +original Truth deals, cache1 purchases, funded shipments and pre-leader battle +supply every role. Runtime includes 38 GameTable SSR renders, +2 standalone native-component renders, 7 actual answer callbacks, +8 whole-guide callbacks, 1 native flight callback and 1 native +poison callback. Source/further counts derive from recorded action arrays; +no played state, victim or future action receipt is installed. +Private proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-flight-poison-proof-v2/`; +journeys/HTML: adjacent `20261027-truth-flight-poison-runtime/run/`. +This is local native/SSR/callback evidence, not hydration or deployed acceptance. + ## Printed Semuta exchange — 27 October 2026 **Prototyped / Partial.** `cardPlay/semutaDrug` means the completed printed diff --git a/game/engine.ts b/game/engine.ts index dda0d018..ff90a15b 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -18375,6 +18375,8 @@ function ornithopterBlock( return 'Use Ornithopter during your own Shipment and Movement turn.'; if (g.truthtrance || g.response || g.decision || g.phaseOpening) return 'Resolve the current interaction before declaring a movement.'; + const promised = printedRolePlayBlock(g, p.id, 'ornithopter'); + if (promised) return promised; if (g.ornithopter) return 'Finish the already played Ornithopter movement first.'; if (p.moved >= movesAllowed(g,p)) @@ -19064,9 +19066,21 @@ function turnActionCardsMatch(g: Game, actor: string, requirements: readonly Tur } function* nativePrintedCardActions(g: Game, actor: string, effect: PrintedCardEffect): Generator { 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; + // Semuta owns a fresh-discard window; Ornithopter uses real movement + // declarations. Neither is a generic card-button preparation. + if (effect === 'semutaDrug' || effect === 'ornithopter') return; + if (effect === 'residualPoison') { + const battle = g.battle; + if (!battle?.event || ![battle.attacker, battle.defender].includes(actor)) return; + const target = battle.attacker === actor ? battle.defender : battle.attacker; + for (const card of player.hand.filter(card => card.effect === effect)) { + try { + validateResidualPoison(g, player, card.id, target, battle.event); + yield { type: 'card', card: card.id, target, event: battle.event }; + } catch (error) { if (!(error instanceof RuleError)) throw error; } + } + return; + } if (effect === 'distrans') { const quote = distransView(g, player); if (quote && !quote.blocked) for (const choice of quote.choices) if (!choice.blocked) @@ -19129,17 +19143,19 @@ function guardTurnActionBoxCost(source: Game, owner: Player, box: string) { for (const seed of turnActionBoxSeeds(source, owner.id, box)) if (turnActionCompletion(seed.game, owner.id)) return; throw new RuleError('The two-spice Box payment must preserve your accepted whole Truthtrance answers.'); } -/** Native transfer/order effects retain their physical cost and public queue. - * No recipient consent, unseen acquisition or income is assumed. */ +/** Native transfer/order and pre-leader costs retain their physical cards and + * queues. No unseen acquisition, random casualty or income is assumed. */ function* turnActionTransferOrderSeeds(source: Game, actor: string, requirements: readonly TurnActionCardRequirement[]): Generator<{ game: Game; actions: Action[] }> { const search = function* (game: Game, actions: Action[]): Generator<{ game: Game; actions: Action[] }> { const pending = [...new Set(requirements.filter(required => required.answer && - (required.effect === 'distrans' || required.effect === 'juiceOfSapho') && + (required.effect === 'distrans' || required.effect === 'juiceOfSapho' || required.effect === 'residualPoison') && nativePrintedCardCount(game, actor, required.effect) <= required.baseline).map(required => required.effect))]; for (const effect of pending) for (const action of nativePrintedCardActions(game, actor, effect)) { try { - const played = applyActionInner(game, actor, action, 'movementPreparation'); + const played = effect === 'residualPoison' + ? projectResidualPoisonPrintedCost(game, actor, String(action.card)) + : applyActionInner(game, actor, action, 'movementPreparation'); const active = played.active, extortion = played.decision?.kind === 'moritaniExtortion' ? played.decision : null; const projected = groundMovementProbe(played, actor); projected.active = active; @@ -19198,7 +19214,7 @@ function* turnActionOwnedCardSeeds(source: Game, actor: string, requirements: re function* turnActionCardSeeds(source: Game, actor: string, requirements: readonly TurnActionCardRequirement[]): Generator<{ game: Game; actions: Action[] }> { if (!requirements.some(required => required.answer && - (required.effect === 'distrans' || required.effect === 'juiceOfSapho') && + (required.effect === 'distrans' || required.effect === 'juiceOfSapho' || required.effect === 'residualPoison') && nativePrintedCardCount(source, actor, required.effect) <= required.baseline)) { yield* turnActionOwnedCardSeeds(source, actor, requirements); return; @@ -20155,7 +20171,7 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b ...(printedCards.size ? { cardPlays: { hajr: printedCards.has('hajr') ? { played: false, closed: !open } : null, ghola: printedCards.has('ghola') ? { played: false, closed: false } : null, ...Object.fromEntries([...printedCards].filter(effect => effect !== 'hajr' && effect !== 'ghola') - .map(effect => [effect, { played: false, closed: false }])) } } : {}), + .map(effect => [effect, { played: false, closed: effect === 'ornithopter' && !open }])) } } : {}), ...(combat ? { combat } : {}), ...(preparation ? { preparation } : {}), ...(nexus ? { alliance: { source: nexus.source, partner: null, closed: false } } : {}), @@ -20398,14 +20414,16 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act if (!action) return null; // The joint quotation can prepare a later answer while an earlier unrelated // answer remains live. Window controls must bind the row naming that source. - const held = action.type === 'card' ? player.hand.find(card => card.id === action.card) : undefined; + const held = action.type === 'card' ? player.hand.find(card => card.id === action.card) + : action.type === 'move' ? player.hand.find(card => card.id === action.movementCard) : undefined; const printedWindow = held?.kind === 'special' && held.effect === 'karama' && action.mode === 'purchase' ? 'karamaAuction' : held?.kind === 'special' && held.effect === 'karama' && action.mode === 'shipment' ? 'karamaShipment' : isWindowCardEffect(held?.effect) ? held.effect : null; const windows: WindowCardEffect[] = printedWindow ? [printedWindow] : action.type === 'decision' && action.karama === true ? ['karamaAuction'] : action.type === 'ready' || held?.effect === 'nullentropyBox' ? [...WINDOW_CARD_EFFECTS] : []; - const sideRole = held?.effect === 'distrans' || held?.effect === 'juiceOfSapho' ? held.effect : null; + const sideRole = held?.effect === 'distrans' || held?.effect === 'juiceOfSapho' || + held?.effect === 'residualPoison' || held?.effect === 'ornithopter' ? held.effect : null; const guidePromise = sideRole ? liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row => row.cardPlays?.[sideRole] && !row.cardPlays[sideRole]!.played && !row.cardPlays[sideRole]!.closed) ?? promise : windows.length ? liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row => @@ -20423,7 +20441,7 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act function turnActionCurrentPrintedPreparation(g: Game, player: Player, action: Action): boolean { if (action.type !== 'card') return true; const card = player.hand.find(card => card.id === action.card); - if (card?.effect !== 'distrans' && card?.effect !== 'juiceOfSapho') return true; + if (card?.effect !== 'distrans' && card?.effect !== 'juiceOfSapho' && card?.effect !== 'residualPoison') return true; return [...nativePrintedCardActions(g, player.id, card.effect)].some(choice => ['type', 'card', 'target', 'give', 'scope', 'event', 'mode'].every(key => choice[key] === action[key])); } @@ -20685,8 +20703,10 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises: .every(promise => matchesShipment(promise, null) === promise.answer); }, needsProgress(state, owner) { - return state.advanced && !hajrAvailable(state, getPlayer(state, owner)) && cardPlays.some(required => - required.effect === 'hajr' && required.answer && nativePrintedCardCount(state, owner, 'hajr') <= required.baseline); + return cardPlays.some(required => required.answer && + nativePrintedCardCount(state, owner, required.effect) <= required.baseline && + (required.effect === 'ornithopter' || required.effect === 'hajr' && state.advanced && + !hajrAvailable(state, getPlayer(state, owner)))); }, progressKey(state, owner) { return cardPlays.map(required => [required.effect, nativePrintedCardCount(state, owner, required.effect) > required.baseline]); @@ -21182,6 +21202,7 @@ function declareGroundMovement(g: Game, p: Player, action: Action, quoteOnly = f : `That destination is blocked or more than ${speed} territories away.`, ); validateFlightSelection(g, p, move); + if (action.movementCard !== undefined) recordNativePrintedCardPlay(g, id, 'ornithopter'); const matching = quoteMatchingMovement(g, move); const gatherMatching=quoteGatherMatching(g,move); if (quoteOnly) return move; @@ -27886,6 +27907,8 @@ function validateResidualPoison( ); const reserved = transferCardBlock(g, p, card); requireRule(!reserved, reserved ?? 'The card is committed.'); + const promised = printedRolePlayBlock(g, p.id, 'residualPoison'); + requireRule(!promised, promised ?? 'Retain your whole Truthtrance answer.'); const target = getPlayer(g, targetId), controlled = controlledLeaders(g, target); const candidates = noFieldRule(() => @@ -27904,6 +27927,15 @@ function validateResidualPoison( }); return { b, card, target, candidates }; } +/** A private completion pays the printed cost without drawing a random victim + * or borrowing a hypothetical casualty for later Ghola/revival preparation. */ +function projectResidualPoisonPrintedCost(source: Game, actor: string, card: string): Game { + const game = structuredClone(source), player = getPlayer(game, actor); + const used = discard(game, player, card); + recordNativePrintedCardPlay(game, actor, 'residualPoison'); + rememberMovementDiscards(game, [{ card: used, discardedBy: actor, publicFace: true }]); + return game; +} function playResidualPoison( g: Game, p: Player, @@ -27918,6 +27950,12 @@ function playResidualPoison( targetId, event, ); + // Check deliberate physical expenditure before random selection. The + // native victim itself remains automatic and is never chosen by a witness. + if (liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length) { + const quoted = projectResidualPoisonPrintedCost(g, p.id, card.id); + guardTurnActionPrintedCardCost(quoted, getPlayer(quoted, p.id), card); + } const selected = shuffle(candidates)[0]; const victim = controlledLeaders(g, target).find( (l) => l.id === selected.id, @@ -27936,6 +27974,8 @@ function playResidualPoison( const harkonnen = byFaction(g, 'harkonnen'); if (harkonnen) returnCaptives(g, harkonnen); const used = discard(g, p, card.id); + recordNativePrintedCardPlay(g, p.id, 'residualPoison'); + guardTurnActionPrintedCardCost(g, p, used); log( g, `${p.name} played Residual Poison against ${target.name}. ${victim.name} was randomly selected from the available leaders and sent to the Tanks. No spice is awarded, no forces are lost, and battle preparation continues.`, diff --git a/game/printed-card-play.ts b/game/printed-card-play.ts index 54a2861d..3ae3684a 100644 --- a/game/printed-card-play.ts +++ b/game/printed-card-play.ts @@ -1,4 +1,5 @@ -export const PRINTED_CARD_EFFECTS = ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug'] as const; +export const PRINTED_CARD_EFFECTS = ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug', + 'ornithopter', 'residualPoison'] as const; export type PrintedCardEffect = typeof PRINTED_CARD_EFFECTS[number]; export type PrintedCardPlayCounts = { turn: number; @@ -56,7 +57,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. Semuta requires its physical - * exchange, not commitment or candidate inspection. Genuine reacquisition may replay. */ + * exchange, not commitment or candidate inspection. Ornithopter counts placement + * into its native flight, not each group or final disposal. Genuine reacquisition may replay. */ export function recordPrintedCardPlay(counts: PrintedCardPlayCounts | undefined, player: string, turn: number, cardEffect: PrintedCardEffect): PrintedCardPlayCounts { validateRequest(player, turn, cardEffect); diff --git a/game/reference.ts b/game/reference.ts index 523cbb8d..6ed51518 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3232,6 +3232,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', + 'A printed Ornithopter clause counts its first accepted native flight declaration after answering, not every group or disposal. It names no mode or route; an already active flight supplies no new credit. Its interval closes at your actual Movement end. A printed Residual Poison clause counts actual native random death/disposal without naming a victim, through turn end. Private completion pays its card without sampling a victim or inventing casualties for revival. Native movement and pre-leader legality, current events, costs and other commitments remain.', '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.', @@ -3247,11 +3248,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/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: '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, accepted Ornithopter flights, native Residual Poison 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 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-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'] }, + { 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-flight-poison.ts', 'tests/truth-flight-poison-runtime.test.ts', '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'] }, ], }, { diff --git a/game/truthtrance.ts b/game/truthtrance.ts index edd67d31..8b905ede 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -757,6 +757,8 @@ export function truthQuestionText( 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'; + if (action.effect === 'ornithopter') return 'you play Ornithopter into a native flight after this answer before your Shipment and Movement turn ends'; + if (action.effect === 'residualPoison') return 'you play Residual Poison in its printed role 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`; } diff --git a/game/turn-action-promises.ts b/game/turn-action-promises.ts index 28784840..6df9923e 100644 --- a/game/turn-action-promises.ts +++ b/game/turn-action-promises.ts @@ -951,7 +951,7 @@ export function closeTurnActionPayments(promises: r }); } -/** Close transport and its native Hajr stage; later Ghola or payment can still complete the tree. +/** Close transport and its native Hajr/Ornithopter stages; later card or payment alternatives can complete the tree. * The native joint solver, rather than this ledger, rejects own illegal closure. */ export function closeTurnActionTransport(promises: readonly T[], player: string, turn: number): T[] { @@ -962,6 +962,7 @@ export function closeTurnActionTransport(promises: return settle(promise, { ...promise, transportClosed: true, shipment: { ...promise.shipment, pending: false }, ...(promise.cardPlays ? { cardPlays: { ...promise.cardPlays, hajr: promise.cardPlays.hajr ? { ...promise.cardPlays.hajr, closed: true } : null, + ...(promise.cardPlays.ornithopter ? { ornithopter: { ...promise.cardPlays.ornithopter, closed: true } } : {}), } } : {}) }); }); } diff --git a/tests/fixture-truth-flight-poison.ts b/tests/fixture-truth-flight-poison.ts new file mode 100644 index 00000000..61f777ce --- /dev/null +++ b/tests/fixture-truth-flight-poison.ts @@ -0,0 +1,214 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import type { MixedActionExpression } from '../game/mixed-action-question'; +import type { OrnithopterMode } from '../game/ornithopter'; +import { residualPoisonCandidates } from '../game/residual-poison'; +import { richeseCards } from '../game/richese-cards'; +import type { TruthFact, TruthQuestion } from '../game/truthtrance'; +import { actResidualPoisonCapture } from './fixture-residual-poison-capture'; +import { ornithopterHajrMove } from './fixture-ornithopter-hajr'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; +import { truthTurnThumperSpice, truthTurnThumperTraitors } from './fixture-truth-turn-thumper'; + +export type TruthFlightPoisonEffect = 'ornithopter' | 'residualPoison'; +export type TruthFlightPoisonLeaf = { kind: 'cardPlay'; effect: TruthFlightPoisonEffect } | + { kind: 'payment'; recipient: string; minimum: number }; +export type TruthFlightPoisonExpression = MixedActionExpression; +export type TruthFlightPoisonStep = { actor: string; action: Action }; +export const actTruthFlightPoison = actResidualPoisonCapture; +export const truthFlightPoisonPlayer = (game: Game, actor: string) => { + const player = game.players.find(candidate => candidate.id === actor); assert.ok(player); return player; +}; +export const truthFlightPoisonClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && + !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingNullentropy && !game.pendingShipment && !game.currentAuctionSale; +export const truthFlightPoisonSpice = truthTurnThumperSpice; +export const truthFlightPoisonTraitors = truthTurnThumperTraitors; +/** An active physical flight is the card's real table slot until disposal. */ +export function truthFlightPoisonInventory(game: Game) { + const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), + ...game.players.flatMap(player => player.hand), ...(game.ornithopter ? [game.ornithopter.card] : []), + ...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])] + .map(card => card.id).sort(); + assert.equal(new Set(ids).size, ids.length, 'Every original Treachery Card has one actual physical slot'); return ids; +} +export const truthFlightPoisonUse = (effect: TruthFlightPoisonEffect): TruthFlightPoisonExpression => ({ kind: 'action', action: { kind: 'cardPlay', effect } }); +export const truthFlightPoisonFact = (fact: TruthFact): TruthFlightPoisonExpression => ({ kind: 'fact', fact }); +export const truthFlightPoisonPayment = (recipient: string, minimum = 2): TruthFlightPoisonExpression => + ({ kind: 'action', action: { kind: 'payment', recipient, minimum } }); +export const truthFlightPoisonGroup = (kind: 'and' | 'or', ...terms: TruthFlightPoisonExpression[]): TruthFlightPoisonExpression => ({ kind, terms }); + +export function nextTruthFlightPoisonNative(game: Game): TruthFlightPoisonStep | null { + if (game.truthtrance || game.pendingNullentropy) return null; + if (game.pendingTreacheryDiscard) return nextRicheseStrongholdsNativeStep(game); + if (truthFlightPoisonClean(game) && game.battle?.preparation?.kind === 'prescience') + return { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } }; + if (!game.response && !game.phaseOpening && game.decision?.kind === 'guildTiming') + return { actor: game.decision.player, action: { type: 'decision', take: false } }; + return nextRicheseStrongholdsNativeStep(game); +} +export function advanceTruthFlightPoison(game: Game, until: (state: Game) => boolean, + actions: TruthFlightPoisonStep[] = []): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original flight/Poison source ended before its requested boundary'); + const step = nextTruthFlightPoisonNative(game); assert.ok(step, 'Supply the actual Truth, private selection or native Battle Plan'); + game = actTruthFlightPoison(game, step.actor, step.action, actions); + } + throw Error('The original flight/Poison source did not reach its finite boundary'); +} +export function settleTruthFlightPoison(game: Game, actions: TruthFlightPoisonStep[] = []): Game { + return advanceTruthFlightPoison(game, truthFlightPoisonClean, actions); +} +export function finishTruthFlightPoison(game: Game, actions: TruthFlightPoisonStep[] = []): Game { + const turn = game.turn; + return advanceTruthFlightPoison(game, state => state.turn === turn + 1, actions); +} + +/** Fresh bare R/A/E/G original setup in its actual requested mode. Native + * cache1 acquisition, funded physical shipment and real battle preparation + * supply every current role; no Nexus/profile is removed from a played game. */ +export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPoisonEffect; advanced?: boolean } = {}) { + const effect = options.effect ?? 'ornithopter', advanced = options.advanced ?? false; + const target = effect === 'ornithopter' ? 'emperor' : 'atreides', asker = 'richese', recipient = asker; + const canonical = richeseCards().find(card => card.effect === effect); assert.ok(canonical); const card = canonical.id; + let game = createGame('TRUTHFLIGHTPOISON', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']); + for (const faction of ['atreides', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction)); + const initial = structuredClone(game), actions: TruthFlightPoisonStep[] = []; + const act = (actor: string, action: Action) => { game = actTruthFlightPoison(game, actor, action, actions); }; + const sourceStep = (state: Game) => { + if (!state.response && !state.phaseOpening && !state.pendingTreacheryDiscard) { + if (state.decision?.kind === 'richeseCache' && state.richeseCache?.some(held => held.id === card)) + return { actor: state.decision.player, action: { type: 'decision', event: state.richeseBidding!.event, + card, method: 'onceAround', direction: 'counterclockwise' } satisfies Action }; + const lot = state.richeseAuction; + if (truthFlightPoisonClean(state) && lot?.cardId === card && !lot.outcome && lot.active === target && lot.bid === 0) + return { actor: target, action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } satisfies Action }; + } + return nextTruthFlightPoisonNative(state); + }; + const reach = (until: (state: Game) => boolean) => { + for (let count = 0; count < 1800; count++) { + if (until(game)) return; + const step = sourceStep(game); assert.ok(step); act(step.actor, step.action); + } + throw Error('The actual cache acquisition/source boundary must be reached finitely'); + }; + for (const player of game.players) act(player.id, { type: 'ready' }); const lobby = structuredClone(game); + act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); + const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => player.hand.length === 0)); + const originalCards = truthFlightPoisonInventory(game), originalSpice = truthFlightPoisonSpice(game), originalTraitors = truthFlightPoisonTraitors(game); + const startingTruthSelection = ['richese', 'guild'].map(actor => { + const position = game.players.findIndex(player => player.id === actor); + const from = game.deck.findIndex((held, index) => index >= position && held.effect === 'truthtrance'); assert.ok(from >= position); + const truth = game.deck[from].id; [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { actor, position, from, card: truth }; + }); + const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing'); const afterSetup = structuredClone(game); + const front: Game['spiceDeck'] = []; + for (let count = 0; count < (advanced ? 2 : 1); count++) { + const at = game.spiceDeck.findIndex(held => 'territory' in held && !held.discovery); assert.ok(at >= 0); + front.push(game.spiceDeck.splice(at, 1)[0]); + } + game.spiceDeck.unshift(...front); + reach(state => state.decision?.kind === 'richeseCache'); const beforePurchase = structuredClone(game), purchaseAt = actions.length; + reach(state => truthFlightPoisonPlayer(state, target).hand.some(held => held.id === card) && truthFlightPoisonClean(state)); + const acquisition = { before: beforePurchase, steps: structuredClone(actions.slice(purchaseAt)), after: structuredClone(game) }; + assert.equal(truthFlightPoisonPlayer(beforePurchase, target).spice - truthFlightPoisonPlayer(game, target).spice, 1); + const shipper = effect === 'ornithopter' ? target : asker, place = effect === 'ornithopter' ? 'imperial_basin' : 'arrakeen'; + const sector = effect === 'ornithopter' ? 9 : 10, amount = effect === 'ornithopter' ? 2 : 1; + reach(state => state.phase === 5 && state.active === shipper && truthFlightPoisonClean(state)); + const beforeShipment = structuredClone(game), shipmentAt = actions.length; + const shipmentStep: TruthFlightPoisonStep = { actor: shipper, action: { type: 'ship', territory: place, sector, amount, elite: 0, allyPayment: 0 } }; + act(shipper, shipmentStep.action); reach(truthFlightPoisonClean); + const shipment = { before: beforeShipment, step: shipmentStep, steps: structuredClone(actions.slice(shipmentAt)), after: structuredClone(game) }; + assert.equal(truthFlightPoisonPlayer(beforeShipment, shipper).spice - truthFlightPoisonPlayer(game, shipper).spice, effect === 'ornithopter' ? 4 : 1); + let choice: TruthFlightPoisonStep | null = null; + if (effect === 'residualPoison') { + reach(state => state.phase === 6 && truthFlightPoisonClean(state)); + const actual = viewGame(game, game.active!).battleChoices.find(battle => battle.territory === 'arrakeen'); assert.ok(actual); + choice = { actor: actual.chooser, action: { type: 'chooseBattle', territory: 'arrakeen', target: actual.chooser === target ? asker : target } }; + act(choice.actor, choice.action); + reach(state => !!state.battle?.preLeader && !state.battle.preLeader.closed && !state.battle.preparation && truthFlightPoisonClean(state)); + assert.deepEqual(game.battle!.plans, {}); assert.ok(residualPoisonCandidates(truthFlightPoisonPlayer(game, asker).leaders, 'arrakeen').length > 1); + const quote = viewGame(game, target).residualPoison; assert.ok(quote && !quote.blocked && quote.target === asker); + } + assert.ok(truthFlightPoisonClean(game)); assert.equal(!!game.semutaPreview, !advanced); + assert.ok(!game.nexusCards && !game.homeworlds && !game.leaderSkills && !game.techTokens && !game.strongholdCards); + assert.deepEqual(truthFlightPoisonInventory(game), originalCards); assert.deepEqual(truthFlightPoisonSpice(game), originalSpice); + assert.deepEqual(truthFlightPoisonTraitors(game), originalTraitors); assert.equal(game.turnActionPromises?.length ?? 0, 0); + const truths = startingTruthSelection.map(row => row.card), truthHolders = startingTruthSelection.map(row => row.actor); + for (const [index, truth] of truths.entries()) assert.ok(truthFlightPoisonPlayer(game, truthHolders[index]).hand.some(held => held.id === truth)); + const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthFlightPoisonPlayer(game, target).spice }; + const mixed = truthFlightPoisonGroup('and', truthFlightPoisonUse(effect), truthFlightPoisonPayment(recipient)); + const question: TruthQuestion = { kind: 'turnActions', target, turn: game.turn, mixed }; + return { game, source: structuredClone(game), effect, advanced, target, asker, recipient, card, truth: truths[0], truths, truthHolders, + initial, lobby, setup, beforeStartingDeal, startingTruthSelection, afterSetup, spiceOrder: structuredClone(front), acquisition, shipment, choice, + origin: effect === 'ornithopter' ? 'imperial_basin:9' : null, poisonTarget: effect === 'residualPoison' ? asker : null, + event: game.battle?.event ?? null, spiceFact, mixed, question, originalCards, originalSpice, originalTraitors, + actions, sourceActions: actions, sourceActionCount: actions.length, + sourceCounts: { selected: actions.length, nativeCacheBids: actions.filter(step => step.actor === target && step.action.type === 'richeseBid' && step.action.amount === 1).length, + acquisition: acquisition.steps.length, shipment: shipment.steps.length }, + payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action, + end: { type: 'endMovement' } satisfies Action, + sourceDisclosure: 'Genuine public bare Richese/Atreides/Emperor/Guild selected-CHOAM setup in its original Basic or Advanced preview mode. ' + + 'Only two original unopened Richese/Guild Truth deals, original unopened first-turn land Spice positions and scalar lotteries are selected. ' + + 'The holder actually buys its canonical cache card for1. ' + (effect === 'ornithopter' + ? 'Emperor actually pays4 to ship two ordinary counters to Imperial Basin, retaining5 and its unused ground movement. ' + : 'Richese actually pays1 to ship one original counter into original Atreides10 in Arrakeen; actual chooseBattle and native preparation create the preleader window while Atreides retains9. ') + + 'Basic retains its original Semuta marker and native discard passes. No module, mode, played hand, wallet, army, battle, victim, movement, clock or receipt is installed. Source counts derive from actual arrays.', + }; +} +export type TruthFlightPoisonFixture = ReturnType; +export function declareTruthFlightPoison(f: TruthFlightPoisonFixture, source = f.game, copy: 0 | 1 = 0, actions: TruthFlightPoisonStep[] = []) { + const asker = f.truthHolders[copy]; let game = actTruthFlightPoison(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 = actTruthFlightPoison(game, actor.id, { type: 'truthPass' }, actions); + } + assert.equal(game.truthtrance?.stage, 'ask'); return { game, asker, actions }; +} +export function askTruthFlightPoison(game: Game, asker: string, question: TruthQuestion, actions: TruthFlightPoisonStep[] = []): Game { + return actTruthFlightPoison(game, asker, { type: 'truthAsk', question: structuredClone(question) }, actions); +} +export function answerTruthFlightPoison(game: Game, target: string, answer: 'yes' | 'no', actions: TruthFlightPoisonStep[] = []): Game { + return actTruthFlightPoison(game, target, { type: 'truthAnswer', answer }, actions); +} +export function bindTruthFlightPoison(f: TruthFlightPoisonFixture, answer: 'yes' | 'no' = 'yes', question: TruthQuestion = f.question, + source = f.game, copy: 0 | 1 = 0, actions: TruthFlightPoisonStep[] = []) { + const declared = declareTruthFlightPoison(f, source, copy, actions), asked = askTruthFlightPoison(declared.game, declared.asker, question, actions); + return { game: answerTruthFlightPoison(asked, f.target, answer, actions), declared, asked, actions }; +} +export function truthFlightPoisonPromise(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 truthFlightPoisonMove(game: Game, f: TruthFlightPoisonFixture, mode?: OrnithopterMode, from = f.origin!): Action { + assert.equal(f.effect, 'ornithopter'); + return ornithopterHajrMove(game, from, { ...(mode ? { mode } : {}), range: mode === 'range3' ? 3 : 1, elite: 0 }); +} +export function truthFlightPoisonAction(game: Game, f: TruthFlightPoisonFixture): Action { + assert.equal(f.effect, 'residualPoison'); const quote = viewGame(game, f.target).residualPoison; + assert.ok(quote && !quote.blocked && quote.event && quote.target); + return { type: 'card', card: quote.card.id, target: quote.target, event: quote.event }; +} +export function closeTruthFlightPoisonPreparation(game: Game, actions: TruthFlightPoisonStep[] = []): Game { + assert.ok(game.battle?.preLeader); + for (const actor of [game.battle.attacker, game.battle.defender]) { + if (!game.battle!.preLeader!.ready.includes(actor) && !game.battle!.preLeader!.closed) + game = actTruthFlightPoison(game, actor, { type: 'battlePreparationReady', event: game.battle!.preLeader!.event }, actions); + } + assert.equal(game.battle!.preLeader!.closed, true); return game; +} +export function finishTruthFlightPoisonBattle(f: TruthFlightPoisonFixture, source: Game, actions: TruthFlightPoisonStep[] = []) { + assert.equal(f.effect, 'residualPoison'); let game = settleTruthFlightPoison(source, actions); + game = closeTruthFlightPoisonPreparation(game, actions); + const plans: TruthFlightPoisonStep[] = []; + for (const actor of [game.battle!.attacker, game.battle!.defender]) { + if (game.battle!.plans[actor]) continue; + const candidates = residualPoisonCandidates(truthFlightPoisonPlayer(game, actor).leaders, game.battle!.territory) + .sort((a, b) => actor === f.target ? b.strength - a.strength : a.strength - b.strength); + assert.ok(candidates[0]); const step: TruthFlightPoisonStep = { actor, action: { type: 'battlePlan', leader: candidates[0].id, dial: 0, support: 0 } }; + game = actTruthFlightPoison(game, actor, step.action, actions); plans.push(step); + } + game = advanceTruthFlightPoison(game, state => !state.battle && truthFlightPoisonClean(state), actions); + return { game, plans, actions }; +} diff --git a/tests/printed-card-play.test.ts b/tests/printed-card-play.test.ts index 0d7b2d10..ce8871fb 100644 --- a/tests/printed-card-play.test.ts +++ b/tests/printed-card-play.test.ts @@ -9,7 +9,8 @@ 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', 'semutaDrug']); + assert.deepEqual(PRINTED_CARD_EFFECTS, ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug', + 'ornithopter', 'residualPoison']); assert.equal(printedCardPlayCount(undefined, 'richese', 3, 'nullentropyBox'), 0); const ghola = recordPrintedCardPlay(undefined, 'richese', 3, 'ghola'); const first = recordPrintedCardPlay(ghola, 'richese', 3, 'nullentropyBox'); @@ -76,6 +77,25 @@ void test('Semuta counts actual physical resolutions separately from other roles assert.equal(printedCardPlayCount(full, 'richese', 4, 'semutaDrug'), Number.MAX_SAFE_INTEGER); }); +void test('Ornithopter placement and Residual Poison use retain separate exact-role counts without group or victim metadata', () => { + const original = recordPrintedCardPlay(undefined, 'richese', 3, 'ghola'); + let counts = original; + for (const effect of ['ornithopter', 'residualPoison'] as const) { + assert.equal(printedCardPlayCount(counts, 'richese', 3, effect), 0); + counts = recordPrintedCardPlay(counts, 'richese', 3, effect); + assert.equal(printedCardPlayCount(counts, 'richese', 3, effect), 1); + assert.equal(printedCardPlayCount(counts, 'other', 3, effect), 0); + assert.equal(printedCardPlayCount(counts, 'richese', 4, effect), 0); + for (const extra of [{ mode: 'twoGroups' }, { group: 2 }, { victim: 'private-leader' }, { outcome: 'killed' }]) { + const invalid = { turn: 3, plays: [{ player: 'richese', effect, count: 1, ...extra }] } as PrintedCardPlayCounts; + assert.throws(() => printedCardPlayCount(invalid, 'richese', 3, effect), PrintedCardPlayError); + } + const full: PrintedCardPlayCounts = { turn: 3, plays: [{ player: 'richese', effect, count: Number.MAX_SAFE_INTEGER }] }; + assert.throws(() => recordPrintedCardPlay(full, 'richese', 3, effect), PrintedCardPlayError); + } + assert.deepEqual(original, { turn: 3, plays: [{ player: 'richese', effect: 'ghola', count: 1 }] }); +}); + void test('missing legacy counts and other turn/player/effect baselines read as zero', () => { assert.equal(printedCardPlayCount(undefined, 'atreides', 1, 'hajr'), 0); const counts = recordPrintedCardPlay(undefined, 'atreides', 1, 'hajr'); diff --git a/tests/truth-flight-poison-runtime.test.ts b/tests/truth-flight-poison-runtime.test.ts new file mode 100644 index 00000000..b340c312 --- /dev/null +++ b/tests/truth-flight-poison-runtime.test.ts @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { gameDistance, location, splitLocation } from '../game/board'; +import { treacheryDeck } from '../game/cards'; +import { applyAction, 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 { + actTruthFlightPoison as act, advanceTruthFlightPoison as advance, answerTruthFlightPoison as answer, + askTruthFlightPoison as ask, bindTruthFlightPoison as bind, closeTruthFlightPoisonPreparation as closePreparation, + createTruthFlightPoisonFixture as fixture, declareTruthFlightPoison as declare, + finishTruthFlightPoison as finish, finishTruthFlightPoisonBattle as finishBattle, + nextTruthFlightPoisonNative as nativeStep, settleTruthFlightPoison as settle, + truthFlightPoisonAction as poison, truthFlightPoisonClean as clean, + truthFlightPoisonFact as fact, truthFlightPoisonGroup as group, truthFlightPoisonInventory as inventory, + truthFlightPoisonMove as move, truthFlightPoisonPayment as payment, truthFlightPoisonPlayer as player, + truthFlightPoisonPromise as promise, truthFlightPoisonSpice as spice, + truthFlightPoisonTraitors as traitors, truthFlightPoisonUse as use, + type TruthFlightPoisonExpression, type TruthFlightPoisonFixture, type TruthFlightPoisonStep, +} from './fixture-truth-flight-poison'; + +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: TruthFlightPoisonFixture) { + assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(spice(game), f.originalSpice); + assert.deepEqual(traitors(game), f.originalTraitors); +} +const raw = (f: TruthFlightPoisonFixture, mixed: TruthFlightPoisonExpression): TruthQuestion => + ({ kind: 'turnActions', target: f.target, turn: f.game.turn, mixed }); +const stage = (game: Game, f: TruthFlightPoisonFixture, index = 0) => game.turnActionPromises![index].cardPlays?.[f.effect]; +const count = (game: Game, f: TruthFlightPoisonFixture) => printedCardPlayCount(game.printedCardPlays, f.target, game.turn, f.effect); +const pay = (game: Game, f: TruthFlightPoisonFixture, amount: number, actions: TruthFlightPoisonStep[] = []) => + act(game, f.target, { type: 'bribe', target: f.recipient, amount }, actions); +const resources = (game: Game) => game.players.map(candidate => ({ id: candidate.id, spice: candidate.spice, + forces: structuredClone(candidate.forces), reserves: candidate.reserves, tanks: candidate.tanks, + elites: structuredClone(candidate.elites), moved: candidate.moved, shipped: candidate.shipped })); + +void test('flight and Poison sources earn native cache1 cards, real original forces and unopened Truths in both modes', () => { + for (const effect of ['ornithopter', 'residualPoison'] as const) for (const advanced of [false, true]) { + const f = fixture({ effect, advanced }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); + assert.ok(f.beforeStartingDeal.players.every(candidate => candidate.hand.length === 0)); + assert.deepEqual(f.originalCards, [...treacheryDeck(['choam']), ...richeseCards()].map(card => card.id).sort()); + assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.sourceCounts.selected, f.actions.length); + assert.equal(f.sourceCounts.nativeCacheBids, 1); assert.equal(f.sourceCounts.acquisition, f.acquisition.steps.length); + assert.equal(f.sourceCounts.shipment, f.shipment.steps.length); assert.equal(!!f.game.semutaPreview, !advanced); + assert.ok(!f.game.nexusCards && !f.game.homeworlds && !f.game.leaderSkills && !f.game.techTokens && !f.game.strongholdCards); + assert.equal(player(f.acquisition.before, f.target).spice - player(f.acquisition.after, f.target).spice, 1); + 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); + if (effect === 'ornithopter') { + assert.equal(player(f.game, f.target).spice, 5); assert.equal(player(f.game, f.target).forces['imperial_basin:9'], 2); + assert.equal(player(f.game, f.target).elites?.forces['imperial_basin:9'] ?? 0, 0); assert.equal(player(f.game, f.target).moved, 0); + } else { + assert.equal(player(f.game, f.target).spice, 9); assert.equal(player(f.game, f.asker).forces['arrakeen:10'], 1); + assert.equal(player(f.game, f.target).forces['arrakeen:10'], 10); assert.equal(f.game.battle!.preLeader!.closed, false); + assert.deepEqual(f.game.battle!.plans, {}); assert.ok(viewGame(f.game, f.target).residualPoison && !viewGame(f.game, f.target).residualPoison!.blocked); + } + physical(f.game, f); + } +}); + +void test('Basic flight AND frozen fact and all five paid counts the first declaration before disposal with no required second group', () => { + const f = fixture(), mixed = group('and', fact(f.spiceFact), use(f.effect), payment(f.recipient, 5)); + let game = settle(bind(f, 'yes', raw(f, mixed)).game); game = pay(game, f, 5); + assert.equal(player(game, f.target).spice, 0); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); + game = settle(act(game, f.target, move(game, f, 'twoGroups'))); + assert.equal(count(game, f), 1); assert.equal(stage(game, f)?.played, true); assert.equal(promise(game).fulfilled, true); + assert.equal(game.ornithopter?.completed, 1); assert.equal(game.ornithopter?.card.id, f.card); + assert.equal(game.discard.filter(card => card.id === f.card).length, 0); physical(game, f); + game = settle(act(game, f.target, f.end)); + assert.equal(game.ornithopter, null); assert.equal(player(game, f.target).moved, 1); + assert.equal(game.discard.filter(card => card.id === f.card).length, 1); assert.equal(count(game, f), 1); + assert.equal(stage(game, f)?.played, true); assert.deepEqual(promise(game).mixed, mixed); physical(game, f); +}); + +void test('range-three and both distinct two-group routes are actual native flights and count only one printed use', () => { + for (const advanced of [false, true]) for (const mode of ['range3', 'twoGroups'] as const) { + const f = fixture({ advanced }); let game = settle(bind(f, 'yes', raw(f, use(f.effect))).game); + const first = move(game, f, mode), destination = location(String(first.territory), Number(first.sector)); + assert.equal(gameDistance(game, f.origin!, destination, key => splitLocation(key).sector === game.storm), mode === 'range3' ? 3 : 1); + game = settle(act(game, f.target, first)); assert.equal(count(game, f), 1); assert.equal(promise(game).fulfilled, true); + if (mode === 'twoGroups') { + assert.equal(game.ornithopter?.completed, 1); + const second = move(game, f); assert.equal(second.ornithopterEvent, game.ornithopter!.event); assert.equal(second.movementCard, undefined); + assert.equal(second.from, f.origin); game = settle(act(game, f.target, second)); + assert.equal(player(game, f.target).moved, 2); + } else assert.equal(player(game, f.target).moved, 1); + assert.equal(game.ornithopter, null); assert.equal(game.discard.filter(card => card.id === f.card).length, 1); + assert.equal(count(game, f), 1); physical(game, f); + } +}); + +void test('a positive flight answer prevents own ordinary-move or early-end evasion while OR can end and pay later', () => { + const f = fixture({ advanced: true }); let game = settle(bind(f, 'yes', raw(f, use(f.effect))).game); + rejected(game, f.target, f.end); rejected(game, f.target, move(game, f)); assert.equal(count(game, f), 0); + const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'move'); + assert.equal(guide.action.movementCard, f.card); game = settle(act(game, f.target, guide.action)); assert.equal(promise(game).fulfilled, true); + let alternate = settle(bind(f, 'yes', raw(f, group('or', use(f.effect), payment(f.recipient)))).game); + alternate = settle(act(alternate, f.target, f.end)); assert.equal(stage(alternate, f)?.played, false); assert.equal(stage(alternate, f)?.closed, true); + alternate = advance(alternate, state => state.phase === 8 && clean(state)); alternate = pay(alternate, f, 2); + assert.equal(promise(alternate).fulfilled, true); assert.ok(player(alternate, f.target).hand.some(card => card.id === f.card)); + physical(game, f); physical(alternate, f); +}); + +void test('an active flight gives no new-interval credit and its second group or later disposal does not count again', () => { + const f = fixture({ advanced: true }); let game = settle(act(f.game, f.target, move(f.game, f, 'twoGroups'))); + assert.equal(count(game, f), 1); assert.equal(game.ornithopter?.completed, 1); + const event = game.ornithopter!.event, declared = declare(f, game), asked = ask(declared.game, declared.asker, raw(f, use(f.effect))); + assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); + game = settle(answer(asked, f.target, 'no')); assert.equal(stage(game, f)?.played, false); assert.equal(game.ornithopter!.event, event); + game = settle(act(game, f.target, move(game, f))); assert.equal(count(game, f), 1); assert.equal(stage(game, f)?.played, false); + assert.equal(game.discard.filter(card => card.id === f.card).length, 1); game = settle(act(game, f.target, f.end)); + assert.equal(promise(game).fulfilled, true); assert.equal(count(game, f), 1); physical(game, f); +}); + +void test('flight whole No AND permits one branch and No OR keeps its payment cap after own movement closes', () => { + const f = fixture({ advanced: true }), packet = move(f.game, f, 'range3'); + let played = settle(bind(f, 'no').game); played = settle(act(played, f.target, packet)); played = pay(played, f, 1); + rejected(played, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); assert.equal(count(played, f), 1); + let paid = settle(bind(f, 'no').game); paid = pay(paid, f, 2); rejected(paid, f.target, packet); assert.equal(count(paid, f), 0); + let neither = settle(bind(f, 'no', raw(f, group('or', use(f.effect), payment(f.recipient)))).game); + rejected(neither, f.target, packet); neither = pay(neither, f, 1); neither = settle(act(neither, f.target, f.end)); + assert.equal(stage(neither, f)?.closed, true); neither = advance(neither, state => state.phase === 8 && clean(state)); + rejected(neither, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); assert.ok(!promise(neither).released); + neither = finish(neither); assert.equal(promise(neither).fulfilled, true); + physical(played, f); physical(paid, f); physical(neither, f); +}); + +void test('Residual AND frozen fact and all nine paid kills one actual native leader for no fee or bounty before disposal continuation', () => { + for (const advanced of [false, true]) { + const f = fixture({ effect: 'residualPoison', advanced }), mixed = group('and', fact(f.spiceFact), use(f.effect), payment(f.recipient, 9)); + let game = settle(bind(f, 'yes', raw(f, mixed)).game); const before = resources(game), leaders = structuredClone(player(game, f.asker).leaders); + const packet = poison(game, f); assert.equal('leader' in packet, false); game = act(game, f.target, packet); + const victims = player(game, f.asker).leaders.filter(leader => leader.dead && !leaders.find(original => original.id === leader.id)!.dead); + assert.equal(victims.length, 1); assert.equal(victims[0].deaths, leaders.find(original => original.id === victims[0].id)!.deaths + 1); + assert.deepEqual(resources(game), before); assert.equal(count(game, f), 1); assert.equal(stage(game, f)?.played, true); + assert.equal(game.discard.filter(card => card.id === f.card).length, 1); assert.deepEqual(game.battle!.plans, {}); + if (!advanced) assert.ok(game.pendingTreacheryDiscard); + game = settle(game); game = pay(game, f, 9); assert.equal(player(game, f.target).spice, 0); + assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.equal(promise(game).fulfilled, true); + const completed = finishBattle(f, game); game = completed.game; + assert.equal(stage(game, f)?.played, true); assert.equal(count(game, f), 1); assert.equal(promise(game).fulfilled, true); + assert.equal(player(game, f.asker).leaders.find(leader => leader.id === victims[0].id)!.dead, true); physical(game, f); + } +}); + +void test('Residual completion quotes neither sample a random victim nor create hypothetical Tanks or bounty funding', t => { + const f = fixture({ effect: 'residualPoison' }), declared = declare(f), before = structuredClone(declared.game); + let rng = 0; const mock = t.mock.method(crypto, 'getRandomValues', () => { rng++; throw Error('A future-role quote must not choose a victim'); }); + const funded = applyAction(declared.game, declared.asker, { type: 'truthAsk', question: raw(f, group('and', use(f.effect), payment(f.recipient, 9))) }); + assert.deepEqual(viewGame(funded, f.target).truthTurnActionAnswers, ['yes', 'no']); + const unfunded = applyAction(declared.game, declared.asker, { type: 'truthAsk', question: raw(f, group('and', use(f.effect), payment(f.recipient, 10))) }); + assert.deepEqual(viewGame(unfunded, f.target).truthTurnActionAnswers, ['no']); assert.equal(rng, 0); mock.mock.restore(); + assert.deepEqual(declared.game, before); assert.deepEqual(funded.players, before.players); assert.deepEqual(unfunded.players, before.players); + assert.equal(player(funded, f.asker).leaders.filter(leader => leader.dead).length, 0); assert.equal(count(funded, f), 0); physical(funded, f); +}); + +void test('Residual Yes rejects own first leader commitment while a real foreign first plan releases the unfulfilled known-window condition', () => { + const f = fixture({ effect: 'residualPoison', advanced: true }); + let bound = settle(bind(f, 'yes', raw(f, group('and', use(f.effect), payment(f.recipient)))).game); bound = pay(bound, f, 2); + bound = closePreparation(bound); const packet = poison(bound, f); + const ownLeader = player(bound, f.target).leaders.find(leader => !leader.dead && !leader.usedAt)!; + rejected(bound, f.target, { type: 'battlePlan', dial: 0, support: 0, leader: ownLeader.id }); + assert.equal(stage(bound, f)?.played, false); assert.ok(!promise(bound).released); + const enemyLeader = player(bound, f.asker).leaders.find(leader => !leader.dead && !leader.usedAt)!; + const foreign = act(bound, f.asker, { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader.id }); + assert.equal(promise(foreign).released, true); assert.equal(stage(foreign, f)?.played, false); + assert.equal(foreign.turnActionPromises![0].payments[0].amount, 2); assert.ok(player(foreign, f.target).hand.some(card => card.id === f.card)); + rejected(foreign, f.target, packet); assert.equal(player(foreign, f.asker).leaders.filter(leader => leader.dead).length, 0); physical(foreign, f); +}); + +void test('Residual whole OR supports actual manual printed use or payment followed by native battle cleanup with the card unused', () => { + const f = fixture({ effect: 'residualPoison', advanced: true }), question = raw(f, group('or', use(f.effect), payment(f.recipient))); + const bound = settle(bind(f, 'yes', question).game); + const played = settle(act(bound, f.target, poison(bound, f))); assert.equal(promise(played).fulfilled, true); + assert.equal(player(played, f.target).spice, 9); assert.equal(player(played, f.recipient).bribes, 0); + let paid = pay(bound, f, 2); paid = finishBattle(f, paid).game; + assert.equal(promise(paid).fulfilled, true); assert.equal(stage(paid, f)?.played, false); + assert.ok(player(paid, f.target).hand.some(card => card.id === f.card)); assert.equal(player(paid, f.asker).leaders.filter(leader => leader.dead).length, 0); + physical(played, f); physical(paid, f); +}); + +void test('Residual whole No caps payments and blocks the forbidden random effect before any victim is sampled', t => { + const f = fixture({ effect: 'residualPoison', advanced: true }), originalPacket = poison(f.game, f); + let played = settle(bind(f, 'no').game); played = settle(act(played, f.target, originalPacket)); played = pay(played, f, 1); + rejected(played, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); assert.equal(count(played, f), 1); + let paid = settle(bind(f, 'no').game); paid = pay(paid, f, 2); + let neither = settle(bind(f, 'no', raw(f, group('or', use(f.effect), payment(f.recipient)))).game); + for (const game of [paid, neither]) { + const before = structuredClone(game); let rng = 0; + const mock = t.mock.method(crypto, 'getRandomValues', () => { rng++; throw Error('No must reject before random victim selection'); }); + assert.throws(() => applyAction(game, f.target, originalPacket)); assert.equal(rng, 0); mock.mock.restore(); assert.deepEqual(game, before); + assert.equal(count(game, f), 0); assert.equal(player(game, f.asker).leaders.filter(leader => leader.dead).length, 0); + } + neither = pay(neither, f, 1); neither = finishBattle(f, neither).game; + neither = advance(neither, state => state.phase === 8 && clean(state)); rejected(neither, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); + neither = finish(neither); assert.equal(promise(neither).fulfilled, true); assert.equal(stage(neither, f)?.played, false); + physical(played, f); physical(paid, f); physical(neither, f); +}); + +void test('earlier Residual use is not a second original Truth future-play receipt and cannot create a new victim on disposal', () => { + const f = fixture({ effect: 'residualPoison', advanced: true }); let game = settle(act(f.game, f.target, poison(f.game, f))); + assert.equal(count(game, f), 1); const dead = player(game, f.asker).leaders.filter(leader => leader.dead).map(leader => leader.id); + const declared = declare(f, game), asked = ask(declared.game, declared.asker, raw(f, use(f.effect))); + assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); + game = settle(answer(asked, f.target, 'no')); assert.equal(stage(game, f)?.played, false); assert.equal(count(game, f), 1); + assert.deepEqual(player(game, f.asker).leaders.filter(leader => leader.dead).map(leader => leader.id), dead); physical(game, f); +}); + +void test('exact flight and Poison guides bind native packets through JSON and reject stale promise, source or forged victim fields', () => { + for (const effect of ['ornithopter', 'residualPoison'] as const) { + const f = fixture({ effect, advanced: true }); let game = settle(bind(f, 'yes', raw(f, use(f.effect))).game); + const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.event, promise(game).event); + assert.equal(guide.action.turnActionPromiseEvent, promise(game).event); assert.equal(guide.action.turnActionPromisePhase, game.phase); + rejected(game, f.asker, guide.action); rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-flight-poison-answer' }); + rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 8 }); + if (effect === 'ornithopter') { + assert.equal(guide.action.type, 'move'); assert.equal(guide.action.movementCard, f.card); + assert.ok(guide.action.ornithopter === 'range3' || guide.action.ornithopter === 'twoGroups'); + rejected(game, f.target, { ...guide.action, ornithopterEvent: 'already-started-stale-flight' }); + } else { + assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card); assert.equal(guide.action.target, f.asker); + assert.equal(guide.action.event, f.event); assert.equal('leader' in guide.action, false); + rejected(game, f.target, { ...guide.action, event: 'stale-poison-battle' }); + rejected(game, f.target, { ...guide.action, leader: player(game, f.asker).leaders[0].id }); + } + const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, packet)); + assert.equal(promise(game).fulfilled, true); assert.equal(stage(game, f)?.played, true); assert.equal(count(game, f), 1); + rejected(game, f.target, packet); physical(game, f); + } +}); + +void test('four minimal profiles consume exact guides for a human-bound Yes in both roles and modes without requiring strategic answer policy', () => { + for (const effect of ['ornithopter', 'residualPoison'] as const) for (const advanced of [false, true]) { + const f = fixture({ effect, advanced }), declared = declare(f), asked = ask(declared.game, declared.asker, f.question); + for (const difficulty of DIFFICULTIES) { + const answering = viewGame(asked, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty; + const proposed = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(proposed); + assert.ok(answering.truthTurnActionAnswers?.includes(proposed.answer as 'yes' | 'no' | 'unknown')); + let game = answer(structuredClone(asked), f.target, 'yes'); const actions: TruthFlightPoisonStep[] = []; + for (let step = 0; step < 100 && (!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, packet = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.action.turnActionPromiseEvent); + assert.ok(packet); assert.deepEqual(packet, guide.action); game = act(game, f.target, packet, actions); + } else { + const next = nativeStep(game); assert.ok(next); game = act(game, next.actor, next.action, actions); + } + } + assert.equal(promise(game).fulfilled, true); assert.equal(count(game, f), 1); assert.equal(player(game, f.recipient).bribes, 2); + if (effect === 'ornithopter' && game.ornithopter) game = settle(act(game, f.target, f.end, actions), actions); + assert.equal(game.discard.filter(card => card.id === f.card).length, 1); assert.equal(count(game, f), 1); + assert.equal(player(game, f.target).spice, player(f.game, f.target).spice - 2); physical(game, f); + } + } +}); diff --git a/tests/truth-turn-action-controls.test.tsx b/tests/truth-turn-action-controls.test.tsx index 92a8f8f0..0a99622d 100644 --- a/tests/truth-turn-action-controls.test.tsx +++ b/tests/truth-turn-action-controls.test.tsx @@ -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' | 'semutaDrug' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } }); +const printedCard = (effect: 'hajr' | 'ghola' | 'nullentropyBox' | 'distrans' | 'juiceOfSapho' | 'semutaDrug' | 'ornithopter' | 'residualPoison' = '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' @@ -181,6 +181,27 @@ function semutaPlay(stage: 'offer' | 'select' = 'offer', next?: Action): GameVie game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard('semutaDrug'), payment()] }; return game; } +function flightPlay(mode: 'range3' | 'twoGroups' = 'range3', continuation = false): GameView { + const card = richeseCards().find(card => card.effect === 'ornithopter'); assert.ok(card); + const game = projected(5, { type: 'move', forces: { 'arrakeen:10': 3, 'arrakeen:11': 1 }, eliteForces: { 'arrakeen:10': 1 }, + territory: 'carthag', sector: 11, fighters: false, + ...(continuation ? { ornithopterEvent: 'controlled-flight' } : { movementCard: card.id, ornithopter: mode }) }); + const me = game.players.find(player => player.id === 'p')!; me.hand = continuation ? [] : [card]; + me.forces = { 'arrakeen:10': 4, 'arrakeen:11': 1 }; me.moved = continuation ? 1 : 0; + game.ornithopter = { card, blocked: null, modes: [{ mode: 'range3', blocked: null }, { mode: 'twoGroups', blocked: null }], + active: continuation ? { event: 'controlled-flight', mode, completed: 1, remaining: 1, + cohort: { forces: { 'arrakeen:10': 4, 'arrakeen:11': 1 }, elites: { 'arrakeen:10': 1 } } } : null }; + game.turnActionPromises[0].mixed = { kind: 'and', terms: [printedCard('ornithopter'), physical('move', 4, 'carthag'), payment()] }; + return game; +} +function residualPlay(): GameView { + const card = richeseCards().find(card => card.effect === 'residualPoison'); assert.ok(card); + const game = combat(false, { type: 'card', card: card.id, event: 'controlled-native-battle', target: 'a' }); + game.players.find(player => player.id === 'p')!.hand = [card]; + game.residualPoison = { card, event: 'controlled-native-battle', target: 'a', blocked: null }; + game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard('residualPoison'), 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'; @@ -1296,17 +1317,20 @@ void test('turn-action printed-role editor uses public names without holdings or 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, 'ornithopter', { get() { throw Error('No private flight authoring inspection'); }, configurable: true }); + Object.defineProperty(game, 'residualPoison', { get() { throw Error('No private poison 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', 'semutaDrug']); + assert.deepEqual(choices.map(choice => choice.props.value), ['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug', 'ornithopter', 'residualPoison']); 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', 'semutaDrug'] as const) { + for (const effect of ['distrans', 'juiceOfSapho', 'semutaDrug', 'ornithopter', 'residualPoison'] 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' }]) + for (const extra of [{ target: 'r' }, { give: 'private-card' }, { scope: 'movement' }, { event: 'private-source' }, { mode: 'first' }, + { territory: 'carthag' }, { ornithopter: 'range3' }, { victim: 'private-leader' }]) assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect, ...extra } }, game, 'p')); } assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'ghola', card: 'private-card-id' } }, game, 'p')); @@ -1324,17 +1348,20 @@ void test('turn-action printed-role editor uses public names without holdings or 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, /Declare a physical Ornithopter flight after this answer this turn/); + assert.match(html, /Complete a printed Residual Poison play after this answer this turn/); + assert.match(html, /Later groups and disposal do not count again/); 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 % 3 === 0 ? 'semutaDrug' : count % 2 ? 'distrans' : 'juiceOfSapho'); + select(full.tree(), `1.${count}`, 'effect', count % 2 ? 'ornithopter' : 'residualPoison'); } 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('semutaDrug')] }, false, game); + const deep = editor({ kind: 'and', terms: [fact(), printedCard('residualPoison')] }, 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'); @@ -1673,9 +1700,106 @@ void test('Semuta guides reject extra fields, offer-stage selection and fake car assert.equal(guide({ game: unrelated, busy: false, act() { assert.fail('A pending fresh discard owns its native controls'); } }).props.disabled, true); }); +void test('Ornithopter guides preserve exact native initial modes and played-zone continuation without requiring a new role atom', () => { + for (const mode of ['range3', 'twoGroups'] as const) { + const game = flightPlay(mode), sent: Action[] = []; + Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private flight receipt inspection'); } }); + const button = guide({ game, busy: false, act(action) { sent.push(action); } }); + assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action); + assert.equal(sent[0].movementCard, 'richese-ornithopter'); assert.equal(sent[0].ornithopter, mode); + assert.equal(Object.hasOwn(sent[0], 'ornithopterEvent'), false); + assert.deepEqual(sent[0].eliteForces, { 'arrakeen:10': 1 }); assert.equal(sent[0].fighters, false); + assert.match(markup(game), /Declare Ornithopter/); + } + const game = flightPlay('twoGroups', true), sent: Action[] = []; + game.turnActionPromises[0].mixed = claim(); + assert.equal(game.players.find(player => player.id === 'p')!.hand!.length, 0); + 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.equal(sent[0].ornithopterEvent, 'controlled-flight'); assert.equal(Object.hasOwn(sent[0], 'movementCard'), false); + assert.equal(Object.hasOwn(sent[0], 'ornithopter'), false); + const preparation = flightPlay(); preparation.turnActionPromises[0].mixed = claim(); + assert.equal(guide({ game: preparation, busy: false, act() {} }).props.disabled, false); +}); + +void test('Ornithopter quotes and active events cannot be bypassed by generic move packets or stale callbacks', () => { + for (const change of [ + (game: GameView) => { game.ornithopter = null; }, + (game: GameView) => { game.ornithopter = { ...game.ornithopter! }; }, + (game: GameView) => { game.ornithopter!.modes[0].blocked = 'A binding No prevents this printed use.'; }, + (game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; }, + (game: GameView) => { game.players.find(player => player.id === 'p')!.forces['arrakeen:10'] = 1; }, + (game: GameView) => { game.players.find(player => player.id === 'p')!.moved = 1; }, + (game: GameView) => { game.active = 'a'; }, + (game: GameView) => { game.turnActionPromises[0].mixed = payment(); }, + (game: GameView) => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; }, + ]) { + const game = flightPlay(), 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 extra of [{ movementCard: 'unheld-flight-card' }, { ornithopter: 'unsupported' }, { ornithopterEvent: 'old-flight' }, + { target: 'a' }, { leader: 'private-leader' }, { mode: 'range3' }]) { + const game = flightPlay(); Object.assign(game.turnActionGuide!.action, extra); + assert.equal(guide({ game, busy: false, act() { assert.fail('A new flight must use the current native declaration'); } }).props.disabled, true); + } + for (const extra of [{ ornithopterEvent: 'old-flight' }, { movementCard: 'richese-ornithopter' }, { ornithopter: 'twoGroups' }]) { + const game = flightPlay('twoGroups', true); Object.assign(game.turnActionGuide!.action, extra); + assert.equal(guide({ game, busy: false, act() { assert.fail('A continuation cannot declare or recount its played card'); } }).props.disabled, true); + } + const missingEvent = flightPlay('twoGroups', true); delete missingEvent.turnActionGuide!.action.ornithopterEvent; + assert.equal(guide({ game: missingEvent, busy: false, act() { assert.fail('An active flight requires its current event'); } }).props.disabled, true); + const current = flightPlay('twoGroups', true), sent: Action[] = [], saved = guide({ game: current, busy: false, act(action) { sent.push(action); } }); + assert.equal(saved.props.disabled, false); current.ornithopter!.active!.event = 'next-flight'; press(saved); assert.deepEqual(sent, []); + const direct = flightPlay(); direct.turnActionGuide!.action = { type: 'card', card: 'richese-ornithopter', + turnActionPromiseEvent: event, turnActionPromisePhase: 5 }; + assert.equal(guide({ game: direct, busy: false, act() { assert.fail('Ornithopter requires a physical native flight declaration'); } }).props.disabled, true); + const no = flightPlay(); no.turnActionPromises[0].answer = false; no.ornithopter!.blocked = 'A binding No prevents this printed use.'; + assert.equal(guide({ game: no, busy: false, act() { assert.fail('The generic route cannot bypass the native No quote'); } }).props.disabled, true); +}); + +void test('Residual Poison guides use the exact canonical card and actual current opponent without inspecting leaders', () => { + const game = residualPlay(), sent: Action[] = []; + for (const player of game.players) { + Object.defineProperty(player, 'leaders', { get() { throw Error('The native quote owns the random victim pool'); }, configurable: true }); + if (player.id !== 'p') Object.defineProperty(player, 'hand', { get() { throw Error('No foreign custody inspection'); }, configurable: true }); + } + Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private poison receipt inspection'); } }); + const button = guide({ game, busy: false, act(action) { sent.push(action); } }); + assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action); + assert.deepEqual(sent[0], { type: 'card', card: 'richese-residual-poison', event: 'controlled-native-battle', target: 'a', + turnActionPromiseEvent: event, turnActionPromisePhase: 6 }); + assert.match(markup(game), /Play Residual Poison against Asker/); assert.match(markup(game), /the native leader death is random/); +}); + +void test('Residual Poison rejects blocked No quotes, stale battles, false targets and victim selectors without generic fallback', () => { + for (const change of [ + (game: GameView) => { game.residualPoison = null; }, + (game: GameView) => { game.residualPoison = { ...game.residualPoison! }; }, + (game: GameView) => { game.residualPoison!.blocked = 'A binding No prevents this printed use.'; }, + (game: GameView) => { game.residualPoison!.event = 'next-battle'; }, + (game: GameView) => { game.battle!.revealed = true; }, + (game: GameView) => { game.battle!.event = 'next-battle'; }, + (game: GameView) => { game.battle!.defender = 'r'; }, + (game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; }, + (game: GameView) => { game.turnActionPromises[0].mixed = planClaim(); }, + (game: GameView) => { game.decision = { kind: 'auctionPayment', player: 'p' }; }, + ]) { + const game = residualPlay(), 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 extra of [{ event: 'old-battle' }, { target: 'p' }, { target: 'r' }, { leader: 'selected-victim' }, + { victim: 'selected-victim' }, { amount: 1 }, { mode: 'poison' }, { random: 0 }]) { + const game = residualPlay(); Object.assign(game.turnActionGuide!.action, extra); + const button = guide({ game, busy: false, act() { assert.fail('Only the actual printed Residual Poison packet is allowed'); } }); + assert.equal(button.props.disabled, true); press(button); + } + const no = residualPlay(); no.turnActionPromises[0].answer = false; no.residualPoison!.blocked = 'A binding No prevents this printed use.'; + assert.equal(guide({ game: no, busy: false, act() { assert.fail('Generic preparation cannot bypass the native No quote'); } }).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'), printedCard('semutaDrug')] }] }; + game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard(), { kind: 'and', terms: [fact(), printedCard('ghola'), printedCard('nullentropyBox'), printedCard('distrans'), printedCard('juiceOfSapho'), printedCard('semutaDrug'), printedCard('ornithopter'), printedCard('residualPoison')] }] }; 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 }); @@ -1689,6 +1813,10 @@ void test('printed-card history retains only raw aggregate conditions and their 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, /Ornithopter counts its actual physical flight declaration after the answer/); + assert.match(html, /Later groups and disposal do not count again/); + assert.match(html, /Residual Poison counts the actual printed play, random leader death and disposal after the answer/); + assert.match(html, /Its whole No deadline is the original turn end/); assert.match(html, /remains open until the actual turn ends/); assert.doesNotMatch(html, /cardPlays|compiled|controlled-hajr|controlled-ghola/); }); @@ -1720,8 +1848,6 @@ void test('turn-action guide forwards exact shipment, movement, preparation and projected().turnActionGuide!.action, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 4, elite: 1, allyPayment: 0 }, { type: 'guildShip', from: 'reserves', territory: 'carthag', sector: 11, amount: 6, elite: 0, allyPayment: 3 }, - { type: 'move', territory: 'carthag', sector: 11, forces: { 'arrakeen:10': 4 }, eliteForces: {}, - movementCard: 'controlled-ornithopter', ornithopter: 'twoGroups', ornithopterEvent: 'controlled-flight' }, { type: 'card', card: 'controlled-hajr' }, { type: 'prepareAdvisors', territory: 'arrakeen' }, { type: 'revealNoField', territory: 'arrakeen', sector: 10 }, { type: 'endMovement' }, ]; diff --git a/tests/truthtrance.test.ts b/tests/truthtrance.test.ts index be1116cc..c188afdf 100644 --- a/tests/truthtrance.test.ts +++ b/tests/truthtrance.test.ts @@ -94,6 +94,26 @@ void test('Semuta future-use questions require completed physical exchange witho } }); +void test('Ornithopter and Residual Poison questions name actual native play and the proper deadline without chosen details', () => { + const game = fixture(); + for (const effect of ['ornithopter', 'residualPoison'] as const) { + const action = { kind: 'cardPlay' as const, effect }; + const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2, + mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } }, + { kind: 'action', action }] } }; + assert.doesNotThrow(() => validateTruthQuestionReceipt(game, 'a', question)); + const text = truthQuestionText(question, id => id, id => id); + assert.match(text, effect === 'ornithopter' ? /play Ornithopter into a native flight after this answer before your Shipment and Movement turn ends/ : + /play Residual Poison in its printed role after this answer this turn/); + assert.doesNotMatch(text, /twoGroups|private-leader|killed|Semuta|Ghola/); + for (const extra of [{ mode: 'twoGroups' }, { group: 2 }, { victim: 'private-leader' }, { outcome: 'killed' }]) { + 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( diff --git a/tests/turn-action-promises.test.ts b/tests/turn-action-promises.test.ts index a5b15c35..5505e3a5 100644 --- a/tests/turn-action-promises.test.ts +++ b/tests/turn-action-promises.test.ts @@ -1693,7 +1693,7 @@ 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', 'semutaDrug'] as const; + const optional = PRINTED_CARD_EFFECTS.filter(effect => effect !== 'hajr' && effect !== 'ghola'); for (let mask = 0; mask < (1 << optional.length); mask++) { const cardPlays = { ...base.cardPlays! }; optional.forEach((effect, index) => { if (mask & (1 << index)) cardPlays[effect] = null; }); @@ -1807,3 +1807,59 @@ void test('Semuta No remains binding through other deadlines and physical use or assert.deepEqual(used[0].payments, []); validateTurnActionPromises([expired], seats); }); + +void test('Movement end closes only the owner Ornithopter interval while Residual Poison retains its actual turn deadline', () => { + const no = cards(group('or', card('ornithopter'), card('residualPoison')), false); + assert.equal(closeTurnActionTransport([no], 'other', 1)[0], no); + assert.equal(closeTurnActionTransport([no], 'respondent', 2)[0], no); + assert.throws(() => recordTurnActionCardPlay([no], 'respondent', 1, 'ornithopter'), TurnActionError); + const moved = closeTurnActionTransport([no], 'respondent', 1); + assert.deepEqual(moved[0].cardPlays!.ornithopter, { played: false, closed: true }); + assert.deepEqual(moved[0].cardPlays!.residualPoison, { played: false, closed: false }); + assert.equal(moved[0].fulfilled, false); + assert.equal(recordTurnActionCardPlay(moved, 'respondent', 1, 'ornithopter')[0], moved[0]); + assert.throws(() => recordTurnActionCardPlay(moved, 'respondent', 1, 'residualPoison'), TurnActionError); + const paidClose = closeTurnActionPayments(moved, 'respondent', 1); + assert.equal(paidClose[0].fulfilled, false); + const expired = finishTurnActionTurn(paidClose, 'respondent', 1)[0]; + assert.equal(expired.fulfilled, true); + assert.deepEqual(expired.cardPlays!.residualPoison, { played: false, closed: true }); + const orniOnly = cards(card('ornithopter'), false, 'turn-action:2'); + const closed = closeTurnActionTransport([orniOnly], 'respondent', 1)[0]; + assert.equal(closed.fulfilled, true); + assert.equal(recordTurnActionCardPlay([closed], 'respondent', 1, 'ornithopter')[0], closed); + const old = cards(card('ghola'), false, 'turn-action:3'); + assert.equal(Object.hasOwn(closeTurnActionTransport([old], 'respondent', 1)[0].cardPlays!, 'ornithopter'), false); + const explicitNull = { ...old, cardPlays: { ...old.cardPlays!, ornithopter: null } }; + assert.equal(closeTurnActionTransport([explicitNull], 'respondent', 1)[0].cardPlays!.ornithopter, null); + validateTurnActionPromises([expired, closed], seats); +}); + +void test('Ornithopter and Residual Poison whole receipts preserve dynamic alternatives and reject selected modes or victims', () => { + const joint = cards(group('and', card('ornithopter'), card('residualPoison'), pay())); + const alternative = cards(group('or', card('ornithopter'), card('residualPoison')), true, 'turn-action:2'); + for (const effect of ['ornithopter', 'residualPoison'] as const) { + for (const extra of [{ mode: 'twoGroups' }, { group: 2 }, { victim: 'private-leader' }, { outcome: 'killed' }]) { + assert.throws(() => parseTurnActionLeaf({ kind: 'cardPlay', effect, ...extra }), TurnActionError); + const invalid = { ...joint, cardPlays: { ...joint.cardPlays!, [effect]: { played: false, closed: false, ...extra } } }; + assert.throws(() => turnActionValue(invalid), TurnActionError); + } + } + const flight = recordTurnActionCardPlay([joint, alternative], 'respondent', 1, 'ornithopter'); + assert.equal(flight[1].fulfilled, true); + assert.deepEqual(branches(flight[0]), [[{ kind: 'cardPlay', effect: 'residualPoison', answer: true }, signedPay()]]); + assert.equal(recordTurnActionCardPlay(flight, 'respondent', 1, 'ornithopter')[0], flight[0]); + const moved = closeTurnActionTransport(flight, 'respondent', 1); + const poisoned = recordTurnActionCardPlay(moved, 'respondent', 1, 'residualPoison'); + assert.deepEqual(branches(poisoned[0]), [[signedPay()]]); + assert.equal(recordTurnActionPayment(poisoned, 'respondent', 1, payment())[0].fulfilled, true); + const no = cards(group('and', card('ornithopter'), card('residualPoison')), false, 'turn-action:3'); + const one = recordTurnActionCardPlay([no], 'respondent', 1, 'ornithopter'); + const before = structuredClone(one); + assert.throws(() => recordTurnActionCardPlay(one, 'respondent', 1, 'residualPoison'), TurnActionError); + assert.deepEqual(one, before); + const fresh = cards(card('ornithopter'), true, 'turn-action:4'); + assert.equal(fresh.cardPlays!.ornithopter!.played, false); + assert.equal(recordTurnActionCardPlay([fresh], 'respondent', 1, 'ornithopter')[0].fulfilled, true); + validateTurnActionPromises(poisoned, seats); +});