diff --git a/components/turn-action-fields.tsx b/components/turn-action-fields.tsx index ff299646..35e6a943 100644 --- a/components/turn-action-fields.tsx +++ b/components/turn-action-fields.tsx @@ -76,10 +76,13 @@ 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 or Tleilaxu Ghola in its printed role after this answer this turn. + Printed-card conditions ask you to play Hajr, Tleilaxu Ghola or Nullentropy Box in its printed role after this answer this turn. An earlier play, discard, sale, Karama role or recovery alone does not count; an actual later replay after Box recovery does. Hajr counts when played even if its extra movement is unused. Tleilaxu Ghola is a card play, separate from ordinary Revival. Hajr conditions close at your own Movement turn end; Tleilaxu Ghola conditions close at the actual turn end. + Nullentropy Box counts when its printed activation is accepted and you pay 2 spice to begin the private search. + Earlier activation or recovery alone does not count. The selected recovered card stays private and is not promised by this condition. + Box conditions close at the actual turn end; complete the paid search with the existing native controls. 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. @@ -166,7 +169,7 @@ const config: MixedActionConfig = { if (kind === 'ship' || kind === 'move') onChange({ kind, territory: value.kind === 'ship' || value.kind === 'move' ? value.territory : 'carthag', minimum }); }}> - + @@ -182,12 +185,13 @@ const config: MixedActionConfig = { {value.kind === 'cardPlay' ? <> : value.kind === 'windowCardPlay' ? <> diff --git a/components/turn-action-promises.tsx b/components/turn-action-promises.tsx index 66c94f46..d16adad0 100644 --- a/components/turn-action-promises.tsx +++ b/components/turn-action-promises.tsx @@ -33,6 +33,9 @@ const windowRoleNames: Record = { weather: 'Weather Co const namesWindow = (mixed: MixedActionExpression, effect: WindowCardEffect, source?: string): boolean => mixed.kind === 'action' ? mixed.action.kind === 'windowCardPlay' && mixed.action.effect === effect && (source === undefined || mixed.action.source === source) : mixed.kind !== 'fact' && mixed.terms.some(term => namesWindow(term, effect, source)); +const namesBoxPlay = (mixed: MixedActionExpression): boolean => mixed.kind === 'action' + ? mixed.action.kind === 'cardPlay' && mixed.action.effect === 'nullentropyBox' + : mixed.kind !== 'fact' && mixed.terms.some(namesBoxPlay); /** One projected native packet; no client branch selection or completion solver. */ export function TurnActionGuideButton(props: TurnActionProps) { @@ -72,6 +75,7 @@ export function TurnActionGuideButton(props: TurnActionProps) { const box = game.nullentropy, search = box?.search; const windowPromise = game.turnActionPromises.find(promise => promise.event === event && promise.player === actor && promise.turn === turn); const windowQuestion = windowPromise && JSON.stringify(windowPromise.mixed); + const boxRoleNamed = !!windowPromise && namesBoxPlay(windowPromise.mixed); const opening = game.phaseOpening, openingSignature = JSON.stringify(opening); const currentAmalWindow = windowCardOpportunity(game, 'amal'); const amalRoleNamed = !!windowPromise && !!currentAmalWindow && namesWindow(windowPromise.mixed, 'amal', currentAmalWindow.source); @@ -108,7 +112,16 @@ export function TurnActionGuideButton(props: TurnActionProps) { }); const windowSignature = windowEffect || heldCard?.effect === 'nullentropyBox' || earlyWindowReadyAction || amalReadyAction ? publicWindowState(game) : null; const originalBlow = windowEffect === 'harvester' || earlyWindowReadyAction && phase === 1 ? game.spiceWindow : null; - const earlyBoxSignature = box && JSON.stringify({ card: box.card.id, blocked: box.blocked, search: box.search?.event }); + const boxSource = (quote: GameView['nullentropy']) => quote && JSON.stringify({ + card: { id: quote.card.id, kind: quote.card.kind, effect: quote.card.effect }, + blocked: quote.blocked, search: quote.search?.event, fullHandExchange: quote.fullHandExchange, + }); + const earlyBoxSignature = boxSource(box); + const boxOwnerSource = (current: GameView) => { + const owner = current.players.find(player => player.id === actor); + return JSON.stringify({ cash: owner?.spice, hand: owner?.hand?.map(card => card.id), limit: owner?.handLimit }); + }; + const boxOwnerSignature = heldCard?.effect === 'nullentropyBox' ? boxOwnerSource(game) : null; const auctionCards = game.karamaAuctionCards, auctionCardsSignature = JSON.stringify(auctionCards); const shipmentCards = game.karamaShipmentCards, shipmentCardsSignature = JSON.stringify(shipmentCards); const thumperCards = game.thumperCards, thumperCardsSignature = JSON.stringify(thumperCards); @@ -188,15 +201,20 @@ export function TurnActionGuideButton(props: TurnActionProps) { const printedAuctionInvoice = printedInvoiceAction && printedWindowSource && nativeAuctionCard && Object.keys(action).every(key => ['type', 'card', 'karama', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key)); const quotedBoxCard = heldCard?.effect !== 'nullentropyBox' || !!box && current.nullentropy === box && - JSON.stringify({ card: box.card.id, blocked: box.blocked, search: box.search?.event }) === earlyBoxSignature && - !box.blocked && !box.search && box.card.id === heldCard.id; + boxSource(box) === earlyBoxSignature && boxOwnerSource(current) === boxOwnerSignature && + box.blocked === null && !box.search && box.card.id === heldCard.id && box.card.kind === 'special' && box.card.effect === 'nullentropyBox' && + Object.keys(action).every(key => ['type', 'card', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key)); + const printedBox = ownCard && !!heldCard && heldCard.kind === 'special' && heldCard.effect === 'nullentropyBox' && + quotedBoxCard && !!windowPromise && current.turnActionPromises.includes(windowPromise) && + JSON.stringify(windowPromise.mixed) === windowQuestion && namesBoxPlay(windowPromise.mixed); const genericCard = ownCard && action.type === 'card' && !auctionPurchaseAction && !printedShipmentAction && quotedBoxCard && + (!boxRoleNamed || heldCard?.effect !== 'nullentropyBox' || printedBox) && (!shipmentPriceAction || (action.target ?? actor) === current.active) && !!heldCard && heldCard.effect !== 'ghola' && heldCard.effect !== 'hajr' && !isWindowEffect(heldCard.effect); 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 && - JSON.stringify({ card: box.card.id, blocked: box.blocked, search: box.search?.event }) === earlyBoxSignature && + boxSource(box) === earlyBoxSignature && !box.blocked && !box.search && box.card.id === heldCard.id && !!windowPromise && current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion && WINDOW_CARD_EFFECTS.some(effect => { @@ -275,7 +293,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 = openingReady || earlyWindowReady || earlyWindowBox || printedWindow || ownedPower || payment || paidBox || printedHajr || printedGhola || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2 + const opportunity = printedBox || openingReady || earlyWindowReady || earlyWindowBox || printedWindow || ownedPower || payment || paidBox || printedHajr || printedGhola || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2 ? charityClaim || !current.ready.includes(actor) && (action.type === 'ready' || genericCard) : phase === 3 ? invoice || nativePass || requiredBid || action.type === 'passBid' && !current.richeseAuction && currentAuction && current.auction?.active === actor @@ -311,6 +329,7 @@ export function TurnActionGuideButton(props: TurnActionProps) { : `Play Tleilaxu Ghola to return ${amount} physical forces while honoring your whole answer` : action.type === 'card' && windowEffect === 'karamaAuction' ? 'Spend a printed Karama to purchase this ordinary lot while honoring your whole answer' : action.type === 'card' && windowEffect === 'karamaShipment' ? `Spend a printed Karama to grant ${recipient} this native shipment price while honoring your whole answer` + : action.type === 'card' && heldCard?.effect === 'nullentropyBox' ? 'Pay 2 spice to activate Nullentropy Box while honoring your whole answer' : action.type === 'card' && 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))}` @@ -403,10 +422,13 @@ 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 or Tleilaxu Ghola plays in their printed role after the answer this turn. + Printed-card conditions count actual future Hajr, Tleilaxu Ghola or Nullentropy Box plays in their printed role after the answer this turn. Earlier plays, discards, sales, Karama roles and recovery alone do not count; an actual later Box replay does. Hajr counts even if its extra movement is unused and closes at your own Movement turn end. Tleilaxu Ghola is separate from ordinary Revival and remains open until the actual turn ends. + Nullentropy Box counts when its printed activation is accepted and you pay 2 spice to begin the private search. + Earlier activation or recovery alone does not count. The selected recovered card stays private and is not promised by this condition. + Box conditions close at the actual turn end; complete the paid search with the existing native controls. 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. @@ -466,8 +488,9 @@ 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 and Tleilaxu Ghola conditions require actual later printed-role plays, not earlier play, disposal or recovery alone. - Hajr closes at your own Movement turn end even if its extra move is unused; Ghola closes at the actual turn end. + Hajr, Tleilaxu Ghola and Nullentropy Box conditions require actual later printed-role plays, not earlier play, disposal or recovery alone. + Box counts at accepted activation and payment of 2 spice; the recovered card is private and is not promised by this condition. + Hajr closes at your own Movement turn end even if its extra move is unused; Ghola and Box close at the actual turn end. 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 19f5f43c..59b8de0a 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,34 @@ open mode gates. Continue remaining Basic functions before Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Truthtrance / printed Nullentropy Box — 27 October. Prototyped / Partial.** +Whole questions can name actual printed activation after answering. The accepted +two-spice fee records use before private selection; the selected card is outside +the predicate. Shared payment/shipment funding, whole AND/OR/No, frozen facts, +late Ready, exact human guides and four legal profiles are connected. Unknown +unpaid recovery cannot fund another effect or battle card. Basic retains native +Semuta continuation; Kull, legacy bribe and module envelopes remain separate. + +Frozen **20 files, 311/311**, types/lint/build and five journey groups +(six Basic/Advanced programmes) pass. Runtime evidence includes 36 GameTable SSR +renders, 6 actual answer callbacks, 4 guide callbacks, 2 native Box +activation callbacks and 1 paid Take callback. Genuine sources earn the +cache Box and ordinary Hajr purchases; Basic keeps its actual Semuta pause. +Source/continuation counts and inventories derive from native arrays. A separate +explicit detached supplement checks opaque battle-card quotation and claims no +new native acquisition. No full rules/combination certificate is implied. + +Feedback fixes Basic's separate parser/whole reconciliation admission, a malformed +test cast and a const declaration. Bounded review also excludes opaque weapon, +defense and Hero identities from private battle witnesses. The Guild sheet now +reflects its already integrated separate permission cancellation. + +Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-box-play-proof-v3/`; journeys/HTML: adjacent `20261027-truth-box-play-runtime/run/`. +No HTTP/storage boundary, saved-game/database, restart or deployed operation +changes. Broader future actions/funding, optional combinations, full modes, +browser/hydration and final assurance/deployed acceptance remain open. +[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-nullentropy-box--27-october-2026). + **Truthtrance / CHOAM opening-income installments — 27 October. Prototyped / Partial.** Whole, bare, grouped and mixed payments can share one native automatic CHOAM receipt plus one distinct ordinary Charity claim. Current guides spend only diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 153896ae..29f15113 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,27 @@ # Dune implementation status +## 27 October 2026 — Printed Nullentropy Box commitments + +Prototyped / Partial. Actual paid activation records the future printed play; +private selection remains native and supplies no assumed unknown effect. +One purse funds Box, payments and physical actions. Basic's real Semuta pause, +whole No and late readiness retain their original controls. Opaque battle +candidates now exclude unknown weapons/defenses/Heroes; a detached supplement +labels its forecast-only metadata. The stale Guild permission sentence is fixed. + +Frozen **20 files, 311/311**, types/lint/build and five journey groups +(six Basic/Advanced programmes) pass. Runtime evidence includes 36 GameTable SSR +renders, 6 actual answer callbacks, 4 guide callbacks, 2 native Box +activation callbacks and 1 paid Take callback. Genuine sources earn the +cache Box and ordinary Hajr purchases; Basic keeps its actual Semuta pause. +Source/continuation counts and inventories derive from native arrays. A separate +explicit detached supplement checks opaque battle-card quotation and claims no +new native acquisition. No full rules/combination certificate is implied. +No strategic AI, saved-game operation, server restart or deployment occurs. +Wider future actions/funding, full modes and final acceptance remain unfinished. +[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-nullentropy-box--27-october-2026). +Private proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-box-play-proof-v3/`. + ## 27 October 2026 — CHOAM opening-income payment installments Prototyped / Partial. Payment witnesses can use the owned automatic native diff --git a/docs/NULLENTROPY_BOX_ENGINE_AUDIT.md b/docs/NULLENTROPY_BOX_ENGINE_AUDIT.md index e61073ee..602c7752 100644 --- a/docs/NULLENTROPY_BOX_ENGINE_AUDIT.md +++ b/docs/NULLENTROPY_BOX_ENGINE_AUDIT.md @@ -2,6 +2,17 @@ Audit: 2026-09-06, with Basic and Advanced runtime amendments on 8 October below. This is a source/continuation contract, not complete Richese certification. Read with [the acquisition audit](RICHESE_ACQUISITION_RULES.md). The full-hand publisher question remains unresolved; the user's first-version instruction permits the visibly qualified prototype, not a new timing ruling. +## Printed-use Truthtrance commitment — 27 October 2026 + +Whole turn-action questions can now name a future printed Box activation. Actual +accepted payment records it before private selection, and the fee shares the +remaining purse with other obligations. No recovered identity is promised or +exposed. Unpaid unknown recovery cannot fund another effect. Current exact +guides and existing paid-selection controls retain their separate ownership and +native priority. Basic keeps its real Semuta continuation. This does not change +the printed cost, disposal order or existing provisional capacity/refund policy. +[Current contract and evidence](TRUTHTRANCE_TURN_ACTIONS.md#printed-nullentropy-box--27-october-2026). + ## Shipping-price cost recovery — 26 October 2026 Box can now search while the original purchased-shipment cost remains in diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 0ad9fb6d..f9dfbce4 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,19 @@ # Rule decision index +## Truthtrance printed Box activation — 27 October 2026 + +Prototyped / Partial. A future printed Nullentropy Box play counts when its real +two-spice search payment is accepted, before private selection. The selected +card is outside the predicate. Reuse native cost, exchange and known recovery +rules; an unpaid unknown card cannot supply another promised effect. Share +funding with every other commitment and check voluntary loss before inspection. +The exact already-declared Truthtrance cost is a guaranteed future discard, +without choosing a foreign reaction. Native Basic Semuta quiet windows now +retain whole answers through their real discard continuation; legacy bribe, +Kull and module envelopes stay separate. This is explicit software composition, +not a dedicated publisher Truthtrance example or full mode acceptance. +[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-nullentropy-box--27-october-2026). + ## Truthtrance CHOAM opening-income promises — 27 October 2026 Prototyped / Partial. Compose the native CHOAM opening receipt with ordinary diff --git a/docs/TRUTHTRANCE_TURN_ACTIONS.md b/docs/TRUTHTRANCE_TURN_ACTIONS.md index 20b8282c..b147dcac 100644 --- a/docs/TRUTHTRANCE_TURN_ACTIONS.md +++ b/docs/TRUTHTRANCE_TURN_ACTIONS.md @@ -1,5 +1,53 @@ # Truthtrance combined current-turn actions +## Printed Nullentropy Box — 27 October 2026 + +**Prototyped / Partial.** `cardPlay/nullentropyBox` concerns an accepted printed +Box activation after answering in this turn. Its actual two-spice payment +commits the search and records the play once, before the native private choice. +The question does not name the recovered card. An earlier activation, recovering +the used Box or another role is not a new play. No remains binding through the +current turn, including Mentat; Movement end does not close this card's interval. + +The detached witness pays the same native fee and preserves the physical +exchange without selecting/shuffling a real game. Known owned recovery routes +retain their existing meaning; an unpaid unknown recovered card is opaque and +cannot supply another promised effect. Payments, shipment, Revival and battle +share the remaining purse. Printed-cost preflight rejects voluntary loss before +paid inspection can suspend the table. The current declared Truthtrance's exact +guaranteed discard is available after its answer, without disposing of another +queued question or choosing an opponent's Semuta response. + +Basic native Richese keeps its existing Semuta marker and original discard +continuation. Whole-answer admission and reconciliation now support that quiet +native envelope. A pending claim/search retains native priority; the separate +legacy bribe, Kull and optional-module envelopes are unchanged. This adds no game +start permission or full combination certification. + +The editor names the printed role, exact current Box guides use current native +availability, and paid selection remains in the existing private search panel. +No candidates or selected identity enter an unpaid guide. Accepted-order +promises, whole AND/OR/No, frozen facts and four minimally legal profiles remain +connected. Old Hajr/Ghola rows keep their shape; only Box questions add the +optional Box stage. Full strategy and calibration remain deferred. + +This is bounded composition of existing Truthtrance and the +[native Box policy](NULLENTROPY_BOX_ENGINE_AUDIT.md#verified-authority-and-remaining-boundaries), +not a separate publisher worked example. Empty-search/capacity policies retain +their existing provisional scope. Other future actions and funding, wider +combinations, complete rules and final assurance/deployed acceptance remain open. +Frozen **20 files, 311/311**, types/lint/build and five journey groups +(six Basic/Advanced programmes) pass. Runtime evidence includes 36 GameTable SSR +renders, 6 actual answer callbacks, 4 guide callbacks, 2 native Box +activation callbacks and 1 paid Take callback. Genuine sources earn the +cache Box and ordinary Hajr purchases; Basic keeps its actual Semuta pause. +Source/continuation counts and inventories derive from native arrays. A separate +explicit detached supplement checks opaque battle-card quotation and claims no +new native acquisition. No full rules/combination certificate is implied. +Private proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-box-play-proof-v3/`; +journeys/HTML: adjacent `20261027-truth-box-play-runtime/run/`. +This is local native/SSR evidence, not browser hydration or deployed acceptance. + ## CHOAM opening income — 27 October 2026 **Prototyped / Partial.** Whole, bare, grouped and mixed ordinary payment diff --git a/game/engine.ts b/game/engine.ts index 95b36805..d9180b84 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -12,7 +12,7 @@ import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurn closeTurnActionRevival, settleTurnActionAlliance, recordTurnActionPlan, recordTurnActionTraitor, closeTurnActionCombat, recordTurnActionBattlePower, closeTurnActionBattlePower, recordTurnActionWindowCardPlay, closeTurnActionWindowCardPlay, - type TurnActionPlanValue, type TurnActionCombat, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation, type TurnActionWindowCards } from './turn-action-promises'; + turnActionPromiseModeSupported, type TurnActionPlanValue, type TurnActionCombat, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation, type TurnActionWindowCards } from './turn-action-promises'; import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShipment, recordTravelMovement, finishTravelTurn, validateTravelPromises, type TravelClaim, type TravelPromise, type TravelBranchLeaf } from './travel-expression-promises'; import { mixedTraitorRequirement, type MixedTraitorClaim } from './mixed-traitor-question'; @@ -4959,7 +4959,9 @@ function finishNullentropy( } function beginNullentropy(g: Game, owner: Player, cardId: string) { const { candidates } = validateNullentropyBegin(g, owner, cardId); + guardTurnActionBoxCost(g, owner, cardId); reopenTurnActionWindowReady(g, owner.id); + recordNativePrintedCardPlay(g, owner.id, 'nullentropyBox'); owner.spice -= 2; const event = crypto.randomUUID(); g.pendingNullentropy = { @@ -5015,6 +5017,7 @@ function nullentropyView(g: Game, owner: Player) { let blocked: string | null = null; try { validateNullentropyBegin(g, owner, card.id); + guardTurnActionBoxCost(g, owner, card.id); } catch (error) { if (!(error instanceof RuleError)) throw error; blocked = error.message; @@ -5035,7 +5038,7 @@ function savedTransferResponses(g: Game) { } function transferCardBlock(g: Game, owner: Player, card: Card) { if ((g as TurnActionCardProbe)._turnActionUnknownCards?.includes(card.id)) - return 'An unseen future auction card cannot fund this completion.'; + return 'An unseen future acquired card cannot fund this completion.'; if (isPortableSnooper(card) && homeworldSavedDecisions(g).some((d) => d.kind === 'homeworldDefense' && d.player === owner.id)) return 'Resolve the Homeworld late-defense choice before transferring its Portable Snooper.'; @@ -18963,6 +18966,7 @@ function projectMovementBoxRecovery(source: Game, actor: string, box: string, se 'Retain the original paid search.'); else { validateNullentropyBegin(game, owner, box); owner.spice -= 2; + recordNativePrintedCardPlay(game, actor, 'nullentropyBox'); // The real voluntary Box play closes Advanced Hajr's immediate window. // Recovery cannot preserve that window across its own card action. if (game.advanced) delete owner.hajrWindow; @@ -19018,6 +19022,13 @@ function turnActionCardsMatch(g: Game, actor: string, requirements: readonly Tur } function* nativePrintedCardActions(g: Game, actor: string, effect: PrintedCardEffect): Generator { const player = getPlayer(g, actor); + if (effect === 'nullentropyBox') { + for (const card of player.hand.filter(card => card.effect === effect)) { + try { validateNullentropyBegin(g, player, card.id); yield { type: 'card', card: card.id }; } + catch (error) { if (!(error instanceof RuleError)) throw error; } + } + return; + } if (effect === 'hajr') { if (g.phase === 5 && g.active === actor && hajrAvailable(g, player)) for (const card of player.hand.filter(card => card.effect === 'hajr' && !transferCardBlock(g, player, card))) @@ -19033,14 +19044,44 @@ function* nativePrintedCardActions(g: Game, actor: string, effect: PrintedCardEf yield* gholaForcePreparations({ cards: ghola.cards, tanks: player.tanks, eliteTanks: player.elites?.tanks ?? 0, maxForces: ghola.maxForces, eliteRemaining: ghola.eliteRemaining }); } +/** A known recovery may supply its known effect. An unpaid private choice is + * otherwise opaque: no recovered identity can fund another promised action. + * Pure recovery preserves pieces and costs without a hidden draw or shuffle. */ +function* turnActionBoxSeeds(source: Game, actor: string, onlyBox?: string): + Generator<{ game: Game; actions: Action[] }> { + if (source.pendingNullentropy) return; + const player = getPlayer(source, actor); + const known = knownMovementDiscards(source.movementDiscardKnowledge, actor, source.discard); + const candidates = eligibleNullentropyCards(source.discard); + const choices = [...known.map(card => ({ card, opaque: false })), + ...candidates.filter(card => !known.some(seen => seen.id === card.id)).slice(0, 1) + .map(card => ({ card, opaque: true }))]; + for (const box of player.hand.filter(card => card.effect === 'nullentropyBox' && (!onlyBox || card.id === onlyBox))) + for (const choice of choices) { + const projected = projectMovementBoxRecovery(source, actor, box.id, choice.card.id, false); + if (!projected) continue; + if (choice.opaque) (projected as TurnActionCardProbe)._turnActionUnknownCards = + [...new Set([...((projected as TurnActionCardProbe)._turnActionUnknownCards ?? []), choice.card.id])]; + yield { game: projected, actions: [{ type: 'card', card: box.id }] }; + } +} +/** Reject the known printed fee before paid inspection can suspend legality. + * The native owner chooses the recovered card only after actual payment. */ +function guardTurnActionBoxCost(source: Game, owner: Player, box: string) { + if (!liveTurnActionPromises(source.turnActionPromises ?? [], owner.id, source.turn).length) return; + 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.'); +} /** Current Ghola and known owned Box routes share the original physical cost. * A separate Movement forecast retains the later-play alternative. */ function* turnActionCardSeeds(source: Game, actor: string, requirements: readonly TurnActionCardRequirement[]): Generator<{ game: Game; actions: Action[] }> { const gholaDue = requirements.some(required => required.effect === 'ghola' && required.answer && nativePrintedCardCount(source, actor, 'ghola') <= required.baseline); - yield { game: gholaDue ? structuredClone(source) : source, actions: [] }; - if (!gholaDue) return; + const boxDue = requirements.some(required => required.effect === 'nullentropyBox' && required.answer && + nativePrintedCardCount(source, actor, 'nullentropyBox') <= required.baseline); + yield { game: gholaDue || boxDue ? structuredClone(source) : source, actions: [] }; + if (!gholaDue && !boxDue) return; const player = getPlayer(source, actor); const plays = function* (game: Game, prefix: Action[]): Generator<{ game: Game; actions: Action[] }> { for (const action of nativePrintedCardActions(game, actor, 'ghola')) { @@ -19052,7 +19093,16 @@ function* turnActionCardSeeds(source: Game, actor: string, requirements: readonl } catch (error) { if (!(error instanceof RuleError)) throw error; } } }; - yield* plays(source, []); + if (boxDue) for (const boxed of turnActionBoxSeeds(source, actor)) { + yield boxed; + if (gholaDue) yield* plays(boxed.game, boxed.actions); + } + if (!gholaDue) return; + for (const played of plays(source, [])) { + yield played; + if (boxDue) for (const boxed of turnActionBoxSeeds(played.game, actor)) + yield { game: boxed.game, actions: [...played.actions, ...boxed.actions] }; + } const paid = source.pendingNullentropy; if (paid?.player === actor) { if (!nullentropyIntegrity(source)) for (const card of eligibleNullentropyCards(source.discard).filter(card => card.effect === 'ghola')) { @@ -19400,7 +19450,14 @@ function guardTurnActionShipmentPriceDelivery(g: Game, shipment: PendingShipment guardTurnActionShipmentPriceBoundary(trial, shipment.player, original, undefined, receipt); } function turnActionTargetAvailable(g: Game, _actor: string): boolean { - return bribeQuestionWindow(g.phaseOpening ? { ...g, phaseOpening: null } : g) && movementPromiseModeSupported(g); + const context = g.phaseOpening ? { ...g, phaseOpening: null } : g; + return turnActionPromiseTurn(g) && !bribeTimingBlock({ ...context, truthtrance: null, + inflation: context.inflation?.side === 'double' ? null : context.inflation }); +} +/** Whole native commitments retain the existing Semuta discard continuation. + * This does not expand the separate legacy bribe or Kull/module envelopes. */ +function turnActionPromiseTurn(g: Game): boolean { + return g.status === 'playing' && g.phase >= 0 && g.phase <= 8 && turnActionPromiseModeSupported(g); } function turnActionTransportOpen(g: Game, actor: string): boolean { return g.phase < 5 || g.phase === 5 && (!!g.phaseOpening?.initialize || !!g.movementRemaining?.includes(actor)); @@ -19868,7 +19925,7 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b const compiled = compileMixedActionExpression(claim.mixed, fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer); const open = turnActionTransportOpen(g, p.id); - let bid = false, revival = false, alliance = false, plan = false, traitor = false, cardHajr = false, cardGhola = false; + let bid = false, revival = false, alliance = false, plan = false, traitor = false, cardHajr = false, cardGhola = false, cardBox = false; const powers = new Set(); const windowCards: TurnActionWindowCards = {}; const visit = (node: TurnActionClaim['mixed']): void => { @@ -19883,6 +19940,7 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b }; if (node.action.kind === 'cardPlay' && node.action.effect === 'hajr') cardHajr = true; if (node.action.kind === 'cardPlay' && node.action.effect === 'ghola') cardGhola = true; + if (node.action.kind === 'cardPlay' && node.action.effect === 'nullentropyBox') cardBox = true; if (node.action.kind === 'forces' || node.action.kind === 'leader') revival = true; } else if (node.kind !== 'fact') node.terms.forEach(visit); }; @@ -19914,8 +19972,9 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b closed: false, call: null } : null } : undefined; let promise: TurnActionPromise = { turn: claim.turn, mixed: structuredClone(claim.mixed), event, player: p.id, asker, answer, compiled, ...(Object.keys(windowCards).length ? { windowCards } : {}), - ...(cardHajr || cardGhola ? { cardPlays: { hajr: cardHajr ? { played: false, closed: !open } : null, - ghola: cardGhola ? { played: false, closed: false } : null } } : {}), + ...(cardHajr || cardGhola || cardBox ? { cardPlays: { hajr: cardHajr ? { played: false, closed: !open } : null, + ghola: cardGhola ? { played: false, closed: false } : null, + ...(cardBox ? { nullentropyBox: { played: false, closed: false } } : {}) } } : {}), ...(combat ? { combat } : {}), ...(preparation ? { preparation } : {}), ...(nexus ? { alliance: { source: nexus.source, partner: null, closed: false } } : {}), @@ -19930,8 +19989,17 @@ function turnActionAnswerSource(g: Game, player: string, alliance = false, windo const source = g.phase === 0 && window || g.phase === 1 && (alliance || window) || g.phase === 2 || g.phase === 4 && !g.revivalOrder || g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g) ? { ...g, ready: g.ready.filter(id => id !== player) } : g; - return source.phaseOpening ? { ...source, phaseOpening: { ...source.phaseOpening, + const opened = source.phaseOpening ? { ...source, phaseOpening: { ...source.phaseOpening, passed: source.phaseOpening.passed.filter(id => id !== player) } } : source; + const consumed = g.truthtrance?.stage !== 'priority' && g.truthtrance?.queue[0]; + if (!consumed) return opened; + const projected = structuredClone(opened), owner = getPlayer(projected, consumed.player); + if (!owner.hand.some(card => card.id === consumed.card && card.effect === 'truthtrance')) return opened; + // This exact already-declared public cost is discarded on either definite + // answer. Quote it once, without playing another queued card or a reaction. + const card = discard(projected, owner, consumed.card); + rememberMovementDiscards(projected, [{ card, discardedBy: owner.id, publicFace: true }]); + return projected; } function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): TruthAnswer[] { if (claim.turn !== g.turn || !turnActionTargetAvailable(g, p.id)) return []; @@ -19990,7 +20058,7 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s requireRule(actor !== promise.player || !promise.answer, 'You cannot voluntarily evade your Truthtrance bribe answer.'); if (promise.answer) promise.released = true; else promise.fulfilled = true; } - if (!bribePromiseTurn(g) || g.truthtrance) return; + if (!turnActionPromiseTurn(g) || g.truthtrance) return; const nexus = allianceOpportunity(g); if (nexus && action?.type === 'bribe' && g.turnActionPromises!.some(promise => promise.player === actor && promise.turn === g.turn && promise.alliance?.source === nexus.source)) @@ -20397,11 +20465,16 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises: return; } for (const required of cardPlays.filter(required => required.answer && - nativePrintedCardCount(state, owner, required.effect) <= required.baseline)) + nativePrintedCardCount(state, owner, required.effect) <= required.baseline)) { + if (required.effect === 'nullentropyBox') { + yield* turnActionBoxSeeds(state, owner); + continue; + } for (const action of nativePrintedCardActions(state, owner, required.effect)) { try { yield { game: groundMovementProbe(applyActionInner(state, owner, action, 'movementPreparation'), owner), actions: [action] }; } catch (error) { if (!(error instanceof RuleError)) throw error; } } + } if (state.advanced && player.faction === 'beneGesserit') { for (const territory of quoteAdvisorPreparationWindow(state, owner).territories) { const action: Action = { type: 'prepareAdvisors', territory }; @@ -28513,7 +28586,10 @@ function findLegalBattlePlan( const promises = options.promises ?? b.truthPromises ?? []; const fixed = options.prescience; const commitments = committedPlanElements(b, p.id); + const unseen = (g as TurnActionCardProbe)._turnActionUnknownCards ?? []; + const hand = p.hand.filter(card => !unseen.includes(card.id)); const accepts = (plan: Partial) => + ![plan.leader, plan.weapon, plan.defense].some(card => typeof card === 'string' && unseen.includes(card)) && (!options.unarmed || (!plan.weapon && !plan.defense)) && commitments.every(element => inspectedPlanMatches(p, element.field, element.value, plan[element.field])) && @@ -28553,11 +28629,11 @@ function findLegalBattlePlan( ...controlledLeaders(g, p) .filter((l) => !l.dead && (!l.usedAt || l.usedAt === b.territory)) .map((l) => l.id), - ...p.hand.filter((c) => c.kind === 'hero').map((c) => c.id), + ...hand.filter((c) => c.kind === 'hero').map((c) => c.id), ]; const weapons = [ null, - ...p.hand + ...hand .filter( (card) => battleCardSlotEligible('weapon', card, { planetologistWeapon: true }), @@ -28566,7 +28642,7 @@ function findLegalBattlePlan( ]; const defenses = [ null, - ...p.hand.filter((c) => battleCardSlotEligible('defense', c)).map((c) => c.id), + ...hand.filter((c) => battleCardSlotEligible('defense', c)).map((c) => c.id), ]; for (const leader of leaderIds) { if (!accepts({ leader })) continue; diff --git a/game/faction-reference.ts b/game/faction-reference.ts index 841ac1bd..fcbe43d1 100644 --- a/game/faction-reference.ts +++ b/game/faction-reference.ts @@ -119,7 +119,7 @@ export const FACTION_RULES: Record< 'Start with five forces in Tuek’s Sietch, fifteen in reserve and five spice. One force revival is free each turn.', 'Choose one shipment: reserves to Dune, one on-planet territory to another, or one on-planet territory back to reserves. Movement follows shipment.', 'Pay half the normal shipment rate, rounding the total cost up. Returning forces cost one spice per two tokens, rounded up.', - 'In classic-faction games without optional modules, a Guild or allied ordinary reserve/cross-planet shipment and Guild’s own return to reserves wait for a Karama rate response before paying. Canceling the discount charges the normal one/two-spice-per-force destination tariff for the exact force group and original approved payment split; return uses one spice per force. If that split or the saved physical group cannot complete, the declaration returns unused, and a replacement this turn uses full price. The full-price return and safe withdrawal are table interpretations, not dedicated publisher examples; optional modules and separate transport-permission cancellation still need integration.', + 'In classic-faction games without optional modules, a Guild or allied ordinary reserve/cross-planet shipment and Guild’s own return to reserves wait for a Karama rate response before paying. Canceling the discount charges the normal one/two-spice-per-force destination tariff for the exact force group and original approved payment split; return uses one spice per force. If that split or the saved physical group cannot complete, the declaration returns unused, and a replacement this turn uses full price. The full-price return and safe withdrawal are table interpretations, not dedicated publisher examples; a separate native cross/return-permission response is also connected before payment or departure. Permission denial preserves the discount and another legal shipment or movement; immediate redeclaration for that original opportunity remains provisionally blocked. Optional combinations remain partial.', 'Receive spice for other factions’ ordinary off-planet shipments and allied cross-shipments. Karama shipping and a canceled Guild payment go to the bank. Fremen’s normal reinforcements provide no income. Any spice the Guild contributes toward its ally’s shipment goes to the bank.', 'Allies can ship from off-planet or cross-ship on Dune at half price. They cannot ship back to reserves. Allied Fremen may pay to cross-ship from southern reserves to any legal destination. Allied cross-shipments pay the Guild; any Guild-funded share goes to the bank.', 'If the game reaches its end without another qualifying victory, Guild and its ally win through the Guild special victory.', diff --git a/game/printed-card-play.ts b/game/printed-card-play.ts index 1f2411a3..e4c5a672 100644 --- a/game/printed-card-play.ts +++ b/game/printed-card-play.ts @@ -1,4 +1,5 @@ -export type PrintedCardEffect = 'hajr' | 'ghola'; +export const PRINTED_CARD_EFFECTS = ['hajr', 'ghola', 'nullentropyBox'] as const; +export type PrintedCardEffect = typeof PRINTED_CARD_EFFECTS[number]; export type PrintedCardPlayCounts = { turn: number; plays: { player: string; effect: PrintedCardEffect; count: number }[]; @@ -23,7 +24,7 @@ const identifier = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value === value.trim(); const positive = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value > 0; -const effect = (value: unknown): value is PrintedCardEffect => value === 'hajr' || value === 'ghola'; +const effect = (value: unknown): value is PrintedCardEffect => PRINTED_CARD_EFFECTS.some(candidate => candidate === value); function validateCounts(counts: unknown): asserts counts is PrintedCardPlayCounts { check(object(counts) && keys(counts, 'plays,turn') && positive(counts.turn) && Array.isArray(counts.plays), @@ -40,7 +41,7 @@ function validateCounts(counts: unknown): asserts counts is PrintedCardPlayCount function validateRequest(player: unknown, turn: unknown, cardEffect: unknown): void { check(identifier(player) && positive(turn) && effect(cardEffect), - 'Choose the printed Hajr or Ghola play, current turn and exact player.'); + 'Choose the printed Hajr, Ghola or Nullentropy Box play, current turn and exact player.'); } /** Private native witness baseline only. Per-answer promises track their own diff --git a/game/reference.ts b/game/reference.ts index 971701c9..faf35a13 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1036,6 +1036,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'An active Truthtrance question and exact card or transaction commitments must resolve as required. Basic and Advanced full-hand holders have a visibly provisional atomic exchange: retrieve one non-Box and discard the played Box, leaving the final hand at its current limit. The printed intermediate-capacity question remains unresolved; over-limit hands are rejected.', 'A pending purchased-shipment cost does not block the search. Under the provisional refund composition, recovering that exact cost preserves its purchased price, creator and beneficiary but ends its later Guild special-stop refund, even if discarded again. Selecting another card leaves the cost’s existing claim status unchanged. Empty or only-Box searches are rejected before payment under the current unsupported-empty-search policy; there is no fallback deck draw.', 'All four AI profiles start only when their own projected availability and spice reserve allow it. They do not inspect unpaid discard contents. Once paid, they choose from their entitled cards, prefer existing functional effects over unfinished Richese faces and complete a legal selection even if only unfinished effects remain. A paid search takes priority over optional actions.', + 'Whole Truthtrance questions may name a future printed Box activation. Accepted payment records that play once before private selection; it does not promise the recovered identity. Actual costs share funding with other commitments, while an unpaid unknown recovery cannot supply another effect. Native paid selection and Semuta continuation keep their priority.', 'The paid search and continuations are integrated in development fixtures, with a connected full-hand prototype in both modes. Purchased-shipment cost recovery now uses the provisional nonduplication policy without blocking the search. The explicit Semuta preview can recover the used Box after a clean completed search without replaying payment or shuffle; other discard compositions remain separate. Full Richese starts and complete browser/mobile/full-game acceptance remain gated.', ], related: [ @@ -3228,6 +3229,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Native owners can provide their eligible ally’s power. Voice finishes before Prescience; a pending Prescience condition does not advance its turn. Original command and No-Field restrictions remain. All predicates concern the same one native choice; source expiry is absence and never becomes a synthetic Decline. Borrowed Nexus and special powers retain separate scope.', 'A printed-card condition requires an actual Hajr or Tleilaxu Ghola play in its printed role after this answer. Earlier plays, discards, sales, Karama roles and recovery alone do not count; a genuine later Box recovery followed by replay can count. No hidden card is needed merely to ask the public named condition.', 'Hajr counts even if its extra move is unused, closes at your own Movement end and must earn its actual Advanced timing. Ghola remains available through this turn’s Mentat and uses real Tanks, eligible leader or Kwisatz and typed selection; it does not spend or satisfy ordinary Revival quotas. Ordinary returns and Ghola share the actual remaining Tanks and purse.', + 'A printed Nullentropy Box clause counts its accepted two-spice search activation after answering this turn, before private selection. It does not name the recovered card; earlier use or recovery alone does not count. No lasts through Mentat. The actual fee shares the purse with payments and physical actions; an unpaid unknown recovered card cannot supply another effect. Whole native Basic Richese answers retain their real Semuta discard continuation. Current guides use native availability and paid selection keeps its own panel.', '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,7 +3249,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-turn-action-controls.test.tsx'] }, { area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed Hajr/Ghola and original power declarations; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-thumper-runtime.test.ts'] }, { area: 'Documentation', status: 'Implemented', detail: 'Current funding, physical event meaning, private alternatives, separate deadlines and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TURN_ACTIONS.md', 'docs/RULE_DECISIONS.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/fixture-truth-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-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 ecb2330d..e3f74ee8 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1,6 +1,6 @@ import { parseTravelActionClaim, type TravelClaim } from './travel-expression-promises'; import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES, - type TurnActionClaim } from './turn-action-promises'; + turnActionPromiseModeSupported, type TurnActionClaim } from './turn-action-promises'; import { battlePowerOf, nativeBattlePowerOpportunity } from './battle-power-question'; import { parseKaramaShipmentSource, windowCardOpportunity } from './window-card-play'; import { parseTraitorActionClaim, type MixedTraitorClaim } from './mixed-traitor-question'; @@ -380,7 +380,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { } if (v.kind === 'turnActions') { check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && v.turn === g.turn && - bribePromiseModeSupported(g) && g.status === 'playing' && Number.isInteger(g.phase) && g.phase >= 0 && g.phase <= 8 && + turnActionPromiseModeSupported(g) && g.status === 'playing' && Number.isInteger(g.phase) && g.phase >= 0 && g.phase <= 8 && !g.response && !g.decision && (!g.phaseOpening || !!windowCardOpportunity(g, 'amal')) && !g.pendingTreacheryDiscard && !g.pendingKarama, 'Supply the current turn, complete turn-action question and a supported quiet native window.'); const respondent = g.players.find(player => player.id === v.target)!; @@ -752,7 +752,9 @@ export function truthQuestionText( action.effect === 'atomics' ? 'Family Atomics' : 'Harvester'} in its printed role after this answer in this original ${ action.effect === 'harvester' ? 'fresh Spice Blow' : 'Storm window'} this turn`; } - if (action.kind === 'cardPlay') return `you play ${action.effect === 'hajr' ? 'Hajr' : 'Tleilaxu Ghola'} in its printed role after this answer this turn`; + if (action.kind === 'cardPlay') return action.effect === 'nullentropyBox' + ? 'you activate Nullentropy Box in its printed role and pay its 2-spice search cost after this answer this turn' + : `you play ${action.effect === 'hajr' ? 'Hajr' : 'Tleilaxu Ghola'} in its printed role after this answer this turn`; if (action.kind === 'battlePower') { const claim = action.claim, where = `in this original battle in ${locationName(action.territory)}`; if (claim.kind === 'voiceCommand') return `you actually declare native Voice ${claim.must ? 'requiring' : 'forbidding'} ${claim.cardType} ${where} (later prevention does not change the declaration)`; diff --git a/game/turn-action-promises.ts b/game/turn-action-promises.ts index 084674f6..fe9b12be 100644 --- a/game/turn-action-promises.ts +++ b/game/turn-action-promises.ts @@ -6,8 +6,9 @@ import { parseRevivalActionClaim, type RevivalActionClaim } from './mixed-reviva import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './revival-expression-promises'; import { parseBattleActionClaim, type MixedBattleActionClaim } from './mixed-battle-question'; import { PlanClaimError } from './battle-promises'; -import type { PrintedCardEffect } from './printed-card-play'; +import { PRINTED_CARD_EFFECTS, type PrintedCardEffect } from './printed-card-play'; import { WINDOW_CARD_EFFECTS, type WindowCardEffect } from './window-card-play'; +import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope'; import { BattlePowerQuestionError, battlePowerMatches, battlePowerOf, parseBattlePowerClaim, type BattlePowerClaim, type BattlePowerDeclaration, type NativeBattlePower } from './battle-power-question'; import { @@ -28,6 +29,15 @@ import type { TruthFact } from './truthtrance'; export const TURN_ACTION_MAX_LEAVES = 16; export const TURN_ACTION_MAX_DEPTH = 4; +/** Native whole answers retain Semuta's own discard continuation. Other + * unsupported interruption/module envelopes stay separately gated. */ +export function turnActionPromiseModeSupported(game: NativePromiseScope & { + kullPreview?: unknown; nexusKullPreview?: unknown; richeseBetrayalPreview?: unknown; +}): boolean { + return nativePromiseScopeSupported(game) && !game.kullPreview && + !game.nexusKullPreview && !game.richeseBetrayalPreview; +} + export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: string; minimum: number } | { kind: 'bid'; lot: number; minimum: number } | RevivalActionClaim | { kind: 'alliance'; partner: string } | { kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim } | @@ -68,7 +78,10 @@ export type TurnActionCombat = { traitor: { beneficiary: string; target: string; leader: string | null; closed: boolean; call: boolean | null } | null; powers?: Record; }; -export type TurnActionCardPlays = Record; +type PrintedCardStage = { played: boolean; closed: boolean } | null; +/** The first two keys retain the legacy schema; later printed roles are optional. */ +export type TurnActionCardPlays = Record<'hajr' | 'ghola', PrintedCardStage> & + Partial, PrintedCardStage>>; export type TurnActionWindowCards = Partial>; export type TurnActionPromise = TurnActionClaim & { event: string; @@ -154,9 +167,9 @@ export function parseTurnActionLeaf(value: unknown): TurnActionLeaf { return { kind: 'battlePower', battle: value.battle, territory: value.territory, claim: parsePowerClaim(value.claim) }; } if (value.kind === 'cardPlay') { - check(keys(value, 'effect,kind') && (value.effect === 'hajr' || value.effect === 'ghola'), - 'Choose only the printed Hajr or Tleilaxu Ghola play after this answer.'); - return { kind: 'cardPlay', effect: value.effect }; + check(keys(value, 'effect,kind') && PRINTED_CARD_EFFECTS.some(effect => effect === value.effect), + 'Choose only the printed Hajr, Tleilaxu Ghola or Nullentropy Box play after this answer.'); + return { kind: 'cardPlay', effect: value.effect as PrintedCardEffect }; } if (value.kind === 'battlePlan' || value.kind === 'traitorCall') { check(keys(value, value.kind === 'battlePlan' ? 'battle,claim,kind,territory' : 'battle,call,kind,territory') && @@ -323,8 +336,10 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction(promises * success, disposal, recovery and real card custody; none count as this event. */ export function recordTurnActionCardPlay(promises: readonly T[], player: string, turn: number, effect: PrintedCardEffect): T[] { - check(effect === 'hajr' || effect === 'ghola', 'Record an accepted printed Hajr or Tleilaxu Ghola play.'); + check(PRINTED_CARD_EFFECTS.some(candidate => candidate === effect), 'Record an accepted printed Hajr, Tleilaxu Ghola or Nullentropy Box play.'); const updates = new Map(); for (const promise of liveTurnActionPromises(promises, player, turn)) { readPromise(promise); @@ -966,10 +981,10 @@ export function finishTurnActionTurn(promises: read shipment: { ...promise.shipment, pending: false }, ...(promise.preparation ? { preparation: { bid: promise.preparation.bid ? { ...promise.preparation.bid, closed: true } : null, revival: promise.preparation.revival ? { ...promise.preparation.revival, closed: true } : null, - } } : {}), ...(promise.cardPlays ? { cardPlays: { - hajr: promise.cardPlays.hajr ? { ...promise.cardPlays.hajr, closed: true } : null, - ghola: promise.cardPlays.ghola ? { ...promise.cardPlays.ghola, closed: true } : null, - } } : {}), ...(promise.windowCards ? { windowCards } : {}) }; + } } : {}), ...(promise.cardPlays ? { cardPlays: Object.fromEntries(PRINTED_CARD_EFFECTS + .filter(effect => Object.hasOwn(promise.cardPlays!, effect)).map(effect => [effect, promise.cardPlays![effect] + ? { ...promise.cardPlays![effect]!, closed: true } : null])) as TurnActionCardPlays } : {}), + ...(promise.windowCards ? { windowCards } : {}) }; const unresolvedCombat = promise.combat && (promise.combat.plan && !promise.combat.plan.closed || promise.combat.traitor && !promise.combat.traitor.closed || promise.combat.powers?.voice && !promise.combat.powers.voice.closed || promise.combat.powers?.prescience && !promise.combat.powers.prescience.closed); @@ -994,7 +1009,7 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[ (!promise.combat?.powers?.voice || promise.combat.powers.voice.closed) && (!promise.combat?.powers?.prescience || promise.combat.powers.prescience.closed) && WINDOW_CARD_EFFECTS.every(effect => promise.windowCards?.[effect]?.closed ?? true) && - (!promise.cardPlays?.hajr || promise.cardPlays.hajr.closed) && (!promise.cardPlays?.ghola || promise.cardPlays.ghola.closed); + PRINTED_CARD_EFFECTS.every(effect => promise.cardPlays?.[effect]?.closed ?? true); const recipientsValid = (node: MixedActionExpression): boolean => node.kind === 'action' ? node.action.kind === 'alliance' ? players.includes(node.action.partner) && node.action.partner !== promise.player : node.action.kind !== 'payment' || players.includes(node.action.recipient) && node.action.recipient !== promise.player diff --git a/tests/fixture-truth-box-play.ts b/tests/fixture-truth-box-play.ts new file mode 100644 index 00000000..d91f8527 --- /dev/null +++ b/tests/fixture-truth-box-play.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import type { MixedActionExpression } from '../game/mixed-action-question'; +import type { TruthFact, TruthQuestion } from '../game/truthtrance'; +import { createHajrReplayFixture, hajrReplayMove } from './fixture-hajr-replay'; +import { actTruthMovementBox, truthMovementBoxInventory, truthMovementBoxPlayer } from './fixture-truth-movement-box'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; +import { truthTurnThumperSpice, truthTurnThumperTraitors } from './fixture-truth-turn-thumper'; + +export type TruthBoxPlayLeaf = { kind: 'cardPlay'; effect: 'nullentropyBox' } | + { kind: 'payment'; recipient: string; minimum: number } | { kind: 'ship'; territory: string; minimum: number }; +export type TruthBoxPlayExpression = MixedActionExpression; +export type TruthBoxPlayStep = { actor: string; action: Action }; +export const actTruthBoxPlay = actTruthMovementBox; +export const truthBoxPlayPlayer = truthMovementBoxPlayer; +export const truthBoxPlayInventory = truthMovementBoxInventory; +export const truthBoxPlaySpice = truthTurnThumperSpice; +export const truthBoxPlayTraitors = truthTurnThumperTraitors; +export const truthBoxPlayClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && + !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingNullentropy && !game.pendingShipment; +export const truthBoxPlayUse = (): TruthBoxPlayExpression => ({ kind: 'action', action: { kind: 'cardPlay', effect: 'nullentropyBox' } }); +export const truthBoxPlayFact = (fact: TruthFact): TruthBoxPlayExpression => ({ kind: 'fact', fact }); +export const truthBoxPlayPayment = (recipient: string, minimum = 2): TruthBoxPlayExpression => + ({ kind: 'action', action: { kind: 'payment', recipient, minimum } }); +export const truthBoxPlayShip = (territory = 'polar_sink', minimum = 1): TruthBoxPlayExpression => + ({ kind: 'action', action: { kind: 'ship', territory, minimum } }); +export const truthBoxPlayGroup = (kind: 'and' | 'or', ...terms: TruthBoxPlayExpression[]): TruthBoxPlayExpression => ({ kind, terms }); + +/** Original controls only. The actual paid private selection is left to the + * caller; no free discard inspection or selected-card promise is introduced. */ +export function nextTruthBoxPlayNative(game: Game): TruthBoxPlayStep | null { + if (game.truthtrance || game.pendingNullentropy) return null; + return nextRicheseStrongholdsNativeStep(game); +} +export function nextTruthBoxPlay(game: Game): TruthBoxPlayStep | null { + if (game.truthtrance || game.pendingNullentropy) return null; + for (const player of game.players) { + const guide = viewGame(game, player.id).turnActionGuide; + if (guide) return { actor: player.id, action: guide.action }; + } + return nextTruthBoxPlayNative(game); +} +export function advanceTruthBoxPlay(game: Game, until: (state: Game) => boolean, + actions: TruthBoxPlayStep[] = [], nativeOnly = false): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original Box chronology ended before its requested boundary'); + const step = nativeOnly ? nextTruthBoxPlayNative(game) : nextTruthBoxPlay(game); + assert.ok(step, 'Supply the actual Truthtrance, paid Box selection or native sealed plan'); + game = actTruthBoxPlay(game, step.actor, step.action, actions); + } + throw Error('The original Box chronology did not reach its finite boundary'); +} +export function settleTruthBoxPlay(game: Game, actions: TruthBoxPlayStep[] = []): Game { + return advanceTruthBoxPlay(game, truthBoxPlayClean, actions, true); +} +export function finishTruthBoxPlay(game: Game, actions: TruthBoxPlayStep[] = []): Game { + const turn = game.turn; + return advanceTruthBoxPlay(game, state => state.turn === turn + 1, actions, true); +} + +/** Exact native prefix of the existing public Richese/Emperor/Guild source. + * Box is bought from the real cache for1 and Hajr in the real normal auction + * for1. Stop BEFORE its recorded six-spice shipment, retaining Emperor8. + * Basic's real Semuta marker is deliberately retained. */ +export function createTruthBoxPlayFixture(options: { advanced?: boolean; multiple?: boolean; late?: boolean } = {}) { + const advanced = options.advanced ?? false, multiple = options.multiple ?? false, late = options.late ?? false; + const base = createHajrReplayFixture({ advanced, truthForGuild: true, truthForRichese: true }); + const shipmentIndex = base.actions.findIndex(step => step.actor === base.shipmentStep.actor && + step.action.type === 'ship' && step.action.territory === base.shipmentStep.action.territory && step.action.amount === 3); + assert.ok(shipmentIndex >= base.acquiredActionCount); + let game = structuredClone(base.beforeShipment); const actions = structuredClone(base.actions.slice(0, shipmentIndex)); + const inheritedActions = actions.length, target = base.owner, asker = 'guild', recipient = asker; + assert.ok(base.guildTruth && base.richeseTruth); + const truths = [base.guildTruth, base.richeseTruth], truthHolders = [asker, 'richese']; + const originalCards = truthBoxPlayInventory(base.setup), originalSpice = truthBoxPlaySpice(base.setup), originalTraitors = truthBoxPlayTraitors(base.setup); + assert.equal(truthBoxPlayPlayer(game, target).spice, 8); assert.equal(truthBoxPlayPlayer(game, target).shipped, false); + assert.ok(truthBoxPlayPlayer(game, target).hand.some(card => card.id === base.box)); + assert.equal(game.discard.length, 0, 'No played cost or discard is inserted in the original prefix'); + const sourcePreparation: { before: Game; steps: TruthBoxPlayStep[]; after: Game } | null = multiple ? (() => { + const before = structuredClone(game), from = actions.length; + game = settleTruthBoxPlay(actTruthBoxPlay(game, target, + { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0 }, actions), actions); + game = settleTruthBoxPlay(actTruthBoxPlay(game, target, hajrReplayMove(game, 'imperial_basin:9'), actions), actions); + game = settleTruthBoxPlay(actTruthBoxPlay(game, target, { type: 'card', card: base.hajr }, actions), actions); + assert.equal(truthBoxPlayPlayer(game, target).spice, 6); assert.equal(truthBoxPlayPlayer(game, target).moved, 1); + assert.equal(game.discard.filter(card => card.id === base.hajr).length, 1); + return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) }; + })() : null; + if (late) game = advanceTruthBoxPlay(game, state => state.phase === 8 && truthBoxPlayClean(state), actions, true); + assert.ok(truthBoxPlayClean(game)); assert.equal(game.advanced, advanced); assert.equal(!!game.semutaPreview, !advanced); + assert.ok(truthBoxPlayPlayer(game, target).hand.some(card => card.id === base.box)); + for (const [index, truth] of truths.entries()) assert.ok(truthBoxPlayPlayer(game, truthHolders[index]).hand.some(card => card.id === truth)); + assert.deepEqual(truthBoxPlayInventory(game), originalCards); assert.deepEqual(truthBoxPlaySpice(game), originalSpice); + assert.deepEqual(truthBoxPlayTraitors(game), originalTraitors); assert.equal(game.turnActionPromises?.length ?? 0, 0); + const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthBoxPlayPlayer(game, target).spice }; + const mixed = truthBoxPlayGroup('and', truthBoxPlayUse(), truthBoxPlayPayment(recipient)); + const question: TruthQuestion = { kind: 'turnActions', target, turn: game.turn, mixed }; + return { game, source: structuredClone(game), base, advanced, multiple, late, asker, target, recipient, + truths, truthHolders, truth: truths[0], box: base.box, hajr: base.hajr, boxFee: 2, spiceFact, mixed, question, + setup: base.setup, afterSetup: base.afterSetup, beforeStartingDeal: base.beforeStartingDeal, + originalCards, originalSpice, originalTraitors, sourcePreparation, + actions, sourceActions: actions, sourceActionCount: actions.length, + sourceCounts: { inherited: inheritedActions, nativeContinuation: actions.length - inheritedActions, selected: actions.length, + paidCacheBids: actions.filter(step => step.actor === target && step.action.type === 'richeseBid' && step.action.amount === 1).length, + paidOrdinaryBids: actions.filter(step => step.actor === target && step.action.type === 'bid' && step.action.amount === 1).length }, + boxAction: { type: 'card', card: base.box } satisfies Action, + payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action, + shipment: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } satisfies Action, + end: { type: 'endMovement' } satisfies Action, + sourceDisclosure: 'Genuine public Richese/Emperor/Guild setup with original selected-CHOAM deck and Richese cache. ' + + 'Only the two original unopened Guild/Richese Truth deals, original unopened Hajr/Spice positions and scalar lotteries are selected. ' + + 'The exact actual cache1/normal1 action prefix stops before the older fixture’s desert shipment, preserving Emperor8 and unused shipment. ' + + 'Optional multiple-choice preparation actually ships one ordinary force for2, completes one move and plays the original Hajr; Advanced uses its earned immediate timing. ' + + 'Basic retains its native Semuta preview and actual declined fresh-discard acknowledgements. A future answered Truth supplies its real disposal before Box activation. ' + + 'No selected recovery or hidden recovered-card effect is promised or forecast. No played hand, purse, force, phase, receipt or marker is installed. Counts derive from actual arrays.', + }; +} +export type TruthBoxPlayFixture = ReturnType; +export function declareTruthBoxPlay(f: TruthBoxPlayFixture, source = f.game, copy: 0 | 1 = 0, actions: TruthBoxPlayStep[] = []) { + const asker = f.truthHolders[copy]; let game = actTruthBoxPlay(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 = actTruthBoxPlay(game, actor.id, { type: 'truthPass' }, actions); + } + assert.equal(game.truthtrance?.stage, 'ask'); return { game, asker, actions }; +} +export function askTruthBoxPlay(game: Game, asker: string, question: TruthQuestion, actions: TruthBoxPlayStep[] = []): Game { + return actTruthBoxPlay(game, asker, { type: 'truthAsk', question: structuredClone(question) }, actions); +} +export function answerTruthBoxPlay(game: Game, target: string, answer: 'yes' | 'no', actions: TruthBoxPlayStep[] = []): Game { + return actTruthBoxPlay(game, target, { type: 'truthAnswer', answer }, actions); +} +export function bindTruthBoxPlay(f: TruthBoxPlayFixture, answer: 'yes' | 'no' = 'yes', question: TruthQuestion = f.question, + source = f.game, copy: 0 | 1 = 0, actions: TruthBoxPlayStep[] = []) { + const declared = declareTruthBoxPlay(f, source, copy, actions), asked = askTruthBoxPlay(declared.game, declared.asker, question, actions); + return { game: answerTruthBoxPlay(asked, f.target, answer, actions), declared, asked, actions }; +} +export function truthBoxPlayPromise(game: Game, target = game.turnActionPromises?.[0]?.player ?? 'emperor') { + const row = viewGame(game, target).turnActionPromises.findLast(promise => promise.player === target); assert.ok(row); return row; +} +/** Caller chooses only after actual payment has opened the private search. */ +export function chooseTruthBoxPlay(game: Game, target: string, selected: string, actions: TruthBoxPlayStep[] = []): Game { + const search = viewGame(game, target).nullentropy?.search; assert.ok(search); + assert.ok(search.cards.some(card => card.id === selected)); + return actTruthBoxPlay(game, target, { type: 'decision', event: search.event, card: selected }, actions); +} diff --git a/tests/printed-card-play.test.ts b/tests/printed-card-play.test.ts index 465696c7..dececf20 100644 --- a/tests/printed-card-play.test.ts +++ b/tests/printed-card-play.test.ts @@ -2,11 +2,37 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { PrintedCardPlayError, + PRINTED_CARD_EFFECTS, printedCardPlayCount, recordPrintedCardPlay, type PrintedCardPlayCounts, } from '../game/printed-card-play'; +void test('Box accepted activation counts extend the shared roles without changing earlier Hajr or Ghola baselines', () => { + assert.deepEqual(PRINTED_CARD_EFFECTS, ['hajr', 'ghola', 'nullentropyBox']); + assert.equal(printedCardPlayCount(undefined, 'richese', 3, 'nullentropyBox'), 0); + const ghola = recordPrintedCardPlay(undefined, 'richese', 3, 'ghola'); + const first = recordPrintedCardPlay(ghola, 'richese', 3, 'nullentropyBox'); + const replay = recordPrintedCardPlay(first, 'richese', 3, 'nullentropyBox'); + assert.equal(printedCardPlayCount(replay, 'richese', 3, 'nullentropyBox'), 2); + assert.equal(printedCardPlayCount(replay, 'richese', 3, 'ghola'), 1); + assert.equal(printedCardPlayCount(ghola, 'richese', 3, 'nullentropyBox'), 0); + assert.deepEqual(first.plays[1], { player: 'richese', effect: 'nullentropyBox', count: 1 }); + assert.deepEqual(recordPrintedCardPlay(replay, 'richese', 4, 'nullentropyBox'), + { turn: 4, plays: [{ player: 'richese', effect: 'nullentropyBox', count: 1 }] }); +}); + +void test('Box activation counts retain safe overflow and strict per-player role metadata', () => { + const full: PrintedCardPlayCounts = { turn: 3, plays: [{ player: 'richese', effect: 'nullentropyBox', count: Number.MAX_SAFE_INTEGER }] }; + const before = structuredClone(full); + assert.throws(() => recordPrintedCardPlay(full, 'richese', 3, 'nullentropyBox'), PrintedCardPlayError); + assert.deepEqual(full, before); + for (const plays of [[...full.plays, ...full.plays], [{ ...full.plays[0], card: 'physical-box' }], + [{ ...full.plays[0], effect: 'Nullentropy Box' }], [{ ...full.plays[0], count: 0 }]]) + assert.throws(() => printedCardPlayCount({ turn: 3, plays } as PrintedCardPlayCounts, + 'richese', 4, 'nullentropyBox'), PrintedCardPlayError); +}); + 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-box-play-runtime.test.ts b/tests/truth-box-play-runtime.test.ts new file mode 100644 index 00000000..66fc10c5 --- /dev/null +++ b/tests/truth-box-play-runtime.test.ts @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { treacheryDeck } from '../game/cards'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { richeseCards } from '../game/richese-cards'; +import { truthFactAnswer, type TruthQuestion } from '../game/truthtrance'; +import { + actTruthBoxPlay as act, advanceTruthBoxPlay as advance, answerTruthBoxPlay as answer, + askTruthBoxPlay as ask, bindTruthBoxPlay as bind, chooseTruthBoxPlay as choose, + createTruthBoxPlayFixture as fixture, declareTruthBoxPlay as declare, finishTruthBoxPlay as finish, + nextTruthBoxPlayNative as nativeStep, settleTruthBoxPlay as settle, truthBoxPlayClean as clean, + truthBoxPlayFact as fact, truthBoxPlayGroup as group, truthBoxPlayInventory as inventory, + truthBoxPlayPayment as payment, truthBoxPlayPlayer as player, truthBoxPlayPromise as promise, + truthBoxPlayShip as ship, truthBoxPlaySpice as spice, truthBoxPlayTraitors as traitors, truthBoxPlayUse as use, + type TruthBoxPlayExpression, type TruthBoxPlayFixture, type TruthBoxPlayStep, +} from './fixture-truth-box-play'; + +function rejected(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before); +} +function physical(game: Game, f: TruthBoxPlayFixture) { + assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(spice(game), f.originalSpice); + assert.deepEqual(traitors(game), f.originalTraitors); +} +const raw = (f: TruthBoxPlayFixture, mixed: TruthBoxPlayExpression): TruthQuestion => + ({ kind: 'turnActions', target: f.target, turn: f.game.turn, mixed }); +const pay = (game: Game, f: TruthBoxPlayFixture, amount: number, actions: TruthBoxPlayStep[] = []) => + act(game, f.target, { type: 'bribe', target: f.recipient, amount }, actions); +const stage = (game: Game, index = 0) => game.turnActionPromises![index].cardPlays?.nullentropyBox; + +void test('printed Box sources preserve real cache and ordinary purchases, inventories and Basic Semuta chronology', () => { + for (const advanced of [false, true]) for (const multiple of [false, true]) { + const f = fixture({ advanced, multiple }); assert.equal(f.setup.status, 'setup'); + assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0)); + assert.deepEqual(f.originalCards, [...treacheryDeck(['choam']), ...richeseCards()].map(card => card.id).sort()); + assert.equal(f.sourceCounts.selected, f.actions.length); assert.equal(f.sourceActionCount, f.actions.length); + assert.equal(f.sourceCounts.inherited + f.sourceCounts.nativeContinuation, f.sourceActionCount); + assert.equal(f.sourceCounts.paidCacheBids, 1); assert.equal(f.sourceCounts.paidOrdinaryBids, 1); + assert.equal(player(f.game, f.target).spice, multiple ? 6 : 8); assert.equal(!!f.game.semutaPreview, !advanced); + assert.ok(player(f.game, f.target).hand.some(card => card.id === f.box)); + assert.equal(f.game.turnActionPromises?.length ?? 0, 0); assert.ok(!f.game.pendingNullentropy); + assert.equal(f.game.discard.filter(card => card.id === f.hajr).length, multiple ? 1 : 0); + if (multiple) { + assert.ok(f.sourcePreparation); assert.equal(player(f.sourcePreparation.before, f.target).spice, 8); + assert.equal(player(f.sourcePreparation.after, f.target).spice, 6); + assert.ok(f.sourcePreparation.steps.some(step => step.action.type === 'ship' && step.action.amount === 1)); + assert.ok(f.sourcePreparation.steps.some(step => step.action.type === 'move')); + assert.ok(f.sourcePreparation.steps.some(step => step.action.type === 'card' && step.action.card === f.hajr)); + } + physical(f.game, f); + } +}); + +void test('Basic printed Box AND payment and shipment share eight actual spice after declined Semuta with frozen facts', () => { + const f = fixture(), mixed = group('and', fact(f.spiceFact), use(), payment(f.recipient, 4), ship()); + const actions: TruthBoxPlayStep[] = []; let game = bind(f, 'yes', raw(f, mixed), f.game, 0, actions).game; + assert.equal(game.semutaPreview, true); game = settle(game, actions); + assert.ok(actions.some(step => step.action.type === 'semutaPass'), 'The original fresh Truth cost completes actual declined Semuta controls'); + assert.deepEqual(game.discard.map(card => card.id), [f.truth]); assert.equal(viewGame(game, f.target).nullentropy?.search, null); + game = pay(game, f, 4, actions); assert.equal(player(game, f.target).spice, 4); + assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); + game = settle(act(game, f.target, f.shipment, actions), actions); assert.equal(player(game, f.target).spice, 2); + assert.equal(player(game, f.target).forces['polar_sink:0'], 1); assert.equal(stage(game)?.played, false); + const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.box); + game = settle(act(game, f.target, guide.action, actions), actions); + assert.equal(player(game, f.target).spice, 0); assert.equal(stage(game)?.played, true); assert.equal(promise(game).fulfilled, true); + assert.equal(game.pendingNullentropy, null, 'A sole actual eligible discard completes without a private selection'); + assert.ok(player(game, f.target).hand.some(card => card.id === f.truth)); assert.equal(game.discard.filter(card => card.id === f.box).length, 1); + assert.equal(player(game, f.recipient).bribes, 4); assert.deepEqual(promise(game).mixed, mixed); physical(game, f); +}); + +void test('Advanced paid Box activation records before its real multi-choice selection and keeps modal priority over remaining payment', () => { + const f = fixture({ advanced: true, multiple: true }); const mixed = group('and', use(), payment(f.recipient, 4)); + let game = bind(f, 'yes', raw(f, mixed)).game; assert.equal(game.discard.length, 2); + assert.equal(viewGame(game, f.target).nullentropy?.search, null); + game = act(game, f.target, f.boxAction); assert.equal(player(game, f.target).spice, 4); + assert.equal(stage(game)?.played, true); assert.ok(!promise(game).fulfilled); assert.equal(game.decision?.kind, 'nullentropy'); + assert.equal(game.discard.filter(card => card.id === f.box).length, 0, 'The physical Box awaits its actual selection disposal'); + const search = viewGame(game, f.target).nullentropy?.search; assert.ok(search); + assert.deepEqual(search.cards.map(card => card.id).sort(), [f.truth, f.hajr].sort()); + rejected(game, f.target, f.payment); rejected(game, f.target, f.end); + rejected(game, f.asker, { type: 'decision', event: search.event, card: f.hajr }); + rejected(game, f.target, { type: 'decision', event: 'stale-paid-box', card: f.hajr }); + game = settle(choose(game, f.target, f.hajr)); assert.equal(player(game, f.target).spice, 4); + assert.ok(player(game, f.target).hand.some(card => card.id === f.hajr)); assert.equal(game.discard.filter(card => card.id === f.box).length, 1); + game = pay(game, f, 4); assert.equal(player(game, f.target).spice, 0); assert.equal(promise(game).fulfilled, true); + const receipt = structuredClone(game.turnActionPromises![0].cardPlays); + game = settle(act(game, f.target, f.end)); assert.deepEqual(game.turnActionPromises![0].cardPlays, receipt); physical(game, f); +}); + +void test('whole OR permits either manual printed Box use or a later Mentat payment without selecting a fixed recovery branch', () => { + const f = fixture({ advanced: true }), mixed = group('or', use(), payment(f.recipient)); + const bound = bind(f, 'yes', raw(f, mixed)).game; + let played = settle(act(bound, f.target, f.boxAction)); + assert.equal(promise(played).fulfilled, true); assert.equal(player(played, f.recipient).bribes, 0); + assert.equal(player(played, f.target).spice, 6); assert.equal(stage(played)?.played, true); + let paid = settle(act(bound, f.target, f.end)); + paid = advance(paid, state => state.phase === 8 && clean(state), [], true); + assert.ok(!promise(paid).fulfilled && !promise(paid).released); assert.equal(stage(paid)?.played, false); + paid = pay(paid, f, 2); assert.equal(promise(paid).fulfilled, true); + assert.ok(player(paid, f.target).hand.some(card => card.id === f.box)); assert.equal(player(paid, f.target).spice, 6); + played = finish(played); assert.equal(stage(played)?.played, true); physical(played, f); physical(paid, f); +}); + +void test('whole AND No permits one actual alternative but rejects the payment or printed activation that would make both true', () => { + const f = fixture({ advanced: true }), bound = bind(f, 'no').game; + let played = settle(act(bound, f.target, f.boxAction)); assert.equal(stage(played)?.played, true); + played = pay(played, f, 1); rejected(played, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); + assert.equal(player(played, f.target).spice, 5); assert.ok(!promise(played).released); + let paid = pay(bound, f, 2); rejected(paid, f.target, f.boxAction); + assert.equal(stage(paid)?.played, false); assert.equal(player(paid, f.target).spice, 6); + played = finish(played); paid = finish(paid); + assert.equal(promise(played).fulfilled, true); assert.equal(promise(paid).fulfilled, true); physical(played, f); physical(paid, f); +}); + +void test('whole OR No retains its cumulative payment cap and rejects printed Box through the actual turn deadline', () => { + const f = fixture({ advanced: true }), mixed = group('or', use(), payment(f.recipient)); + let game = bind(f, 'no', raw(f, mixed)).game; rejected(game, f.target, f.boxAction); + assert.match(viewGame(game, f.target).nullentropy?.blocked ?? '', /whole Truthtrance/); + game = pay(game, f, 1); game = advance(game, state => state.phase === 8 && clean(state), [], true); + rejected(game, f.target, f.boxAction); rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); + assert.ok(!promise(game).released); game = finish(game); + assert.equal(promise(game).fulfilled, true); assert.equal(stage(game)?.played, false); assert.equal(stage(game)?.closed, true); + assert.ok(player(game, f.target).hand.some(card => card.id === f.box)); physical(game, f); +}); + +void test('the printed Box fee cannot be financed twice or by an unpaid hidden recovered-card effect', () => { + const f = fixture({ advanced: true }); + for (const mixed of [group('and', use(), payment(f.recipient, 7)), group('and', use(), payment(f.recipient, 5), ship())]) { + const asked = ask(declare(f).game, f.asker, raw(f, mixed)), before = structuredClone(asked); + assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); assert.deepEqual(asked, before); + rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); + assert.equal(player(asked, f.target).spice, 8); assert.equal(viewGame(asked, f.target).nullentropy?.search, null); + } + physical(f.game, f); +}); + +void test('earlier physical Box activation and its recovered card do not satisfy a second original Truth future-play interval', () => { + const f = fixture({ advanced: true }); let game = bind(f, 'yes', raw(f, use())).game; + game = settle(act(game, f.target, f.boxAction)); assert.equal(promise(game).fulfilled, true); + assert.ok(player(game, f.target).hand.some(card => card.id === f.truth)); assert.equal(stage(game)?.played, true); + const prior = structuredClone(game.turnActionPromises![0]), declared = declare(f, game, 1); + const asked = ask(declared.game, declared.asker, raw(f, use())); + assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); + game = answer(asked, f.target, 'no'); assert.deepEqual(game.turnActionPromises![0], prior); + assert.equal(stage(game, 1)?.played, false); assert.equal(game.discard.filter(card => card.id === f.box).length, 1); + assert.equal(player(game, f.target).spice, 6); game = finish(game); + assert.equal(promise(game).fulfilled, true); assert.equal(stage(game, 1)?.played, false); physical(game, f); +}); + +void test('a definite late Box answer reopens actual own Ready and cannot expire its required paid activation', () => { + for (const advanced of [false, true]) { + const f = fixture({ advanced, late: true }), source = act(f.game, f.target, { type: 'ready' }); + assert.ok(source.ready.includes(f.target)); const actions: TruthBoxPlayStep[] = []; + let game = bind(f, 'yes', raw(f, use()), source, 0, actions).game; game = settle(game, actions); + assert.equal(game.ready.includes(f.target), false); rejected(game, f.target, { type: 'ready' }); + const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'card'); + assert.equal(guide.action.card, f.box); assert.equal(guide.action.turnActionPromisePhase, 8); + game = settle(act(game, f.target, guide.action, actions), actions); + assert.equal(promise(game).fulfilled, true); assert.equal(stage(game)?.played, true); assert.equal(player(game, f.target).spice, 6); + assert.equal(!!game.semutaPreview, !advanced); physical(game, f); + } +}); + +void test('exact printed Box guide envelopes reject stale owners and preselection, then native JSON selection spends only once', () => { + const f = fixture({ advanced: true, multiple: true }); let game = bind(f, 'yes', raw(f, use())).game; + const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); + assert.equal(guide.event, promise(game).event); assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.box); + 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-box-event' }); + rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 4 }); rejected(game, f.target, { ...guide.action, give: f.hajr }); + const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, packet); + assert.equal(player(game, f.target).spice, 4); assert.equal(stage(game)?.played, true); assert.equal(promise(game).fulfilled, true); + const search = viewGame(game, f.target).nullentropy?.search; assert.ok(search); rejected(game, f.target, packet); + const selection: Action = JSON.parse(JSON.stringify({ type: 'decision', event: search.event, card: f.hajr })); + game = settle(act(game, f.target, selection)); assert.equal(player(game, f.target).spice, 4); + assert.ok(player(game, f.target).hand.some(card => card.id === f.hajr)); rejected(game, f.target, selection); physical(game, f); +}); + +void test('all four minimal profiles perform real printed Box payment and native private selection in Basic and Advanced', () => { + for (const advanced of [false, true]) { + const f = fixture({ advanced, multiple: true }), declared = declare(f), asked = ask(declared.game, declared.asker, f.question); + for (const difficulty of DIFFICULTIES) { + let game = structuredClone(asked); const actions: TruthBoxPlayStep[] = []; + const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty; + const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, `${advanced}/${difficulty}`); + assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions); + for (let count = 0; count < 100 && (!promise(game).fulfilled || !clean(game)); count++) { + const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty; + if (game.pendingNullentropy) { + const search = view.nullentropy?.search; assert.ok(search); + const choice = botActions(view).find(candidate => candidate.type === 'decision' && candidate.event === search.event); + assert.ok(choice, `${advanced}/${difficulty}`); game = act(game, f.target, choice, actions); + } else if (view.turnActionGuide) { + const guide = view.turnActionGuide; + const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.action.turnActionPromiseEvent); + assert.ok(action, `${advanced}/${difficulty}`); assert.deepEqual(action, guide.action); game = act(game, f.target, action, actions); + } else { + const step = nativeStep(game); assert.ok(step, `${advanced}/${difficulty}`); game = act(game, step.actor, step.action, actions); + } + } + assert.equal(promise(game).fulfilled, true); assert.ok(clean(game)); assert.equal(stage(game)?.played, true); + assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.box).length, 1); + assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'decision' && typeof step.action.card === 'string').length, 1); + assert.equal(game.discard.filter(card => card.id === f.box).length, 1); assert.equal(player(game, f.target).spice, 2); + assert.equal(player(game, f.recipient).bribes, 2); assert.equal(!!game.semutaPreview, !advanced); physical(game, f); + } + } +}); diff --git a/tests/truth-opaque-card-plan.test.ts b/tests/truth-opaque-card-plan.test.ts new file mode 100644 index 00000000..706d464b --- /dev/null +++ b/tests/truth-opaque-card-plan.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { baseDeck } from '../game/cards'; +import { viewGame, type Game } from '../game/engine'; +import { + askTruthTurnCombat as ask, + createTruthTurnCombatFixture as fixture, + declareTruthTurnCombat as declare, + truthTurnCombatGroup as group, + truthTurnCombatInventory as inventory, + truthTurnCombatPlan as plan, + truthTurnCombatPlayer as player, + truthTurnCombatTraitors as traitors, + type TruthTurnCombatFixture, +} from './fixture-truth-turn-combat'; + +type OpaqueQuote = Game & { _turnActionUnknownCards: string[] }; + +/** Detached-forecast-only metadata supplement. The fixture's card was really + * acquired and is physically held; this marker models an opaque future card + * only for read-only quotation. It is never installed by a gameplay action. */ +function opaqueQuote(source: Game, card: string): OpaqueQuote { + return Object.assign(structuredClone(source), { _turnActionUnknownCards: [card] }); +} + +function originalWeapon(f: TruthTurnCombatFixture) { + assert.ok(f.ordinary); + assert.equal(f.game.battle!.plans[f.target], undefined); + const held = player(f.game, f.target).hand.find(card => card.id === f.ordinary); + assert.ok(held); + const canonical = baseDeck().find(card => card.id === held.id); + assert.ok(canonical); + assert.equal(canonical.kind, 'projectile'); + assert.deepEqual(held, canonical); + assert.equal(f.sourceActions.length, f.sourceActionCount); + assert.equal(Object.hasOwn(f.game, '_turnActionUnknownCards'), false); + return held.id; +} + +function physical(source: Game, f: TruthTurnCombatFixture) { + assert.deepEqual(inventory(source), f.originalCards); + assert.deepEqual(traitors(source), f.originalTraitors); + assert.equal(inventory(source).length, 33); +} + +void test('detached opaque forecast cannot fund a whole positive weapon-plan quote with the same physically held canonical card', () => { + const f = fixture(), card = originalWeapon(f), original = structuredClone(f.game); + const mixed = plan(f.event, f.territory, f.weaponClaim); + const asked = ask(declare(f).game, f.asker, f.target, mixed), before = structuredClone(asked); + assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['yes', 'no']); + + const opaque = opaqueQuote(asked, card), quoteBefore = structuredClone(opaque); + assert.ok(player(opaque, f.target).hand.some(held => held.id === card)); + const opaqueView = viewGame(opaque, f.target); + assert.deepEqual(opaqueView.truthTurnActionAnswers, ['no']); + assert.equal(Object.hasOwn(opaqueView, '_turnActionUnknownCards'), false); + assert.deepEqual(opaque, quoteBefore); + assert.deepEqual(asked, before); + assert.deepEqual(f.game, original); + assert.equal(Object.hasOwn(asked, '_turnActionUnknownCards'), false); + physical(asked, f); physical(opaque, f); physical(f.game, f); +}); + +void test('detached opaque weapon forecast retains an independent positive dial-plan alternative and whole No', () => { + const f = fixture(), card = originalWeapon(f), original = structuredClone(f.game); + const mixed = group('or', plan(f.event, f.territory, f.weaponClaim), + plan(f.event, f.territory, { kind: 'dial', compare: 'gte', value: 1 })); + const asked = ask(declare(f).game, f.asker, f.target, mixed), before = structuredClone(asked); + const known = viewGame(asked, f.target).truthTurnActionAnswers; + assert.ok(known?.includes('yes')); + assert.ok(known?.includes('no'), 'An unarmed zero-dial plan satisfies the whole No'); + + const opaque = opaqueQuote(asked, card), quoteBefore = structuredClone(opaque); + const answers = viewGame(opaque, f.target).truthTurnActionAnswers; + assert.ok(answers?.includes('yes'), 'The positive-dial branch does not borrow the opaque weapon'); + assert.ok(answers?.includes('no'), 'An unarmed zero-dial plan remains legal with the opaque card held'); + assert.deepEqual(opaque, quoteBefore); + assert.deepEqual(asked, before); + assert.deepEqual(f.game, original); + assert.equal(Object.hasOwn(asked, '_turnActionUnknownCards'), false); + physical(asked, f); physical(opaque, f); physical(f.game, f); +}); diff --git a/tests/truth-turn-action-controls.test.tsx b/tests/truth-turn-action-controls.test.tsx index 282697e4..de957259 100644 --- a/tests/truth-turn-action-controls.test.tsx +++ b/tests/truth-turn-action-controls.test.tsx @@ -31,7 +31,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' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } }); +const printedCard = (effect: 'hajr' | 'ghola' | 'nullentropyBox' = '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' @@ -141,6 +141,14 @@ function openingAmal(phase = 4, next: Action = { type: 'card', card: 'controlled game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('amal', `phase-opening:2:${phase}`), payment()] }; return game; } +function boxPlay(phase = 7): GameView { + const game = projected(phase, { type: 'card', card: 'controlled-box' }); + const card = { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' }; + const me = game.players.find(player => player.id === 'p')!; me.hand = [card]; me.handLimit = 4; me.spice = 3; + game.active = 'a'; game.nullentropy = { card, blocked: null, search: null }; + game.turnActionPromises[0].mixed = { kind: 'and', terms: [printedCard('nullentropyBox'), 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'; @@ -1252,24 +1260,36 @@ void test('turn-action printed-role editor uses public names without holdings or for (const player of game.players) for (const key of ['hand', 'spice', 'traitors', 'faceDancers', 'tanks', 'leaders', 'hajrAvailable']) Object.defineProperty(player, key, { get() { throw Error(`No private ${key} authoring inspection`); }, configurable: true }); Object.defineProperty(game, 'ghola', { get() { throw Error('No private Ghola authoring inspection'); }, configurable: true }); + Object.defineProperty(game, 'nullentropy', { get() { throw Error('No private Box authoring inspection'); }, configurable: true }); Object.defineProperty(game.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']); + assert.deepEqual(choices.map(choice => choice.props.value), ['hajr', 'ghola', 'nullentropyBox']); 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); assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'ghola', card: 'private-card-id' } }, game, 'p')); assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'karama' } }, game, 'p')); + for (const extra of [{ card: 'private-box-id' }, { selected: 'private-recovery' }, { amount: 2 }]) + assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'cardPlay', effect: 'nullentropyBox', ...extra } }, game, 'p')); + const draft = editor({ kind: 'action', action: { kind: 'cardPlay', effect: 'unknown-role' } } as unknown as Expression, false, game); + assert.ok(draft.error()); select(draft.tree(), '1', 'effect', 'nullentropyBox'); + assert.deepEqual(draft.value, printedCard('nullentropyBox')); assert.equal(draft.error(), null); const html = e.html(); assert.match(html, /Play Hajr in its printed role after this answer this turn/); assert.match(html, /Play Tleilaxu Ghola in its printed role after this answer this turn/); + assert.match(html, /Activate Nullentropy Box and pay 2 spice after this answer this turn/); + assert.match(html, /The selected recovered card stays private and is not promised/); assert.match(html, /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('ghola')] }, false, game); - for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'cardPlay'); } + const full = editor({ kind: 'or', terms: [printedCard(), printedCard('nullentropyBox')] }, false, game); + for (let count = 3; count <= 16; count++) { + click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'cardPlay'); + select(full.tree(), `1.${count}`, 'effect', 'nullentropyBox'); + } 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('ghola')] }, false, game); + const deep = editor({ kind: 'and', terms: [fact(), printedCard('nullentropyBox')] }, 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'); @@ -1345,9 +1365,72 @@ void test('printed Hajr guides require the actual held card and current own imme assert.equal(guide({ game: missing, busy: false, act() { assert.fail('A missing Hajr cannot dispatch'); } }).props.disabled, true); }); +void test('printed Box guides activate the quoted physical card without inspecting private recovery choices', () => { + for (const phase of [0, 6, 8]) { + const game = boxPlay(phase), sent: Action[] = []; + Object.defineProperty(game, 'discard', { get() { throw Error('The activation guide does not search the discard'); }, configurable: true }); + Object.defineProperty(game.turnActionPromises[0], 'cardPlays', { get() { throw Error('No private Box 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: 'controlled-box', turnActionPromiseEvent: event, turnActionPromisePhase: phase }); + const html = markup(game); + assert.match(html, /Pay 2 spice to activate Nullentropy Box/); + assert.match(html, /you activate Nullentropy Box in its printed role and pay its 2-spice search cost/); + assert.match(html, /The selected recovered card stays private and is not promised/); + assert.match(html, /complete the paid search with the existing native controls/); + assert.doesNotMatch(html, /cardPlays|controlled-box|recovered-card-id/); + } +}); + +void test('printed Box activation callbacks reject changed native quotes, custody, source and paid-search state', () => { + const changes: ((game: GameView) => void)[] = [ + game => { game.nullentropy = null; }, + game => { game.nullentropy = { ...game.nullentropy! }; }, + game => { game.nullentropy!.blocked = 'The printed search cost is not currently available.'; }, + game => { game.nullentropy!.card = { ...game.nullentropy!.card, id: 'different-box' }; }, + game => { game.players.find(player => player.id === 'p')!.hand = []; }, + game => { game.players.find(player => player.id === 'p')!.hand![0].kind = 'worthless'; }, + game => { game.players.find(player => player.id === 'p')!.spice = 2; }, + game => { game.players.find(player => player.id === 'p')!.handLimit = 5; }, + game => { game.turnActionPromises[0].mixed = payment(); }, + game => { game.phase = 8; }, + game => { game.turnActionGuide!.event = 'different-answer'; }, + game => { game.phaseOpening = { passed: [] }; }, + game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; }, + game => { + game.nullentropy!.search = { event: 'now-paid-search', cards: [] }; + Object.defineProperty(game.nullentropy!.search, 'cards', { get() { throw Error('An activation guide cannot inspect paid recovery choices'); } }); + }, + ]; + for (const change of changes) { + const game = boxPlay(2), sent: Action[] = [], button = guide({ game, busy: false, act(action) { sent.push(action); } }); + assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []); + } + for (const change of [ + (game: GameView) => { game.nullentropy = null; }, + (game: GameView) => { game.nullentropy!.blocked = 'No legal search is currently quoted.'; }, + (game: GameView) => { game.nullentropy!.search = { event: 'already-paid', cards: [] }; }, + ]) { + const game = boxPlay(); change(game); + const button = guide({ game, busy: false, act() { assert.fail('An unavailable or already paid Box cannot activate'); } }); + assert.equal(button.props.disabled, true); press(button); + } +}); + +void test('printed Box activation accepts no private selection or extra native parameters through generic preparation', () => { + for (const extra of [{ amount: 2 }, { mode: 'shipment' }, { target: 'a' }, { leader: 'private-leader' }, + { selected: 'recovered-card-id' }, { cards: ['recovered-card-id'] }]) { + const game = boxPlay(2); Object.assign(game.turnActionGuide!.action, extra); + const button = guide({ game, busy: false, act() { assert.fail('A Box activation must remain the exact quoted card packet'); } }); + assert.equal(button.props.disabled, true); press(button); + } + const game = boxPlay(8); game.turnActionPromises[0].mixed = payment(); + assert.equal(guide({ game, busy: false, act() { assert.fail('A turn-end Box activation requires its matching raw role'); } }).props.disabled, true); +}); + void test('printed-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')] }] }; + game.turnActionPromises[0].mixed = { kind: 'or', terms: [printedCard(), { kind: 'and', terms: [fact(), printedCard('ghola'), printedCard('nullentropyBox')] }] }; for (const key of ['cardPlays', 'compiled', 'combat', 'preparation']) Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true }); const html = markup(game); @@ -1355,6 +1438,7 @@ void test('printed-card history retains only raw aggregate conditions and their assert.match(html, /Hajr counts even if its extra movement is unused/); assert.match(html, /Earlier plays, discards, sales, Karama roles and recovery alone do not count/); assert.match(html, /actual later Box replay does/); assert.match(html, /Ghola is separate from ordinary Revival/); + assert.match(html, /you activate Nullentropy Box/); assert.match(html, /Box conditions close at the actual turn end/); assert.match(html, /remains open until the actual turn ends/); assert.doesNotMatch(html, /cardPlays|compiled|controlled-hajr|controlled-ghola/); }); diff --git a/tests/truthtrance.test.ts b/tests/truthtrance.test.ts index 075993da..0303c492 100644 --- a/tests/truthtrance.test.ts +++ b/tests/truthtrance.test.ts @@ -15,6 +15,7 @@ import { DIFFICULTIES } from '../game/bot-profiles'; import { truthFactAnswer, truthQuestionText, + validateTruthQuestionReceipt, type TruthFact, type TruthQuestion, } from '../game/truthtrance'; @@ -31,6 +32,29 @@ void test('whole card questions name Amal, Thumper and the actual printed Karama assert.match(question('karamaShipment', '["karamaShipment",2,"emperor","original"]'), /Karama.*Emperor’s original unused shipment price/); }); +void test('whole Box questions describe future printed activation and its search payment without choosing the recovered card', () => { + const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2, + mixed: { kind: 'action', action: { kind: 'cardPlay', effect: 'nullentropyBox' } } }; + const text = truthQuestionText(question, id => id, id => id); + assert.match(text, /activate Nullentropy Box.*printed role.*pay its 2-spice search cost after this answer this turn/); + assert.doesNotMatch(text, /Ghola|selected|recover.*Shield/); +}); + +void test('Box raw question parsing needs no hidden possession and eagerly rejects extra selected-card data in folded clauses', () => { + const game = fixture(); + assert.ok(!game.players.find(player => player.id === 'e')!.hand.some(card => card.effect === 'nullentropyBox')); + const action = { kind: 'cardPlay' as const, effect: 'nullentropyBox' as const }; + const question: TruthQuestion = { kind: 'turnActions', target: 'e', turn: 2, + mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } }, + { kind: 'action', action }] } }; + assert.doesNotThrow(() => validateTruthQuestionReceipt(game, 'a', question)); + for (const extra of [{ selected: 'Shield' }, { card: 'physical-box' }, { cost: 2 }, { recipient: 'h' }]) { + const invalid = { ...question, mixed: { kind: 'or', terms: [{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 0 } }, + { kind: 'action', action: { ...action, ...extra } }] } }; + assert.throws(() => validateTruthQuestionReceipt(game, 'a', invalid as TruthQuestion)); + } +}); + 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 23c11c77..1be9b1ab 100644 --- a/tests/turn-action-promises.test.ts +++ b/tests/turn-action-promises.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { battlePowerOf, type BattlePowerClaim, type BattlePowerDeclaration } from '../game/battle-power-question'; import { WINDOW_CARD_EFFECTS, type WindowCardEffect } from '../game/window-card-play'; +import { PRINTED_CARD_EFFECTS, type PrintedCardEffect } from '../game/printed-card-play'; import { compileMixedActionExpression, type MixedActionExpression, @@ -56,7 +57,7 @@ const plan = (claim: TurnActionPlanValue['claim'] = { kind: 'dial', compare: 'gt ({ kind: 'action', action: { kind: 'battlePlan', battle, territory: 'carthag', claim } }); const call = (call = true, battle = 'battle:1'): Expression => ({ kind: 'action', action: { kind: 'traitorCall', battle, territory: 'carthag', call } }); -const card = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } }); +const card = (effect: PrintedCardEffect = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } }); const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expression => ({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } }); const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment' @@ -98,9 +99,16 @@ function combat(mixed: Expression, answer = true, id = 'turn-action:1'): TurnAct } const dial = (value = true): TurnActionPlanValue => ({ claim: { kind: 'dial', compare: 'gte', value: 2 }, value }); function cards(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise { - return { ...promise(mixed, answer, id), cardPlays: { + const row: TurnActionPromise = { ...promise(mixed, answer, id), cardPlays: { hajr: { played: false, closed: false }, ghola: { played: false, closed: false }, } }; + const visit = (node: Expression): void => { + if (node.kind === 'action' && node.action.kind === 'cardPlay' && node.action.effect === 'nullentropyBox') + row.cardPlays!.nullentropyBox = { played: false, closed: false }; + else if (node.kind !== 'action' && node.kind !== 'fact') node.terms.forEach(visit); + }; + visit(mixed); + return row; } function powers(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise { const row: TurnActionPromise = { ...promise(mixed, answer, id), combat: { event: 'battle:1', territory: 'carthag', @@ -917,7 +925,7 @@ void test('native off-board combat locations retain exact event and location bin }); void test('printed card-play leaves contain only the printed effect and every raw clause retains its own stage', () => { - for (const effect of ['hajr', 'ghola'] as const) { + for (const effect of PRINTED_CARD_EFFECTS) { const original = { kind: 'cardPlay', effect }; assert.deepEqual(parseTurnActionLeaf(original), original); assert.notEqual(parseTurnActionLeaf(original), original); @@ -1618,3 +1626,66 @@ void test('Amal whole AND and OR keep printed receipt and later payment alternat assert.equal(expired.fulfilled, true); validateTurnActionPromises([expired], seats); }); + +void test('Box AND and OR branches count only an actual future printed activation in each answer interval', () => { + const original = cards(group('and', card('nullentropyBox'), card('ghola'), pay())); + const alternative = cards(group('or', card('nullentropyBox'), pay()), true, 'turn-action:2'); + const signed = { kind: 'cardPlay', effect: 'nullentropyBox', answer: true }; + assert.deepEqual(branches(original), [[signed, { kind: 'cardPlay', effect: 'ghola', answer: true }, signedPay()]]); + for (const [player, turn] of [['other', 1], ['respondent', 2]] as const) + assert.equal(recordTurnActionCardPlay([original], player, turn, 'nullentropyBox')[0], original); + const used = recordTurnActionCardPlay([original, alternative], 'respondent', 1, 'nullentropyBox'); + assert.deepEqual(branches(used[0]), [[{ kind: 'cardPlay', effect: 'ghola', answer: true }, signedPay()]]); + assert.equal(used[1].fulfilled, true); + const fresh = cards(card('nullentropyBox'), true, 'turn-action:3'); + assert.equal(fresh.cardPlays!.nullentropyBox!.played, false, 'An earlier accepted play or later recovery is not a future receipt'); + const replay = recordTurnActionCardPlay([used[0], fresh], 'respondent', 1, 'nullentropyBox'); + assert.equal(replay[0], used[0]); + assert.equal(replay[1].fulfilled, true); + assert.equal(original.cardPlays!.nullentropyBox!.played, false); + const ghola = recordTurnActionCardPlay([used[0]], 'respondent', 1, 'ghola'); + assert.equal(recordTurnActionPayment(ghola, 'respondent', 1, payment())[0].fulfilled, true); + validateTurnActionPromises(used, seats); +}); + +void test('Box No remains binding through transport and payment closure and settles only at the turn deadline', () => { + const no = cards(card('nullentropyBox'), false); + const moved = closeTurnActionTransport([no], 'respondent', 1); + assert.deepEqual(moved[0].cardPlays!.nullentropyBox, { played: false, closed: false }); + const unpaid = closeTurnActionPayments(moved, 'respondent', 1); + assert.equal(unpaid[0].fulfilled, false); + assert.throws(() => recordTurnActionCardPlay(unpaid, 'respondent', 1, 'nullentropyBox'), TurnActionError); + assert.equal(unpaid[0].cardPlays!.nullentropyBox!.played, false); + const expired = finishTurnActionTurn(unpaid, 'respondent', 1)[0]; + assert.equal(expired.fulfilled, true); + assert.deepEqual(expired.cardPlays!.nullentropyBox, { played: false, closed: true }); + assert.equal(recordTurnActionCardPlay([expired], 'respondent', 1, 'nullentropyBox')[0], expired); + const wholeNo = cards(group('and', card('nullentropyBox'), pay()), false, 'turn-action:2'); + const used = recordTurnActionCardPlay([wholeNo], 'respondent', 1, 'nullentropyBox'); + assert.throws(() => recordTurnActionPayment(used, 'respondent', 1, payment()), TurnActionError); + assert.deepEqual(used[0].payments, []); + const yes = cards(card('nullentropyBox'), true, 'turn-action:3'); + assert.equal(finishTurnActionTurn([yes], 'respondent', 1)[0].released, true); + validateTurnActionPromises([expired], seats); +}); + +void test('optional Box stage preserves legacy bytes and strict raw folded metadata without recording a selected card', () => { + const old = cards(card('ghola'), false); + const before = JSON.stringify(old); + const unchanged = recordTurnActionCardPlay([old], 'respondent', 1, 'nullentropyBox')[0]; + assert.equal(unchanged, old); + assert.equal(JSON.stringify(unchanged), before); + assert.equal(Object.hasOwn(closeTurnActionTransport([old], 'respondent', 1)[0].cardPlays!, 'nullentropyBox'), false); + assert.equal(Object.hasOwn(finishTurnActionTurn([old], 'respondent', 1)[0].cardPlays!, 'nullentropyBox'), false); + const row = cards(group('or', fact(), card('nullentropyBox'))); + const stage = row.cardPlays!.nullentropyBox!; + for (const value of [undefined, null, { ...stage, selected: 'physical-recovered-card' }, + { ...stage, cost: 2 }, { ...stage, played: 1 }, { ...stage, closed: 'yes' }]) + assert.throws(() => turnActionValue({ ...row, cardPlays: { hajr: null, ghola: null, + nullentropyBox: value } } as unknown as TurnActionPromise), TurnActionError); + assert.throws(() => turnActionValue({ ...row, cardPlays: { hajr: null, ghola: null } }), TurnActionError); + const optional = { ...old, cardPlays: { ...old.cardPlays!, nullentropyBox: null } }; + validateTurnActionPromises([optional], seats); + for (const extra of [{ selected: 'Shield' }, { card: 'physical-box' }, { minimum: 2 }, { recipient: 'other' }]) + assert.throws(() => parseTurnActionLeaf({ kind: 'cardPlay', effect: 'nullentropyBox', ...extra }), TurnActionError); +});