diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 0634f535..ad10bf1c 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -581,7 +581,7 @@ export function Truthtrance({ Combine current facts and ordinary bids Bind this battle’s Traitor declaration Combine current facts and this Traitor declaration - Combine native battle powers, Hajr/Ghola plays and this turn’s other actions + Combine printed card plays, native battle powers and this turn’s other actions Combine this turn’s shipment and ground movements Bind a shipment from reserves @@ -603,6 +603,7 @@ export function Truthtrance({ {!g.truthTurnActionTargets.includes(target) && Choose a player with supported native Voice or Prescience, printed card plays, current battle, Traitor declaration, alliance, ordinary bid, Revival, payment, shipment or ground-movement opportunities this turn. + Weather Control, Family Atomics and Harvester refer only to their original current Storm or fresh spice-blow window. Ordinary payments may remain available after their Shipment and Movement opportunity ends.} {turnActionsError ?? truthQuestionText(question, leaderName, locationName, name)} > diff --git a/components/turn-action-fields.tsx b/components/turn-action-fields.tsx index ec7a1c10..0bda85c4 100644 --- a/components/turn-action-fields.tsx +++ b/components/turn-action-fields.tsx @@ -6,6 +6,7 @@ import type { MixedActionExpression } from '@/game/mixed-action-question'; import { parseBattleActionClaim } from '@/game/mixed-battle-question'; import { battlePowerOf, nativeBattlePowerOpportunity, type BattlePowerClaim } from '@/game/battle-power-question'; import { battleCardLabel, VOICE_KINDS } from '@/game/battle-cards'; +import { windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play'; import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES, type TurnActionLeaf } from '@/game/turn-action-promises'; import { BattleClaimFields } from './battle-promises'; import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields'; @@ -46,6 +47,8 @@ const newPowerClaim = (game: GameView, target: string): BattlePowerClaim => nativeBattlePowerOpportunity(game, target, 'voice') ? { kind: 'voice', use: true } : nativeBattlePowerOpportunity(game, target, 'prescience') ? { kind: 'prescience', use: true } : { kind: 'voice', use: true }; +const windowEffects: WindowCardEffect[] = ['weather', 'atomics', 'harvester']; +const windowRoleNames: Record = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester' }; const config: MixedActionConfig = { prefix: 'turn-action', legend: 'Current battle and this-turn action conditions, with optional current facts', @@ -74,6 +77,9 @@ const config: MixedActionConfig = { 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. + 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. Shipment conditions concern the single future original reserve shipment after the answer. Ground-movement conditions use the maximum physical forces in one actual future move to that destination; smaller moves never add together. Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity. @@ -106,11 +112,17 @@ const config: MixedActionConfig = { if (!opportunity || action.battle !== opportunity.event || action.territory !== opportunity.territory) throw new Error('Choose this player’s original native Voice or Prescience opportunity in the current battle.'); } + if (action.kind === 'windowCardPlay') { + const opportunity = windowCardOpportunity(game, action.effect); + if (!opportunity || opportunity.turn !== game.turn || action.source !== opportunity.source) + throw new Error('Choose the original currently open Storm or fresh spice-blow window for this printed role.'); + } return action; }, renderAction: ({ value, id, game, target, disabled, onChange }) => { const choices = recipients(game, target), leaderChoices = leaders(game, target), lot = currentLot(game), partnerChoices = partners(game, target); const currentPower = value.kind === 'battlePower' ? powerOpportunity(game, target, value.claim) : null; + const currentWindow = value.kind === 'windowCardPlay' ? windowCardOpportunity(game, value.effect) : null; return <> Future action { @@ -118,6 +130,10 @@ const config: MixedActionConfig = { if (kind === value.kind) return; const minimum = 'minimum' in value ? value.minimum : 1; if (kind === 'cardPlay') onChange({ kind, effect: 'hajr' }); + if (kind === 'windowCardPlay') { + const effect = windowEffects.find(effect => windowCardOpportunity(game, effect)) ?? 'weather'; + onChange({ kind, effect, source: windowCardOpportunity(game, effect)?.source ?? '' }); + } if (kind === 'battlePower') { const claim = newPowerClaim(game, target), opportunity = powerOpportunity(game, target, claim); onChange({ kind, battle: opportunity?.event ?? '', territory: opportunity?.territory ?? '', claim }); @@ -134,6 +150,7 @@ const config: MixedActionConfig = { territory: value.kind === 'ship' || value.kind === 'move' ? value.territory : 'carthag', minimum }); }}> Play Hajr or Tleilaxu Ghola in its printed role + Play Weather Control, Family Atomics or Harvester in this window Declare native Voice or Prescience in this battle Use an actual current battle-plan choice Make the current Traitor declaration @@ -155,6 +172,30 @@ const config: MixedActionConfig = { Play Hajr in its printed role after this answer this turn Play Tleilaxu Ghola in its printed role after this answer this turn + > : value.kind === 'windowCardPlay' ? <> + Printed role in the original window + { + const effect = event.currentTarget.value; + if (effect === 'weather' || effect === 'atomics' || effect === 'harvester') + onChange({ kind: 'windowCardPlay', effect, source: windowCardOpportunity(game, effect)?.source ?? '' }); + }}> + Choose a printed role + {windowEffects.map(effect => {windowRoleNames[effect]})} + + Original native window + { + const opportunity = windowCardOpportunity(game, value.effect); + if (event.currentTarget.value === '') onChange({ ...value, source: '' }); + else if (opportunity?.source === event.currentTarget.value) onChange({ ...value, source: opportunity.source }); + }}> + Choose the original open window + {currentWindow && {value.effect === 'harvester' + ? `Turn ${currentWindow.turn} · Fresh spice blow in ${game.combatLocations.find(location => location.id === game.spiceWindow?.territory)?.name ?? game.spiceWindow?.territory}` + : `Turn ${currentWindow.turn} · Before this Storm movement`}} + + {!currentWindow && This printed role has no current original open native window.} > : value.kind === 'battlePower' ? <> Original native declaration { diff --git a/components/turn-action-promises.tsx b/components/turn-action-promises.tsx index d95f4632..1116e975 100644 --- a/components/turn-action-promises.tsx +++ b/components/turn-action-promises.tsx @@ -9,6 +9,9 @@ import { ordinaryCardAvailability } from '@/game/card-availability'; import { gholaForcePreparations } from '@/game/ghola-force-preparation'; import { nativeBattlePowerOpportunity, type NativeBattlePower } from '@/game/battle-power-question'; import { battleCardLabel, VOICE_KINDS } from '@/game/battle-cards'; +import { windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play'; +import type { TurnActionLeaf } from '@/game/turn-action-promises'; +import type { MixedActionExpression } from '@/game/mixed-action-question'; import { truthQuestionText, type TruthAnswer } from '@/game/truthtrance'; import { CHEAP_HERO_TRAITOR } from '@/game/traitors'; import { Button } from './ui/button'; @@ -23,6 +26,11 @@ const publicBattleContext = (game: GameView) => game.battle && ({ preparation: game.battle.preparation, traitorVoters: game.battle.traitorVoters, traitorSubmitted: game.battle.traitorSubmitted, }); +const isWindowEffect = (effect: unknown): effect is WindowCardEffect => effect === 'weather' || effect === 'atomics' || effect === 'harvester'; +const windowRoleNames: Record = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester' }; +const namesWindow = (mixed: MixedActionExpression, effect: WindowCardEffect, source: string): boolean => + mixed.kind === 'action' ? mixed.action.kind === 'windowCardPlay' && mixed.action.effect === effect && mixed.action.source === source + : mixed.kind !== 'fact' && mixed.terms.some(term => namesWindow(term, effect, source)); /** One projected native packet; no client branch selection or completion solver. */ export function TurnActionGuideButton(props: TurnActionProps) { @@ -56,6 +64,24 @@ export function TurnActionGuideButton(props: TurnActionProps) { const cardOwner = action.type === 'card' ? game.players.find(player => player.id === actor) : undefined; const heldCard = cardOwner?.hand?.find(card => card.id === action.card); const cardSignature = heldCard && JSON.stringify({ id: heldCard.id, kind: heldCard.kind, effect: heldCard.effect }); + const windowEffect = isWindowEffect(heldCard?.effect) ? heldCard.effect : null; + const cardWindow = windowEffect ? windowCardOpportunity(game, windowEffect) : null; + const earlyWindowReadyAction = action.type === 'ready' && (phase === 0 || phase === 1); + const windowPromise = action.type === 'card' || earlyWindowReadyAction + ? game.turnActionPromises.find(promise => promise.event === event && promise.player === actor && promise.turn === turn) : null; + const windowQuestion = windowPromise && JSON.stringify(windowPromise.mixed); + const publicWindowState = (current: GameView) => JSON.stringify({ + opportunity: windowEffect ? windowCardOpportunity(current, windowEffect) + : (['weather', 'atomics', 'harvester'] as const).map(effect => windowCardOpportunity(current, effect)), + stormOpen: current.stormCardWindowOpen, + stormPending: current.stormPending, wallDestroyed: current.shieldWallDestroyed, + blow: current.spiceWindow, + ...(windowEffect === 'atomics' ? { forces: current.players.find(player => player.id === actor)?.forces, + advisors: current.players.find(player => player.id === actor)?.advisors } : {}), + }); + const windowSignature = windowEffect || heldCard?.effect === 'nullentropyBox' || earlyWindowReadyAction ? 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 ghola = heldCard?.effect === 'ghola' ? game.ghola : null, gholaSignature = JSON.stringify(ghola); const hajrSource = (current: GameView) => { const player = current.players.find(player => player.id === actor); @@ -83,7 +109,37 @@ export function TurnActionGuideButton(props: TurnActionProps) { const printedGhola = gholaSource && !!ghola && (action.leader !== undefined ? typeof action.leader === 'string' && (action.leader === 'kwisatz' ? ghola.kwisatz : ghola.leaders.some(leader => leader.id === action.leader)) : action.elite === undefined ? forceChoices.length === 1 : forceChoices.some(choice => choice.elite === action.elite)); - const genericCard = ownCard && !!heldCard && heldCard.effect !== 'ghola' && heldCard.effect !== 'hajr'; + const windowPacket = Object.keys(action).every(key => ['type', 'card', 'turnActionPromiseEvent', 'turnActionPromisePhase', + ...(windowEffect === 'weather' ? ['amount'] : [])].includes(key)) && + (windowEffect !== 'weather' || typeof action.amount === 'number' && Number.isSafeInteger(action.amount) && action.amount >= 0 && action.amount <= 10); + const printedWindow = ownCard && !!heldCard && heldCard.kind === 'special' && !!windowEffect && !!cardWindow && + action.mode === undefined && windowPacket && !!windowPromise && + current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion && + namesWindow(windowPromise.mixed, windowEffect, cardWindow.source) && + (windowEffect !== 'harvester' || current.spiceWindow === originalBlow) && publicWindowState(current) === windowSignature && + ordinaryCardAvailability(current, heldCard.id, windowEffect === 'weather' ? { amount: action.amount } : undefined)?.available === true; + const genericCard = ownCard && !!heldCard && heldCard.effect !== 'ghola' && heldCard.effect !== 'hajr' && !isWindowEffect(heldCard.effect); + const earlyWindowBox = (phase === 0 || phase === 1) && 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 && + !box.blocked && !box.search && box.card.id === heldCard.id && + !!windowPromise && current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion && + (['weather', 'atomics', 'harvester'] as const).some(effect => { + const opportunity = windowCardOpportunity(current, effect); + return !!opportunity && namesWindow(windowPromise.mixed, effect, opportunity.source); + }) && publicWindowState(current) === windowSignature; + const earlyWindowReady = earlyWindowReadyAction && !current.ready.includes(actor) && + Object.keys(action).every(key => ['type', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key)) && + !!windowPromise && current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion && + publicWindowState(current) === windowSignature && + (phase === 0 ? current.stormPending !== null && (['weather', 'atomics'] as const).some(effect => { + const opportunity = windowCardOpportunity(current, effect); + return !!opportunity && namesWindow(windowPromise.mixed, effect, opportunity.source); + }) : current.spiceWindow === originalBlow && (() => { + const opportunity = windowCardOpportunity(current, 'harvester'); + return !!opportunity && namesWindow(windowPromise.mixed, 'harvester', opportunity.source); + })()); const options = current.bribeOptions; const target = action.type === 'bribe' && typeof action.target === 'string' ? current.players.find(player => player.id === action.target) : undefined; @@ -140,7 +196,7 @@ export function TurnActionGuideButton(props: TurnActionProps) { const battlePreparation = genericCard && planWindow; const declaration = action.type === 'traitorCall' && typeof action.call === 'boolean' && !!battle && currentBattle && battle.revealed && battle.traitorVoters.includes(actor) && !battle.traitorSubmitted.includes(actor); - const opportunity = ownedPower || payment || paidBox || printedHajr || printedGhola || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2 + const opportunity = earlyWindowReady || earlyWindowBox || printedWindow || ownedPower || payment || paidBox || printedHajr || printedGhola || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2 ? !current.ready.includes(actor) && (['charity', 'ready'].includes(action.type) || genericCard) : phase === 3 ? invoice || nativePass || requiredBid || action.type === 'passBid' && !current.richeseAuction && currentAuction && current.auction?.active === actor @@ -173,6 +229,7 @@ export function TurnActionGuideButton(props: TurnActionProps) { : action.type === 'card' && heldCard?.effect === 'ghola' ? action.leader !== undefined ? `Play Tleilaxu Ghola to return ${gholaLeader} while honoring your whole answer` : `Play Tleilaxu Ghola to return ${amount} physical forces 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))}` : action.type === 'prescience' ? `Request the ${String(action.field)} field with native Prescience while honoring your whole answer` @@ -184,7 +241,8 @@ export function TurnActionGuideButton(props: TurnActionProps) { : `Accept ${recipient}’s alliance invitation while preserving your whole answer` : action.type === 'charity' ? 'Claim Charity while preserving your whole answer' : action.type === 'ready' ? phase === 8 ? 'Ready to end this turn while honoring your whole answer' - : phase === 1 ? 'Confirm Ready for this Nexus while preserving your whole answer' + : earlyWindowReadyAction && (phase === 0 || !!game.spiceWindow) ? `Accept this ${phase === 0 ? 'Storm' : 'fresh spice-blow'} window while preserving your whole answer` + : phase === 1 ? 'Confirm Ready for this Nexus while preserving your whole answer' : 'Finish this preparation phase while preserving your whole answer' : action.type === 'passBid' ? 'Pass this bid while preserving your whole answer' : action.type === 'bid' ? `Place the required bid of ${amount} while preserving your whole answer` @@ -253,6 +311,9 @@ export function TurnActionPromises(props: TurnActionProps) { 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. + 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. Shipment conditions concern one future original reserve shipment. Ground-movement conditions count the maximum physical forces in one actual future move, never the sum of smaller moves. Earlier shipment and movement do not count. Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index face021c..30cb9b1c 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,10 +16,50 @@ open mode gates. ## Current checkpoint and work -**Next: remaining Basic card/action declarations and native timing.** +**Next: remaining Basic card/action coverage and native timing.** Continue remaining Basic functions before Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Truthtrance / original-window card plays — 27 October. Prototyped / Partial.** +Weather Control, Family Atomics and Harvester now have source-bound future +printed-play conditions in whole turn answers. Real Storm movement closes its +card window; each fresh blow gets a stable identity, so a later Advanced blow +cannot inherit the first blow's No. Original effects, costs, forces and typed +Weather0..10 remain. Definite answers reopen own acceptance; Ready must retain +a completion after source closure. A No cap lasts through actual closure. + +Focused sources earn Harkonnen's real two-counter Shield Wall shipment at +cost4 before Storm2, then actual Atomics sends both to Tanks. Weather6 before +dials, Weather8 after reveal, Atomics36 and Harvester14 prefixes derive from +actual arrays and preserve original33 Treachery/15 Traitors. An actual paired +A/E Truth deal with H Weather/Hajr proves two questions: Weather's button must +bind its matching later row while the earlier Hajr answer remains live. It then +continues to real Movement/Hajr and fulfills both. Feedback fixes that guide +binding; first Storm opening and type feedback are also repaired. + +The final frozen batch passes **18 files,303/303**, types/lint/build. Thirty-nine +new cases include8 source,8 aggregate,7 control,15 native/controlled-view and1 +shared availability case. Nine runtime journeys pass with37 GameTable SSR +renders,10 actual answer callbacks and14 exact guide callbacks. They cover +all three effects, manual Weather10 after reveal, actual Wall casualties, +late payment after source close, foreign Weather, whole No, distinct Advanced +blows and the two-question callback. Four minimal profiles can honor both play +and No-Harvester acceptance. The tempting after-Ready Harvester policy uses a +controlled public view; its real played Game/army remains unchanged. + +The earlier V2 batch passed20 files/324 before the guide follow-up. V3 repeats +the affected18 files and adds the paired-question case; two unchanged early- +travel files (22 cases) are retained only as V2 evidence, not final-source +certification. Bounded Box review finds no recursive whole search or double fee; +window Box/other combinations still need native acceptance. No new assurance +campaign or AI strategy/calibration is run. +Proof/manifest: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-window-proof-v3/`; +journeys/HTML: adjacent `20261027-truth-window-runtime-v3/`. +No HTTP/storage boundary, database, reset/deletion, restart or deployed operation +changes. Wider card/action families and final browser/deployed/recovery/privacy/ +custody acceptance remain open. +[Contract and scope](TRUTHTRANCE_TURN_ACTIONS.md#original-storm-and-fresh-blow-card-plays--27-october-2026). + **Truthtrance / native Voice and Prescience — 27 October. Prototyped / Partial.** Whole turn answers now include original native power use/explicit Decline, exact Voice command or requested Prescience field. All predicates concern the diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index ded83683..814caa7b 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,47 @@ # Dune implementation status +## 27 October 2026 — Original Storm and fresh-blow card plays + +**Truthtrance / original-window card plays — 27 October. Prototyped / Partial.** +Weather Control, Family Atomics and Harvester now have source-bound future +printed-play conditions in whole turn answers. Real Storm movement closes its +card window; each fresh blow gets a stable identity, so a later Advanced blow +cannot inherit the first blow's No. Original effects, costs, forces and typed +Weather0..10 remain. Definite answers reopen own acceptance; Ready must retain +a completion after source closure. A No cap lasts through actual closure. + +Focused sources earn Harkonnen's real two-counter Shield Wall shipment at +cost4 before Storm2, then actual Atomics sends both to Tanks. Weather6 before +dials, Weather8 after reveal, Atomics36 and Harvester14 prefixes derive from +actual arrays and preserve original33 Treachery/15 Traitors. An actual paired +A/E Truth deal with H Weather/Hajr proves two questions: Weather's button must +bind its matching later row while the earlier Hajr answer remains live. It then +continues to real Movement/Hajr and fulfills both. Feedback fixes that guide +binding; first Storm opening and type feedback are also repaired. + +The final frozen batch passes **18 files,303/303**, types/lint/build. Thirty-nine +new cases include8 source,8 aggregate,7 control,15 native/controlled-view and1 +shared availability case. Nine runtime journeys pass with37 GameTable SSR +renders,10 actual answer callbacks and14 exact guide callbacks. They cover +all three effects, manual Weather10 after reveal, actual Wall casualties, +late payment after source close, foreign Weather, whole No, distinct Advanced +blows and the two-question callback. Four minimal profiles can honor both play +and No-Harvester acceptance. The tempting after-Ready Harvester policy uses a +controlled public view; its real played Game/army remains unchanged. + +The earlier V2 batch passed20 files/324 before the guide follow-up. V3 repeats +the affected18 files and adds the paired-question case; two unchanged early- +travel files (22 cases) are retained only as V2 evidence, not final-source +certification. Bounded Box review finds no recursive whole search or double fee; +window Box/other combinations still need native acceptance. No new assurance +campaign or AI strategy/calibration is run. +Proof/manifest: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-window-proof-v3/`; +journeys/HTML: adjacent `20261027-truth-window-runtime-v3/`. +No HTTP/storage boundary, database, reset/deletion, restart or deployed operation +changes. Wider card/action families and final browser/deployed/recovery/privacy/ +custody acceptance remain open. +[Contract and scope](TRUTHTRANCE_TURN_ACTIONS.md#original-storm-and-fresh-blow-card-plays--27-october-2026). + ## 27 October 2026 — Native Voice and Prescience declarations **Truthtrance / native Voice and Prescience — 27 October. Prototyped / Partial.** diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index b0c8a3ed..998466c4 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,19 @@ # Rule decision index +## Truthtrance original-window ordinary card plays — 27 October 2026 + +Compose the existing FAQp8/current-turn AND/OR authority and supplied Advanced +physical39 compliance/release with printed Weather Control, Family Atomics and +Harvester. The question binds the actual original Storm/fresh-blow window and +counts an accepted future printed-role play after the answer, not an earlier +play or a selected distance/amount predicate. Storm movement closes Weather +and Atomics; each fresh blow has its own identity. Existing Harvester stacking, +replay, destroyed-blow policy and physical cards remain authoritative. Own Ready +must retain a completion after window closure; temporary readiness does not +release No caps. This is provisional composition, not a new dedicated publisher +promise ruling. Wider future-card questions and final acceptance remain open. +[Contract](TRUTHTRANCE_TURN_ACTIONS.md#original-storm-and-fresh-blow-card-plays--27-october-2026). + ## Truthtrance native battle-power declarations — 27 October 2026 Compose the existing FAQp8/current-turn AND/OR authority and supplied Advanced diff --git a/docs/TRUTHTRANCE_TURN_ACTIONS.md b/docs/TRUTHTRANCE_TURN_ACTIONS.md index bbeeb570..cfaab206 100644 --- a/docs/TRUTHTRANCE_TURN_ACTIONS.md +++ b/docs/TRUTHTRANCE_TURN_ACTIONS.md @@ -425,3 +425,72 @@ journeys/HTML: adjacent `20261027-truth-power-runtime-v3/`. No HTTP/storage boundary, database, reset/deletion, server restart or deployed operation changes. Borrowed Nexus and special power composition, broader future actions and all final assurance/release gates remain open. + +## Original Storm and fresh-blow card plays — 27 October 2026 + +**Prototyped / Partial.** Add a source-bound action leaf: +`{kind:'windowCardPlay',effect:'weather'|'atomics'|'harvester',source}`. It asks +about an actual future printed-role play after this answer in that original +Storm or fresh-blow window. It does not predicate the selected Weather distance +or resulting spice amount. Earlier play, disposal, another role and recovery +alone do not count. Existing Hajr/Ghola shapes and counters remain unchanged. +This composes existing general current-turn Truth authority and native card +contracts; it is not a new publisher-specific card-promise ruling. + +The original Storm window stays open through dial reveal and closes when actual +movement starts, including continued Storm decisions in phase0. Each newly drawn +fresh blow gets one stable public identity, retained across Harvester plays; +older windows use a stable turn/territory/sector fallback. A subsequent actual +blow has a different source, including the second Advanced blow in phase1. +Harvester retains its existing stacking/replay and destroyed-window policy. + +Private per-answer stages retain exactly the raw named effects, sources and +played/closed booleans. Public own history remains raw question/aggregate only. +Detached witnesses execute actual effects and known owned recovery costs before +sharing resulting pieces, terrain and purse with other obligations. Original +Weather selection0..10, Atomics qualifying fighters/casualties and Harvester's +fresh-blow arithmetic remain native. No branch or speculative card is installed. + +Definite window answers uniformly reopen own concurrent readiness. Own Ready +quotes closure without closing the real source early and must retain a whole +completion, including legal later alternatives. Actual source expiry closes +only that source's stages. No remains binding while the real window is open. +Guides use exact own native card/Box/paid-selection or window-Ready packets. +An owned paid Box preparation reopens its respondent's window acceptance so +its cost cannot leave an owed recovered play behind an earlier Ready. +Minimal profiles can accept a No-Harvester window; automatic Harvester never +reopens their already accepted blow. Complete rules, browser/deployed, +assurance and AI strategy gates remain open. + +Final proof covers18 files/303 passing cases, types/lint/build. Eight source, +eight aggregate, seven control, fifteen native/controlled-view and one shared +availability case add39 checks. Nine original native journeys provide37 full +GameTable SSR renders,10 actual answer callbacks and14 exact guide callbacks. +Original Weather6/8, Atomics36 and Harvester14 prefixes preserve33 Treachery +and15 Traitors. Atomics actually kills the two prior paid4 Wall counters. +Weather's manual10, late Mentat payment, foreign Weather, whole No and the +second actual Advanced blow retain their actual original source semantics. + +The first native source check catches the missing first Storm open marker; +setup now opens it as well as later turns. A two-question regression catches +a guide tagged to an unrelated earlier Hajr row: source-specific card/Box/ +Ready guides now select a live row naming the actual original window. An +unopened paired Truth/Hajr deal makes this real: A binds Hajr/pay2, E binds +Weather, payment leaves Hajr live, the correct Weather callback fulfills E's +answer, then native Movement/Hajr fulfills A's earlier answer. Neither branch +is stored as a separate answer. Types feedback is repaired. + +V2's earlier20-file/324 run precedes this view-only guide correction. V3 reruns +the affected18 files and adds the paired case. The two unchanged early-travel +files'22 cases remain earlier-source evidence and are not counted in303. +The No-Harvester policy additionally uses a controlled public bot view with one +original counter rearranged to the fresh territory; the real native Game and +army are unchanged. Native profile actions still use the actual Ready packet. +Bounded known-Box review finds no recursive whole-question search or double +fee; actual window Box-combination acceptance remains open. +Proof/manifest: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-window-proof-v3/`; +journeys/HTML: adjacent `20261027-truth-window-runtime-v3/`. +This is local engine/SSR/control evidence. No HTTP/storage boundary, database, +reset/deletion, server restart or deployed operation changes. Broader card, +recovery and module combinations and all final assurance/release gates remain +open. diff --git a/game/bots.ts b/game/bots.ts index 6ba53cf9..530e2895 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -3178,6 +3178,7 @@ function policyActions(g: GameView): Action[] { } if ( g.spiceWindow && + !g.ready.includes(me.id) && harvesterAvailable(g.spiceWindow) && countAt(me, g.spiceWindow.territory) ) { diff --git a/game/card-availability.ts b/game/card-availability.ts index 29cc3652..16d9786c 100644 --- a/game/card-availability.ts +++ b/game/card-availability.ts @@ -38,6 +38,8 @@ export type OrdinaryCardState = { decision?: object | null; choamMarket?: { owner: string } | null; shieldWallDestroyed: boolean; + stormCardWindowOpen?: boolean; + stormResolution?: unknown; spiceWindow?: { harvested: boolean; harvesters?: number; @@ -128,7 +130,7 @@ export function ordinaryCardAvailability( ); break; case 'weather': - if (state.phase !== 0) + if (state.phase !== 0 || state.stormCardWindowOpen === false || state.stormResolution) return blocked('Play Weather Control before the storm moves.'); if ( selection && @@ -140,7 +142,7 @@ export function ordinaryCardAvailability( return blocked('Storm movement must be an integer from 0 to 10.'); break; case 'atomics': - if (state.shieldWallDestroyed || state.phase !== 0) + if (state.shieldWallDestroyed || state.phase !== 0 || state.stormCardWindowOpen === false || state.stormResolution) return blocked('Play Family Atomics before storm movement.'); if ( !TERRITORIES.some( diff --git a/game/engine.ts b/game/engine.ts index 8c84d634..27aaa169 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -4,12 +4,14 @@ import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay, type PrintedCardEffect, type PrintedCardPlayCounts } from './printed-card-play'; import { battlePowerOf, battlePowerMatches, nativeBattlePowerOpportunity, type NativeBattlePower, type BattlePowerDeclaration } from './battle-power-question'; +import { windowCardOpportunity, type WindowCardEffect } from './window-card-play'; import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment, recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, closeTurnActionPayments, finishTurnActionTurn, validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival, recordTurnActionCardPlay, closeTurnActionRevival, settleTurnActionAlliance, recordTurnActionPlan, recordTurnActionTraitor, closeTurnActionCombat, recordTurnActionBattlePower, closeTurnActionBattlePower, - type TurnActionPlanValue, type TurnActionCombat, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation } from './turn-action-promises'; + recordTurnActionWindowCardPlay, closeTurnActionWindowCardPlay, + 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'; @@ -2500,6 +2502,8 @@ export type Game = { }[]; stormDials: Record; stormPending: number | null; + /** Actual original Storm card window; absent older games use native phase/resolution. */ + stormCardWindowOpen?: boolean; stormCard: number | null; stormCardKnown: boolean; stormResolution: { @@ -2563,6 +2567,8 @@ export type Game = { winner: string[]; shieldWallDestroyed: boolean; spiceWindow: { + /** Stable identity for a genuinely newly drawn fresh blow. */ + event?: string; territory: string; sector: number; amount: number; @@ -4947,6 +4953,7 @@ function finishNullentropy( } function beginNullentropy(g: Game, owner: Player, cardId: string) { const { candidates } = validateNullentropyBegin(g, owner, cardId); + reopenTurnActionWindowReady(g, owner.id); owner.spice -= 2; const event = crypto.randomUUID(); g.pendingNullentropy = { @@ -12530,6 +12537,7 @@ function finishSetup(g: Game) { .map((leader) => ({ leader, revealed: false })); } g.status = 'playing'; + g.stormCardWindowOpen = true; if (g.homeworlds?.custody) { g.homeworldOccupationHistory = homeworldRule(() => createHomeworldOccupationHistory(homeworldContext(g), g.homeworlds!.custody!, g.turn, crypto.randomUUID())); g.homeworlds.historyVersion = 1; @@ -12588,6 +12596,7 @@ function moveStorm(g: Game, n: number) { offerChoamStorm(g); } function startStormMovement(g: Game, n: number) { + g.stormCardWindowOpen = false; const offer = nexusRule(() => discoveryStormOffer(g)); if (offer && !offer.blocked) { g.ecologicalStorm = nexusRule(() => createDiscoveryStorm(g,crypto.randomUUID())); @@ -13488,7 +13497,7 @@ function continueSpice(g: Game, injected?: SpiceCard) { if (card.discovery) resolveDiscoveryBlow(g, card); g.spiceDiscard[sequence.pile].push(card); const amount = card.amount * (doubleNext ? 2 : 1); - g.spiceWindow = { ...card, amount, harvested: false }; + g.spiceWindow = { ...card, event: crypto.randomUUID(), amount, harvested: false }; const placement = quoteSpicePlacement(g.spice, g.storm, { ...card, amount }); if (!placement.blocked) { g.spice[placement.key] = placement.total; @@ -18908,6 +18917,105 @@ function* turnActionCardSeeds(source: Game, actor: string, requirements: readonl if (projected) yield* plays(projected, [{ type: 'card', card: box.id }]); } } +type TurnActionWindowCardRequirement = Extract; +const isWindowCardEffect = (effect: unknown): effect is WindowCardEffect => + effect === 'weather' || effect === 'atomics' || effect === 'harvester'; +function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffect): Generator { + if (!windowCardOpportunity(g, effect)) return; + const player = getPlayer(g, actor); + for (const card of player.hand.filter(card => card.effect === effect && !transferCardBlock(g, player, card))) + for (const amount of effect === 'weather' ? Array.from({ length: 11 }, (_, value) => value) : [undefined]) { + const availability = ordinaryCardAvailability({ status: g.status, phase: g.phase, turn: g.turn, + active: g.active, me: actor, shieldWallDestroyed: g.shieldWallDestroyed, + stormCardWindowOpen: g.stormCardWindowOpen, stormResolution: g.stormResolution, + spiceWindow: g.spiceWindow, truthtrance: g.truthtrance, response: g.response, + decision: g.decision, phaseOpening: g.phaseOpening, players: [player] }, card.id, { amount }); + if (availability?.available) yield { type: 'card', card: card.id, ...(amount === undefined ? {} : { amount }) }; + } +} +/** Execute real printed effects on detached states before sharing their + * resulting terrain, pieces and recovery costs with the other action families. */ +function* turnActionWindowCardSeeds(source: Game, actor: string, requirements: readonly TurnActionWindowCardRequirement[]): + Generator<{ game: Game; actions: Action[] }> { + const key = (required: TurnActionWindowCardRequirement) => JSON.stringify([required.effect, required.source]); + const search = function* (game: Game, actions: Action[], played: ReadonlySet): Generator<{ game: Game; actions: Action[] }> { + const pending = requirements.filter(required => required.answer && !played.has(key(required))); + if (!pending.length) { + if (requirements.every(required => played.has(key(required)) === required.answer)) yield { game, actions }; + return; + } + for (const required of pending) { + if (windowCardOpportunity(game, required.effect)?.source !== required.source) continue; + const play = function* (state: Game, prefix: Action[]): Generator<{ game: Game; actions: Action[] }> { + for (const action of nativeWindowCardActions(state, actor, required.effect)) { + try { + const next = applyActionInner(state, actor, action, 'movementPreparation'); + yield* search(next, [...actions, ...prefix, action], new Set([...played, key(required)])); + } catch (error) { if (!(error instanceof RuleError)) throw error; } + } + }; + yield* play(game, []); + const paid = game.pendingNullentropy; + if (paid?.player === actor) { + if (!nullentropyIntegrity(game)) for (const card of eligibleNullentropyCards(game.discard).filter(card => card.effect === required.effect)) { + const recovered = projectMovementBoxRecovery(game, actor, paid.box, card.id, true); + if (recovered) yield* play(recovered, [{ type: 'decision', event: paid.event, card: card.id }]); + } + } else for (const box of getPlayer(game, actor).hand.filter(card => card.effect === 'nullentropyBox')) + for (const known of knownMovementDiscards(game.movementDiscardKnowledge, actor, game.discard).filter(card => card.effect === required.effect)) { + const recovered = projectMovementBoxRecovery(game, actor, box.id, known.id, false); + if (recovered) yield* play(recovered, [{ type: 'card', card: box.id }]); + } + } + }; + yield* search(source, [], new Set()); +} +function* turnActionPrintedPreparationSeeds(source: Game, actor: string, cards: readonly TurnActionCardRequirement[], + windows: readonly TurnActionWindowCardRequirement[]): Generator<{ game: Game; actions: Action[] }> { + if (!windows.length) { yield* turnActionCardSeeds(source, actor, cards); return; } + for (const window of turnActionWindowCardSeeds(structuredClone(source), actor, windows)) + for (const card of turnActionCardSeeds(window.game, actor, cards)) + yield { game: card.game, actions: [...window.actions, ...card.actions] }; + // An owned Ghola can supply its real allowed income before a paid Box route. + if (cards.length) for (const card of turnActionCardSeeds(structuredClone(source), actor, cards)) + for (const window of turnActionWindowCardSeeds(card.game, actor, windows)) + yield { game: window.game, actions: [...card.actions, ...window.actions] }; +} +function recordNativeTurnActionWindowCard(g: Game, actor: string, effect: WindowCardEffect, source: string) { + if (g.turnActionPromises) g.turnActionPromises = turnActionRule(() => + recordTurnActionWindowCardPlay(g.turnActionPromises!, actor, g.turn, effect, source)); +} +function reconcileTurnActionWindowCards(g: Game) { + if (!g.turnActionPromises) return; + for (const promise of g.turnActionPromises) { + if (promise.fulfilled || promise.released) continue; + for (const effect of ['weather', 'atomics', 'harvester'] as const) { + const stage = promise.windowCards?.[effect]; + if (!stage || stage.closed || promise.turn === g.turn && windowCardOpportunity(g, effect)?.source === stage.source) continue; + g.turnActionPromises = turnActionRule(() => closeTurnActionWindowCardPlay(g.turnActionPromises!, + promise.player, promise.turn, effect, stage.source)); + } + } +} +/** A concurrent Ready grants permission to close this window. Quote that + * closure without installing it before the real global continuation. */ +function ownTurnActionWindowClosure(g: Game, actor: string): TurnActionPromise[] { + let closed = g.turnActionPromises ?? []; + for (const promise of liveTurnActionPromises(closed, actor, g.turn)) + for (const effect of ['weather', 'atomics', 'harvester'] as const) { + const stage = promise.windowCards?.[effect]; + if (stage && !stage.closed) closed = turnActionRule(() => + closeTurnActionWindowCardPlay(closed, actor, g.turn, effect, stage.source)); + } + return closed; +} +function reopenTurnActionWindowReady(g: Game, actor: string) { + if (g.ready.includes(actor) && liveTurnActionPromises(g.turnActionPromises ?? [], actor, g.turn).some(promise => + (['weather', 'atomics', 'harvester'] as const).some(effect => { + const stage = promise.windowCards?.[effect]; + return stage && !stage.closed && windowCardOpportunity(g, effect)?.source === stage.source; + }))) g.ready = g.ready.filter(player => player !== actor); +} /** Check the actual spent card and returned pieces before a later foreign * income/discard response can hide an own voluntary loss of feasibility. */ function guardTurnActionPrintedCardCost(g: Game, player: Player, used: Card) { @@ -19254,7 +19362,8 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va const voluntary = { actor, action: { type: 'bribe' } }; for (const payment of required) checkBureaucratContributors(paid, payment.recipient, [{ payer: actor, amount: payment.amount }]); - for (const cardSeed of turnActionCardSeeds(paid, actor, cardRequirements)) { + for (const cardSeed of turnActionPrintedPreparationSeeds(paid, actor, cardRequirements, + leaves.filter((leaf): leaf is TurnActionWindowCardRequirement => leaf.kind === 'windowCardPlay'))) { const funded = cardSeed.game, payer = getPlayer(funded, actor); try { reconcileBattlePromises(funded, voluntary); @@ -19320,6 +19429,7 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b const open = turnActionTransportOpen(g, p.id); let bid = false, revival = false, alliance = false, plan = false, traitor = false, cardHajr = false, cardGhola = false; const powers = new Set(); + const windowCards: TurnActionWindowCards = {}; const visit = (node: TurnActionClaim['mixed']): void => { if (node.kind === 'action') { if (node.action.kind === 'bid') bid = true; @@ -19327,6 +19437,9 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b if (node.action.kind === 'battlePlan') plan = true; if (node.action.kind === 'traitorCall') traitor = true; if (node.action.kind === 'battlePower') powers.add(battlePowerOf(node.action.claim)); + if (node.action.kind === 'windowCardPlay') windowCards[node.action.effect] = { + source: node.action.source, played: false, closed: false, + }; if (node.action.kind === 'cardPlay' && node.action.effect === 'hajr') cardHajr = true; if (node.action.kind === 'cardPlay' && node.action.effect === 'ghola') cardGhola = true; if (node.action.kind === 'forces' || node.action.kind === 'leader') revival = true; @@ -19359,6 +19472,7 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b traitor: context ? { beneficiary: context.beneficiary, target: context.target, leader: context.leader, 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 } } : {}), ...(combat ? { combat } : {}), @@ -19371,8 +19485,8 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b g.turn, combat.event, combat.territory, turnActionPlanValues(g, p.id, [claim.mixed])))[0]; return promise; } -function turnActionAnswerSource(g: Game, player: string, alliance = false): Game { - return g.phase === 1 && alliance || g.phase === 2 || g.phase === 4 && !g.revivalOrder || g.phase === 8 || +function turnActionAnswerSource(g: Game, player: string, alliance = false, window = false): Game { + return 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; } @@ -19384,7 +19498,7 @@ function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): Tru try { promise = turnActionPromise(g, p, claim, answer, 'candidate', g.players.find(other => other.id !== p.id)!.id); } catch (error) { if (error instanceof RuleError) continue; throw error; } if (promise.compiled.kind === 'constant' && promise.compiled.value !== answer) continue; - const trial = { ...turnActionAnswerSource(g, p.id, !!promise.alliance), + const trial = { ...turnActionAnswerSource(g, p.id, !!promise.alliance, !!promise.windowCards), turnActionPromises: [...(g.turnActionPromises ?? []), promise], travelPromiseOrder: undefined }; if (turnActionCompletion(trial, p.id, answer && promise.alliance ? consent : undefined)) answers.push(answer ? 'yes' : 'no'); } @@ -19395,7 +19509,7 @@ function bindTurnActionTruth(g: Game, p: Player, claim: TurnActionClaim, answer: requireRule(turnActionTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'), 'Choose a whole turn-action answer compatible with current funding and native actions.'); const promise = turnActionPromise(g, p, claim, answer, crypto.randomUUID(), asker); - g.ready = turnActionAnswerSource(g, p.id, !!promise.alliance).ready; + g.ready = turnActionAnswerSource(g, p.id, !!promise.alliance, !!promise.windowCards).ready; g.turnActionPromises ??= []; initializeTurnActionOrder(g); g.turnActionPromises.push(promise); @@ -19403,6 +19517,7 @@ function bindTurnActionTruth(g: Game, p: Player, claim: TurnActionClaim, answer: } function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) { reconcileTurnActionCombat(g); + reconcileTurnActionWindowCards(g); turnActionRule(() => validateTurnActionPromises(g.turnActionPromises!, g.players.map(player => player.id))); for (const p of g.players) { for (const turn of new Set(g.turnActionPromises!.filter(promise => promise.player === p.id && @@ -19526,11 +19641,29 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act } if (!action && g.phase === 1 && allianceQuestionWindow(g) && !g.ready.includes(player.id)) action = completion.alliance?.[0] ?? { type: 'ready' }; + if (!action && !g.ready.includes(player.id) && + (g.phase === 0 && g.stormPending !== null || g.phase === 1 && g.spiceWindow)) { + const closed = ownTurnActionWindowClosure(g, player.id); + if (closed !== g.turnActionPromises && turnActionCompletion({ ...g, turnActionPromises: closed }, player.id)) + action = { type: 'ready' }; + } if (!action && [7, 8].includes(g.phase) && !g.ready.includes(player.id)) action = { type: 'ready' }; if (!action) return null; + // The joint quotation can prepare a later answer while an earlier unrelated + // answer remains live. Window controls must bind the row naming that source. + const held = action.type === 'card' ? player.hand.find(card => card.id === action.card) : undefined; + const printedWindow = isWindowCardEffect(held?.effect) ? held.effect : null; + const windows: WindowCardEffect[] = printedWindow ? [printedWindow] + : action.type === 'ready' || held?.effect === 'nullentropyBox' ? ['weather', 'atomics', 'harvester'] : []; + const guidePromise = windows.length ? liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row => + windows.some(effect => { + const stage = row.windowCards?.[effect]; + return stage && !stage.closed && (!printedWindow || !stage.played) && + windowCardOpportunity(g, effect)?.source === stage.source; + })) ?? promise : promise; const packet = { ...structuredClone(action) }; for (const key of ['bidPromiseEvent', 'revivalExpressionPromiseEvent', 'leaderRevivalPromiseEvent', 'revivalPromiseEvent']) delete packet[key]; - return { event: promise.event, action: { ...packet, turnActionPromiseEvent: promise.event, turnActionPromisePhase: g.phase } }; + return { event: guidePromise.event, action: { ...packet, turnActionPromiseEvent: guidePromise.event, turnActionPromisePhase: g.phase } }; } function travelRule(operation: () => T): T { try { return operation(); } @@ -25909,6 +26042,7 @@ function payCharity(g: Game, player: Player, amount: number, homeworld = 0) { techIncome(g, 'production', player); } function beginStormTurn(g: Game) { + g.stormCardWindowOpen = true; if (g.dukeVidal) delete g.dukeVidal.leader.usedAt; for (const p of g.players) if (p.elites) p.elites.revived = 0; g.stormDialers = g.lastBattle; @@ -41670,6 +41804,8 @@ function applyActionInner( response: g.response, decision: g.decision, shieldWallDestroyed: g.shieldWallDestroyed, + stormCardWindowOpen: g.stormCardWindowOpen, + stormResolution: g.stormResolution, spiceWindow: g.spiceWindow, players: [ { @@ -41690,6 +41826,11 @@ function applyActionInner( ); if (availability && !availability.available) throw new RuleError(availability.reason); + if (isWindowCardEffect(c.effect)) { + const source = windowCardOpportunity(g, c.effect); + requireRule(source, 'Retain the original open Storm or fresh spice-blow window.'); + recordNativeTurnActionWindowCard(g, id, c.effect, source.source); + } if (c.effect === 'hajr') { p.hajrPlays = hajrPlayCount(g,p) + 1; recordNativePrintedCardPlay(g, p.id, 'hajr'); @@ -41732,7 +41873,7 @@ function applyActionInner( 'This card’s timing and effect are still being implemented.', ); const used = discard(g, p, c.id); - if (execution === 'live' && (c.effect === 'hajr' || c.effect === 'ghola')) + if (execution === 'live' && (c.effect === 'hajr' || c.effect === 'ghola' || isWindowCardEffect(c.effect))) guardTurnActionPrintedCardCost(g, p, used); log( g, @@ -41748,6 +41889,11 @@ function applyActionInner( } if (t === 'ready') { if (allianceQuestionWindow(g)) guardAlliancePromiseReady(g, p); + if (g.turnActionPromises && (g.phase === 0 || g.phase === 1 && g.spiceWindow)) { + const closed = ownTurnActionWindowClosure(g, p.id); + requireRule(closed === g.turnActionPromises || !!turnActionCompletion({ ...g, turnActionPromises: closed }, p.id), + 'Play your required window card or retain another legal whole-answer alternative before accepting this window.'); + } const bribeWindowCloses = g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g); if (bribeWindowCloses && g.turnActionPromises) { // Known Double closes payments at the next opening, while Ghola keeps @@ -42503,6 +42649,8 @@ export function viewGame(state: Game, id: string, now?: number) { fremenVictory, ecazHomeworldVictory, shieldWallDestroyed: g.shieldWallDestroyed, + stormCardWindowOpen: g.status === 'playing' && g.phase === 0 && + g.stormCardWindowOpen !== false && !g.stormResolution, stormDialers: g.stormDialers, stormSubmitted: Object.keys(g.stormDials), karamaShipping: g.karamaShipping diff --git a/game/reference.ts b/game/reference.ts index f3260392..c11793a3 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3212,9 +3212,9 @@ export const RULE_TOPICS: RuleTopic[] = [ id: 'truthtrance-turn-actions', title: 'Truthtrance: combined card plays, battle and turn actions', category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped', - summary: 'Combine native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.', + summary: 'Combine original-window Weather/Atomics/Harvester plays, native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.', steps: [ - 'Choose Combine native battle powers, Hajr/Ghola plays and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.', + 'Choose Combine printed card plays, native battle powers and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of original-window card plays, native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.', 'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.', 'Whole No can permit a temporary paid pair and later require a break. Own Ready must preserve a completion after final membership is fixed; remaining payment or physical-action alternatives keep their later deadlines. Definite answers, including constants, uniformly reopen own Nexus readiness; later ordinary payments require confirmation again.', 'Missing foreign consent can offer I don’t know and save or change the question; it never invents a definite Yes. Unknown requires an otherwise funded native completion, so a known unaffordable branch stays No. Foreign withdrawal can release impossibility without vetoing the other player’s rights.', @@ -3228,6 +3228,8 @@ 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.', + '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.', 'Shipment clauses describe the same one future original reserve shipment. Each ground-movement minimum requires one actual physical group, never the sum of smaller moves. Existing allowances, cards, pieces and preparation remain authoritative.', 'Yes requires the whole expression and fulfills on qualifying actual events. OR preserves legal alternatives; No negates the whole expression. Current facts freeze at answer. A private native quotation shares funding with earlier commitments without storing or publishing a selected branch.', 'Transport and Hajr close at your actual Movement end. Ghola or a payment alternative may remain after that close, through the full current turn including Mentat. A known Inflation Double opening closes payments earlier without closing Ghola. Complete a required payment before its actual opportunity closes; No payment caps remain binding until then.', @@ -3235,11 +3237,11 @@ export const RULE_TOPICS: RuleTopic[] = [ ], related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse native power declarations, printed Hajr/Ghola, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse original-window plays, native power declarations, printed Hajr/Ghola, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/window-card-play.ts', 'game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] }, { area: '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'] }, + { 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'] }, { 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, shared Revival resources and original power declarations; broader composition and final acceptance remain.', evidence: ['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'] }, + { 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/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'] }, ], }, { diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 5079325b..59c70ff8 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -2,6 +2,7 @@ import { parseTravelActionClaim, type TravelClaim } from './travel-expression-pr import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES, type TurnActionClaim } from './turn-action-promises'; import { battlePowerOf, nativeBattlePowerOpportunity } from './battle-power-question'; +import { windowCardOpportunity } from './window-card-play'; import { parseTraitorActionClaim, type MixedTraitorClaim } from './mixed-traitor-question'; import { parseBattleActionClaim, MIXED_BATTLE_MAX_NODES, MIXED_BATTLE_MAX_TERMS, MIXED_BATTLE_MAX_DEPTH, MIXED_BATTLE_MAX_LEAVES, type MixedBattleClaim } from './mixed-battle-question'; @@ -385,6 +386,11 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { const respondent = g.players.find(player => player.id === v.target)!; const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), input => { const action = parseTurnActionLeaf(input); + if (action.kind === 'windowCardPlay') { + const opportunity = windowCardOpportunity(g, action.effect); + check(opportunity && opportunity.turn === g.turn && opportunity.source === action.source, + 'Choose the exact current original Storm or fresh-blow printed-card window.'); + } if (action.kind === 'battlePlan' || action.kind === 'traitorCall' || action.kind === 'battlePower') { const battle = g.battle; check(g.phase === 6 && battle && typeof battle.event === 'string' && battle.event.length > 0 && @@ -734,6 +740,9 @@ export function truthQuestionText( }; if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause, action => { + if (action.kind === 'windowCardPlay') return `you play ${action.effect === 'weather' ? 'Weather Control' : + 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 === 'battlePower') { const claim = action.claim, where = `in this original battle in ${locationName(action.territory)}`; diff --git a/game/turn-action-promises.ts b/game/turn-action-promises.ts index 1bc1d6d6..145c7051 100644 --- a/game/turn-action-promises.ts +++ b/game/turn-action-promises.ts @@ -7,6 +7,7 @@ import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './r import { parseBattleActionClaim, type MixedBattleActionClaim } from './mixed-battle-question'; import { PlanClaimError } from './battle-promises'; import type { PrintedCardEffect } from './printed-card-play'; +import type { WindowCardEffect } from './window-card-play'; import { BattlePowerQuestionError, battlePowerMatches, battlePowerOf, parseBattlePowerClaim, type BattlePowerClaim, type BattlePowerDeclaration, type NativeBattlePower } from './battle-power-question'; import { @@ -32,7 +33,8 @@ export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: s { kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim } | { kind: 'traitorCall'; battle: string; territory: string; call: boolean } | { kind: 'cardPlay'; effect: PrintedCardEffect } | - { kind: 'battlePower'; battle: string; territory: string; claim: BattlePowerClaim }; + { kind: 'battlePower'; battle: string; territory: string; claim: BattlePowerClaim } | + { kind: 'windowCardPlay'; effect: WindowCardEffect; source: string }; export type TurnActionClaim = { turn: number; mixed: MixedActionExpression }; export type TurnActionPayment = { recipient: string; amount: number }; export type TurnActionBranchLeaf = TravelBranchLeaf | @@ -46,7 +48,8 @@ export type TurnActionBranchLeaf = TravelBranchLeaf | beneficiary: string; target: string; leader: string | null } | { kind: 'cardPlay'; effect: PrintedCardEffect; answer: boolean } | { kind: 'battlePower'; battle: string; territory: string; claim: BattlePowerClaim; - owner: string; beneficiary: string; target: string; answer: boolean }; + owner: string; beneficiary: string; target: string; answer: boolean } | + { kind: 'windowCardPlay'; effect: WindowCardEffect; source: string; answer: boolean }; export type TurnActionPreparation = { bid: { lot: number; source: string; maximum: number; closed: boolean } | null; revival: { total: number; normal: number; elite: number; leaders: string[]; closed: boolean } | null; @@ -66,6 +69,7 @@ export type TurnActionCombat = { powers?: Record; }; export type TurnActionCardPlays = Record; +export type TurnActionWindowCards = Partial>; export type TurnActionPromise = TurnActionClaim & { event: string; player: string; @@ -89,6 +93,7 @@ export type TurnActionPromise = TurnActionClaim & { combat?: TurnActionCombat; /** Accepted printed-role plays after this answer only, never earlier counts. */ cardPlays?: TurnActionCardPlays; + windowCards?: TurnActionWindowCards; }; export class TurnActionError extends Error { @@ -114,6 +119,7 @@ const positive = (value: unknown): value is number => whole(value) && value > 0; const physical = (value: unknown): value is number => whole(value) && value <= 20; const printedTerritory = (value: unknown): value is string => typeof value === 'string' && TERRITORIES.some(destination => destination.id === value); +const windowEffect = (value: unknown): value is WindowCardEffect => value === 'weather' || value === 'atomics' || value === 'harvester'; /** Identity membership is decoded by Truthtrance; here retain one strict atom. */ function parseCombatClaim(value: unknown): MixedBattleActionClaim { @@ -137,6 +143,11 @@ function powerMatches(claim: BattlePowerClaim, declaration: BattlePowerDeclarati export function parseTurnActionLeaf(value: unknown): TurnActionLeaf { check(object(value), 'Choose one future native action, printed-card play or ordinary bribe payment.'); try { + if (value.kind === 'windowCardPlay') { + check(keys(value, 'effect,kind,source') && windowEffect(value.effect) && identifier(value.source), + 'Choose one original Storm or fresh-blow printed-card play after this answer.'); + return { kind: 'windowCardPlay', effect: value.effect, source: value.source }; + } if (value.kind === 'battlePower') { check(keys(value, 'battle,claim,kind,territory') && identifier(value.battle) && identifier(value.territory), 'Retain the original battle and native Voice or Prescience declaration clause.'); @@ -193,6 +204,7 @@ function storedFact(value: unknown, depth = 0): TruthFact { } function actionKey(action: TurnActionLeaf): string { + if (action.kind === 'windowCardPlay') return JSON.stringify([action.kind, action.effect, action.source]); if (action.kind === 'battlePower') return JSON.stringify([action.kind, action.battle, action.territory, powerKey(action.claim)]); if (action.kind === 'cardPlay') return JSON.stringify([action.kind, action.effect]); if (action.kind === 'battlePlan') return JSON.stringify([action.kind, action.battle, action.territory, planKey(action.claim)]); @@ -243,6 +255,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction> = { voice: new Set(), prescience: new Set() }; let hasTraitor = false; const cardEffects = new Set(); + const windowSources = new Map>(); const visit = (node: MixedActionExpression): void => { if (node.kind === 'action') { originals.add(actionKey(node.action)); @@ -280,6 +294,11 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction(); + sources.add(node.action.source); + windowSources.set(node.action.effect, sources); + } } else if (node.kind !== 'fact') node.terms.forEach(visit); }; visit(mixed); @@ -291,6 +310,18 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction powerKey(entry.claim) === powerKey(action.claim))?.value ?? false; if (action.kind === 'cardPlay') return promise.cardPlays?.[action.effect]?.played ?? false; @@ -430,6 +462,7 @@ function evaluate(node: CompiledMixedAction, promise: TurnAction } function actionClosed(action: TurnActionLeaf, promise: TurnActionPromise): boolean { + if (action.kind === 'windowCardPlay') return promise.windowCards![action.effect]!.closed; if (action.kind === 'battlePower') return promise.combat!.powers![battlePowerOf(action.claim)]!.closed; if (action.kind === 'cardPlay') return promise.cardPlays![action.effect]!.closed; if (action.kind === 'battlePlan') return promise.combat!.plan!.closed; @@ -471,6 +504,7 @@ export function liveTurnActionPromises(promises: re function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): boolean { if (left.kind !== right.kind) return false; + if (left.kind === 'windowCardPlay') return right.kind === 'windowCardPlay' && left.effect === right.effect && left.source === right.source; if (left.kind === 'battlePower') return right.kind === 'battlePower' && left.battle === right.battle && left.territory === right.territory && left.owner === right.owner && left.beneficiary === right.beneficiary && left.target === right.target && powerKey(left.claim) === powerKey(right.claim); @@ -499,7 +533,8 @@ function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf if (!prior) merged.push({ ...leaf }); continue; } - if (leaf.kind === 'leader' || leaf.kind === 'alliance' || leaf.kind === 'battlePlan' || leaf.kind === 'cardPlay' || leaf.kind === 'battlePower') { + if (leaf.kind === 'leader' || leaf.kind === 'alliance' || leaf.kind === 'battlePlan' || leaf.kind === 'cardPlay' || + leaf.kind === 'battlePower' || leaf.kind === 'windowCardPlay') { const prior = merged.find(other => sameAction(other, leaf)); if (prior && prior.answer !== leaf.answer) return null; if (leaf.kind === 'alliance' && leaf.answer && merged.some(other => other.kind === 'alliance' && @@ -571,6 +606,37 @@ function settle(promise: T, update: T): T { ? { ...update, fulfilled: true } : update; } +/** Only an accepted printed-role play in this exact original native window + * counts. Effect amounts, earlier plays, disposal and recovery do not call it. */ +export function recordTurnActionWindowCardPlay(promises: readonly T[], player: string, + turn: number, effect: WindowCardEffect, source: string): T[] { + check(windowEffect(effect) && identifier(source), 'Record one accepted original-window printed-card play.'); + const updates = new Map(); + for (const promise of liveTurnActionPromises(promises, player, turn)) { + readPromise(promise); + const stage = promise.windowCards?.[effect]; + if (!stage || stage.closed || stage.played || stage.source !== source) continue; + updates.set(promise, settle(promise, { ...promise, windowCards: { ...promise.windowCards!, + [effect]: { ...stage, played: true } } })); + } + return promises.map(promise => updates.get(promise) ?? promise); +} + +/** Close only this original effect/window, preserving every later alternative. */ +export function closeTurnActionWindowCardPlay(promises: readonly T[], player: string, + turn: number, effect: WindowCardEffect, source: string): T[] { + check(windowEffect(effect) && identifier(source), 'Close one original native printed-card window.'); + const updates = new Map(); + for (const promise of liveTurnActionPromises(promises, player, turn)) { + readPromise(promise); + const stage = promise.windowCards?.[effect]; + if (!stage || stage.closed || stage.source !== source) continue; + updates.set(promise, settle(promise, { ...promise, windowCards: { ...promise.windowCards!, + [effect]: { ...stage, closed: true } } })); + } + return promises.map(promise => updates.get(promise) ?? promise); +} + /** Record the actual owned declaration before any later Karama prevention. * Its exact predicates stay true/false even if the power's effect is denied. */ export function recordTurnActionBattlePower(promises: readonly T[], player: string, @@ -891,6 +957,11 @@ export function finishTurnActionTurn(promises: read return promises.map(promise => { if (!live.has(promise)) return promise; readPromise(promise); + const windowCards: TurnActionWindowCards = {}; + for (const effect of ['weather', 'atomics', 'harvester'] as const) { + const stage = promise.windowCards?.[effect]; + if (stage) windowCards[effect] = { ...stage, closed: true }; + } const closed = { ...promise, transportClosed: true, paymentClosed: true, shipment: { ...promise.shipment, pending: false }, ...(promise.preparation ? { preparation: { bid: promise.preparation.bid ? { ...promise.preparation.bid, closed: true } : null, @@ -898,7 +969,7 @@ export function finishTurnActionTurn(promises: read } } : {}), ...(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 } : {}) }; 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); @@ -922,6 +993,7 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[ (!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) && + (['weather', 'atomics', 'harvester'] as const).every(effect => promise.windowCards?.[effect]?.closed ?? true) && (!promise.cardPlays?.hajr || promise.cardPlays.hajr.closed) && (!promise.cardPlays?.ghola || promise.cardPlays.ghola.closed); const recipientsValid = (node: MixedActionExpression): boolean => node.kind === 'action' ? node.action.kind === 'alliance' ? players.includes(node.action.partner) && node.action.partner !== promise.player : diff --git a/game/window-card-play.ts b/game/window-card-play.ts new file mode 100644 index 00000000..8831224d --- /dev/null +++ b/game/window-card-play.ts @@ -0,0 +1,48 @@ +import { harvesterAvailable } from './cards'; + +export type WindowCardEffect = 'weather' | 'atomics' | 'harvester'; +export type WindowCardOpportunity = { effect: WindowCardEffect; source: string; turn: number }; +export type WindowCardGame = { + status: string; + phase: number; + turn: number; + shieldWallDestroyed: boolean; + stormCardWindowOpen?: boolean; + stormResolution?: unknown; + spiceWindow?: { + event?: string; + territory: string; + sector: number; + harvested: boolean; + harvesters?: number; + harvesterClosed?: boolean; + } | null; + response?: unknown; + decision?: unknown; + phaseOpening?: unknown; + truthtrance?: unknown; +}; + +const identifier = (value: unknown): value is string => + typeof value === 'string' && value.length > 0 && value === value.trim(); +const whole = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; + +/** Original global window only. Temporary modal priority, actual card custody, + * force conditions and chosen effect parameters remain native engine rules. */ +export function windowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null { + if (game.status !== 'playing' || !whole(game.turn) || game.turn < 1) return null; + if (effect === 'weather' || effect === 'atomics') { + if (game.phase !== 0 || game.stormResolution || game.stormCardWindowOpen === false || + game.stormCardWindowOpen !== undefined && typeof game.stormCardWindowOpen !== 'boolean' || + effect === 'atomics' && game.shieldWallDestroyed !== false) return null; + return { effect, source: `storm:${game.turn}`, turn: game.turn }; + } + if (effect !== 'harvester' || game.phase !== 1) return null; + const blow = game.spiceWindow; + if (!blow || !identifier(blow.territory) || !whole(blow.sector) || typeof blow.harvested !== 'boolean' || + blow.harvesters !== undefined && !whole(blow.harvesters) || + blow.harvesterClosed !== undefined && typeof blow.harvesterClosed !== 'boolean' || !harvesterAvailable(blow)) return null; + if (blow.event !== undefined && !identifier(blow.event)) return null; + return { effect, source: blow.event ?? `legacy-spice-blow:${game.turn}:${blow.territory}:${blow.sector}`, turn: game.turn }; +} diff --git a/tests/card-availability.test.ts b/tests/card-availability.test.ts index 632430eb..cfaf93e5 100644 --- a/tests/card-availability.test.ts +++ b/tests/card-availability.test.ts @@ -82,6 +82,14 @@ function agrees(effect: Effect, change: (g: Game) => void, expected: boolean) { void test('the four shared availability checks agree with legal server effects', () => { for (const effect of effects) agrees(effect, () => {}, true); }); +void test('Storm cards close at actual movement even while the Storm phase continues', () => { + for (const effect of ['weather', 'atomics'] as const) { + agrees(effect, game => { game.stormCardWindowOpen = false; }, false); + agrees(effect, game => { + game.stormResolution = { from: game.storm, distance: 1, traversed: 0, pending: [] }; + }, false); + } +}); void test('all four cards report pending table windows and reject without mutation', () => { const pauses: ((g: Game) => void)[] = [ (g) => { diff --git a/tests/fixture-truth-turn-window-card.ts b/tests/fixture-truth-turn-window-card.ts new file mode 100644 index 00000000..4c1d1aa3 --- /dev/null +++ b/tests/fixture-truth-turn-window-card.ts @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import type { MixedActionExpression } from '../game/mixed-action-question'; +import type { TruthFact, TruthQuestion } from '../game/truthtrance'; +import { windowCardOpportunity, type WindowCardEffect } from '../game/window-card-play'; +import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; +import { truthTraitorDecisionTraitors } from './fixture-truth-traitor-decision'; +import { actTruthTurnAction, truthTurnActionInventory, truthTurnActionPlayer, + type TruthTurnActionStep } from './fixture-truth-turn-action'; +import type { TruthTurnPowerLeaf } from './fixture-truth-turn-power'; + +export type TruthTurnWindowCardLeaf = TruthTurnPowerLeaf | + { kind: 'cardPlay'; effect: 'hajr' } | + { kind: 'windowCardPlay'; effect: WindowCardEffect; source: string }; +export type TruthTurnWindowCardExpression = MixedActionExpression; +export type TruthTurnWindowCardStep = TruthTurnActionStep; +export const actTruthTurnWindowCard = actTruthTurnAction; +export const truthTurnWindowCardPlayer = truthTurnActionPlayer; +export const truthTurnWindowCardInventory = truthTurnActionInventory; +export const truthTurnWindowCardTraitors = truthTraitorDecisionTraitors; +export const truthTurnWindowCardClean = (game: Game) => !game.truthtrance && !game.response && !game.phaseOpening && + !game.decision && !game.pendingTreacheryDiscard && !game.pendingRevival && !game.pendingShipment; +export const truthTurnWindowCardFact = (fact: TruthFact): TruthTurnWindowCardExpression => ({ kind: 'fact', fact }); +export const truthTurnWindowCardPayment = (recipient: string, minimum = 2): TruthTurnWindowCardExpression => + ({ kind: 'action', action: { kind: 'payment', recipient, minimum } }); +export const truthTurnWindowCardPlay = (effect: WindowCardEffect, source: string): TruthTurnWindowCardExpression => + ({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } }); +export const truthTurnWindowCardGroup = (kind: 'and' | 'or', ...terms: TruthTurnWindowCardExpression[]): TruthTurnWindowCardExpression => + ({ kind, terms }); +export function nextTruthTurnWindowCardNative(game: Game): TruthTurnWindowCardStep | null { + if (game.truthtrance) return null; + return nextStrongholdFactionsNativeStep(game); +} +export function nextTruthTurnWindowCard(game: Game): TruthTurnWindowCardStep | null { + if (game.truthtrance) 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 nextTruthTurnWindowCardNative(game); +} +export function advanceTruthTurnWindowCard(game: Game, until: (state: Game) => boolean, + actions: TruthTurnWindowCardStep[] = [], nativeOnly = false): Game { + for (let count = 0; count < 1600; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original window-card source ended before its native boundary'); + const next = nativeOnly ? nextTruthTurnWindowCardNative(game) : nextTruthTurnWindowCard(game); + assert.ok(next, 'Supply the original Truthtrance question or native private plan'); + game = actTruthTurnWindowCard(game, next.actor, next.action, actions); + } + throw Error('The genuine window-card chronology did not reach its finite boundary'); +} +export function settleTruthTurnWindowCard(game: Game, actions: TruthTurnWindowCardStep[] = []): Game { + return advanceTruthTurnWindowCard(game, truthTurnWindowCardClean, actions, true); +} + +/** Genuine original Basic A/H/E base33. Only original unopened deal/Spice + * positions and scalar lotteries are chosen. Atomics earns actual Shield Wall + * casualties by paying for two original reserves during the preceding turn. */ +export function createTruthTurnWindowCardFixture(options: { + effect?: WindowCardEffect; window?: 'beforeDials' | 'afterReveal'; advanced?: boolean; + pairedTruthAndHajr?: boolean; +} = {}) { + const effect = options.effect ?? 'weather', window = options.window ?? 'beforeDials'; + const advanced = options.advanced ?? false; + const pairedTruthAndHajr = options.pairedTruthAndHajr ?? false; + assert.ok(!advanced || effect === 'harvester', 'This bounded Advanced adapter covers original dual Spice blows only'); + assert.ok(!pairedTruthAndHajr || effect === 'weather', 'The paired original Truth/Hajr deal belongs to this Weather source'); + const asker = 'atreides', target = 'harkonnen', observer = 'emperor', recipient = asker; + let game = createGame('TRUTHTURNWINDOWCARD', newPlayer(asker, 'Atreides', 'atreides'), advanced); + joinGame(game, newPlayer(target, 'Harkonnen', 'harkonnen')); + joinGame(game, newPlayer(observer, 'Emperor', 'emperor')); + const initial = structuredClone(game), actions: TruthTurnWindowCardStep[] = []; + const act = (actor: string, action: Action) => { game = actTruthTurnWindowCard(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceTruthTurnWindowCard(game, until, actions, true); }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); + const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.equal(game.deck.length, 33); + assert.ok(game.players.every(player => player.hand.length === 0)); + const originalCards = truthTurnWindowCardInventory(game), originalTraitors = truthTurnWindowCardTraitors(game); + const truth = game.deck.find(card => card.effect === 'truthtrance'); + const printed = game.deck.find(card => card.effect === effect); + const filler = game.deck.find(card => pairedTruthAndHajr ? card.effect === 'hajr' : card.kind === 'projectile'); + const other = game.deck.find(card => pairedTruthAndHajr ? card.effect === 'truthtrance' && card.id !== truth?.id + : effect === 'atomics' ? card.effect === 'weather' : card.kind === 'shield'); + assert.ok(truth && printed && filler && other); + const deal = [truth.id, printed.id, filler.id, other.id]; + const dealOrder = deal.map((id, position) => { + const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: id }; + }); + const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game); + assert.deepEqual(truthTurnWindowCardPlayer(game, asker).hand.map(card => card.id), [truth.id]); + assert.deepEqual(truthTurnWindowCardPlayer(game, target).hand.map(card => card.id), [printed.id, filler.id]); + assert.deepEqual(truthTurnWindowCardPlayer(game, observer).hand.map(card => card.id), [other.id]); + assert.deepEqual(truthTurnWindowCardPlayer(game, target).forces, { 'carthag:11': 10 }); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + let spiceOrder: ReturnType = [], beforeBlow: Game | null = null; + let wallShipment: { before: Game; steps: TruthTurnWindowCardStep[]; after: Game } | null = null; + let beforeSecondStorm: Game | null = null; + if (effect !== 'weather') { + reach(beforeIxSpiceDraw); + const land = game.spiceDeck.filter(card => 'territory' in card && !card.discovery && card.sector !== game.storm) + .slice(0, advanced ? 2 : 1); + assert.equal(land.length, advanced ? 2 : 1); + spiceOrder = orderIxSpiceCards(game, land.map(card => { assert.ok('territory' in card); return { territory: card.territory }; })); + beforeBlow = structuredClone(game); + if (effect === 'harvester') reach(state => !!state.spiceWindow && truthTurnWindowCardClean(state)); + else { + reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthTurnWindowCardClean(state)); + const before = structuredClone(game), from = actions.length; + act(target, { type: 'ship', territory: 'shield_wall', sector: 9, amount: 2, elite: 0, allyPayment: 0 }); + reach(truthTurnWindowCardClean); + wallShipment = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) }; + assert.equal(truthTurnWindowCardPlayer(before, target).spice - truthTurnWindowCardPlayer(game, target).spice, 4); + assert.equal(truthTurnWindowCardPlayer(game, target).forces['shield_wall:9'], 2); + act(target, { type: 'endMovement' }); + reach(state => state.turn === 2 && state.phase === 0 && truthTurnWindowCardClean(state)); + beforeSecondStorm = structuredClone(game); + } + } else reach(state => state.phase === 0 && truthTurnWindowCardClean(state)); + const beforeDials = structuredClone(game), beforeDialsActions = structuredClone(actions); + if (effect !== 'harvester' && window === 'afterReveal') + reach(state => state.phase === 0 && state.stormPending !== null && truthTurnWindowCardClean(state)); + const opportunity = windowCardOpportunity(game, effect); assert.ok(opportunity); + assert.equal(game.phase, effect === 'harvester' ? 1 : 0); + if (effect !== 'harvester') assert.equal(game.stormCardWindowOpen, true); + if (effect === 'atomics') { + assert.ok(wallShipment); assert.equal(game.turn, 2); assert.equal(game.shieldWallDestroyed, false); + assert.equal(truthTurnWindowCardPlayer(game, target).forces['shield_wall:9'], 2); + assert.equal(truthTurnWindowCardPlayer(game, target).spice, 6); + } + assert.ok(truthTurnWindowCardPlayer(game, target).hand.some(card => card.id === printed.id)); + assert.deepEqual(truthTurnWindowCardInventory(game), originalCards); + assert.deepEqual(truthTurnWindowCardTraitors(game), originalTraitors); + const source = opportunity.source, nativeAction: Action = { type: 'card', card: printed.id, + ...(effect === 'weather' ? { amount: 0 } : {}) }; + const mixed = truthTurnWindowCardGroup('and', truthTurnWindowCardPlay(effect, source), truthTurnWindowCardPayment(recipient)); + const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion; + const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTurnWindowCardPlayer(game, target).spice }; + return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, asker, target, observer, recipient, + effect, source, opportunity, window, advanced, pairedTruthAndHajr, + truth: truth.id, card: printed.id, filler: filler.id, foreignCard: other.id, + hajr: pairedTruthAndHajr ? filler.id : null, + nativeAction, payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action, mixed, question, spiceFact, + originalCards, originalTraitors, originalSpice, dealOrder, spiceOrder, beforeBlow, + beforeDials, beforeDialsActions, wallShipment, beforeSecondStorm, + originalBlow: structuredClone(game.spiceWindow ?? null), + actions, sourceActions: actions, sourceActionCount: actions.length, + sourceCounts: { selected: actions.length, beforeDials: beforeDialsActions.length, + actualWallShipment: wallShipment?.steps.length ?? 0 }, + sourceDisclosure: `Actual public ${advanced ? 'Advanced preview' : 'Basic'} Atreides/Harkonnen/Emperor base33 ready/start, native private Traitors and starting forces. ` + + 'Only original unopened starting Treachery positions, safe original unopened land Spice positions when used, and scalar lotteries are selected. ' + + (pairedTruthAndHajr + ? 'Atreides and Emperor each receive a different original Truthtrance; Harkonnen really receives original Weather Control and Hajr through its native two-card start. ' + : 'Atreides receives one original Truthtrance; Harkonnen really receives its printed role and a distinct original projectile through its native two-card start. ') + + (effect === 'atomics' ? 'Harkonnen pays four spice for two original reserve counters on Shield Wall9, actually ends Movement and completes the first turn before original Storm2; those two real counters remain available for actual Atomics casualties. Emperor holds original Weather Control. ' + : effect === 'harvester' ? 'Complete original Storm1 and actually draw the selected original safe land Spice card; stop at its fresh native blow and original event. ' + + (advanced ? 'The original second safe land card remains next for the actual Advanced second-blow continuation. ' : '') + : 'Stop at original Storm1 before any dial or after the actual original dial reveal, before storm movement. ') + + 'Every count comes from its recorded action array. No hand, purse, army, phase, wall, spice, clock, printed play or promise is installed.', + }; +} +export type TruthTurnWindowCardFixture = ReturnType; +export function declareTruthTurnWindowCard(f: TruthTurnWindowCardFixture, source = f.game, + actions: TruthTurnWindowCardStep[] = []) { + let game = actTruthTurnWindowCard(source, f.asker, { type: 'card', card: f.truth }, actions); + while (game.truthtrance?.stage === 'priority') { + const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor); + game = actTruthTurnWindowCard(game, actor.id, { type: 'truthPass' }, actions); + } + assert.equal(game.truthtrance?.stage, 'ask'); return { game, actions }; +} +export function askTruthTurnWindowCard(game: Game, asker: string, target: string, mixed: TruthTurnWindowCardExpression, + actions: TruthTurnWindowCardStep[] = []): Game { + return actTruthTurnWindowCard(game, asker, { type: 'truthAsk', question: { + kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed), + } }, actions); +} +export function answerTruthTurnWindowCard(game: Game, target: string, answer: 'yes' | 'no' | 'unknown', + actions: TruthTurnWindowCardStep[] = []): Game { + return actTruthTurnWindowCard(game, target, { type: 'truthAnswer', answer }, actions); +} +export function bindTruthTurnWindowCard(f: TruthTurnWindowCardFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed, + source = f.game, actions: TruthTurnWindowCardStep[] = []) { + const declared = declareTruthTurnWindowCard(f, source, actions); + const asked = askTruthTurnWindowCard(declared.game, f.asker, f.target, mixed, actions); + return { game: answerTruthTurnWindowCard(asked, f.target, answer, actions), declared, asked, actions }; +} +export function truthTurnWindowCardPromise(game: Game, target = game.turnActionPromises![0].player) { + const row = viewGame(game, target).turnActionPromises.find(candidate => candidate.player === target); assert.ok(row); return row; +} +export function closeTruthTurnWindowCard(game: Game, f: TruthTurnWindowCardFixture, + actions: TruthTurnWindowCardStep[] = []): Game { + return advanceTruthTurnWindowCard(game, + state => truthTurnWindowCardClean(state) && windowCardOpportunity(state, f.effect)?.source !== f.source, actions, true); +} +export function finishTruthTurnWindowCard(game: Game, actions: TruthTurnWindowCardStep[] = []): Game { + const turn = game.turn; + return advanceTruthTurnWindowCard(game, state => state.turn === turn + 1 && state.phase === 0 && truthTurnWindowCardClean(state), actions); +} diff --git a/tests/truth-turn-action-controls.test.tsx b/tests/truth-turn-action-controls.test.tsx index 03f341dd..89b8f3f7 100644 --- a/tests/truth-turn-action-controls.test.tsx +++ b/tests/truth-turn-action-controls.test.tsx @@ -11,6 +11,7 @@ import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView, import type { MixedActionExpression } from '../game/mixed-action-question'; import type { MixedBattleActionClaim } from '../game/mixed-battle-question'; import type { BattlePowerClaim } from '../game/battle-power-question'; +import type { WindowCardEffect } from '../game/window-card-play'; import { createRevivalOrder } from '../game/revival-order'; import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction'; import type { TurnActionLeaf } from '../game/turn-action-promises'; @@ -31,6 +32,8 @@ const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', 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 windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow' : 'storm:2'): Expression => + ({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } }); const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 3 }): Expression => ({ kind: 'action', action: { kind: 'battlePlan', battle: 'controlled-native-battle', territory: 'arrakeen', claim } }); const traitorClaim = (call = true): Expression => @@ -79,7 +82,8 @@ function projected(phase = 5, next: Action = { type: 'move', forces: { 'arrakeen game.turnActionGuide = { event, action: { ...next, turnActionPromiseEvent: event, turnActionPromisePhase: phase } }; game.truthTurnActionAnswers = null; if (next.type === 'card' && typeof next.card === 'string') { - const effect = next.card.includes('hajr') ? 'hajr' : next.card.includes('ghola') ? 'ghola' : 'karama'; + const cardId = next.card, effect = (['hajr', 'ghola', 'weather', 'atomics', 'harvester'] as const) + .find(effect => cardId.includes(effect)) ?? 'karama'; const face = baseDeck().find(card => card.effect === effect); assert.ok(face); const me = game.players.find(player => player.id === 'p')!; me.hand = [{ ...face, id: next.card }]; me.hajrAvailable = effect === 'hajr'; @@ -127,6 +131,16 @@ function battlePower(power: 'voice' | 'prescience' = 'voice', next?: Action, all game.turnActionPromises[0].mixed = { kind: 'or', terms: [powerClaim({ kind: power, use: true }), payment()] }; return game; } +function windowCard(effect: WindowCardEffect = 'weather', next?: Action): GameView { + const game = projected(effect === 'harvester' ? 1 : 0, next ?? { type: 'card', card: `controlled-${effect}`, + ...(effect === 'weather' ? { amount: 4 } : {}) }); + game.stormCardWindowOpen = true; game.shieldWallDestroyed = false; game.stormPending = null; + game.spiceWindow = effect === 'harvester' ? { event: 'controlled-native-blow', territory: 'carthag', sector: 11, + amount: 6, harvested: false } : null; + game.players.find(player => player.id === 'p')!.forces = { 'shield_wall:8': 1 }; + game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim(effect), payment()] }; + return game; +} function revival(next: Action = { type: 'revive', amount: 3, elite: 1 }): GameView { const game = projected(4, next); game.revivalOrder = createRevivalOrder({ turn: 2, order: ['p', 'a', 'r'], ready: [] }); @@ -634,6 +648,163 @@ void test('native power history keeps raw declarations distinct from effects and assert.doesNotMatch(html, /controlled-native-battle|battlePowers|declarations|private-card/); }); +void test('window-role editor and raw history use original public sources without hidden holdings or eligibility reads', () => { + const game = windowCard(), e = editor(physical(), false, game); + for (const player of game.players) for (const key of ['hand', 'spice', 'forces', 'advisors', 'tanks', 'leaders', 'traitors']) + Object.defineProperty(player, key, { get() { throw Error(`No private window authoring ${key} read`); }, configurable: true }); + for (const key of ['windowCards', 'compiled', 'cardPlays']) + Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} read`); }, configurable: true }); + select(e.tree(), '1', 'action', 'windowCardPlay'); assert.deepEqual(e.value, windowClaim()); assert.equal(e.error(), null); + select(e.tree(), '1', 'window-effect', 'atomics'); assert.deepEqual(e.value, windowClaim('atomics')); assert.equal(e.error(), null); + game.shieldWallDestroyed = true; assert.ok(e.error()); assert.match(e.html(), /no current original open native window/); + select(e.tree(), '1', 'window-effect', 'weather'); assert.equal(e.error(), null, 'Weather needs no wall or private forces.'); + game.stormCardWindowOpen = false; assert.ok(e.error()); game.stormCardWindowOpen = true; + select(e.tree(), '1', 'window-effect', 'harvester'); assert.ok(e.error()); + game.phase = 1; game.spiceWindow = { event: 'new-original-blow', territory: 'carthag', sector: 11, amount: 6, harvested: false }; + select(e.tree(), '1', 'window-source', 'new-original-blow'); assert.deepEqual(e.value, windowClaim('harvester', 'new-original-blow')); + assert.equal(e.error(), null); + delete game.spiceWindow.event; assert.ok(e.error()); select(e.tree(), '1', 'window-source', 'legacy-spice-blow:2:carthag:11'); + assert.deepEqual(e.value, windowClaim('harvester', 'legacy-spice-blow:2:carthag:11')); assert.equal(e.error(), null); + game.spiceWindow.harvesterClosed = true; assert.ok(e.error()); game.spiceWindow.harvesterClosed = false; + game.turnActionGuide = null; game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim(), windowClaim('atomics'), windowClaim('harvester')] }; + const html = markup(game); assert.match(html, /Weather Control/); assert.match(html, /Family Atomics/); assert.match(html, /Harvester/); + assert.match(html, /recovery alone do not count/); assert.match(html, /original native window closes/); + assert.match(html, /do not promise a selected storm distance or resulting spice amount/); + assert.doesNotMatch(html, /windowCards|compiled|controlled-weather/); +}); + +void test('window-role editor retains strict repairable drafts and the shared sixteen-leaf four-level budget', () => { + const game = windowCard(), e = editor(windowClaim(), false, game); + select(e.tree(), '1', 'window-source', ''); assert.ok(e.error()); assert.deepEqual(e.value, windowClaim('weather', '')); + select(e.tree(), '1', 'window-source', 'storm:2'); assert.equal(e.error(), null); + for (const action of [{ kind: 'windowCardPlay', effect: 'weather', source: 'storm:2', amount: 4 }, + { kind: 'windowCardPlay', effect: 'ghola', source: 'storm:2' }, + { kind: 'windowCardPlay', effect: 'weather', source: 'storm:1' }]) + assert.ok(turnActionInputError({ kind: 'action', action }, game, 'p')); + const full = editor({ kind: 'or', terms: [windowClaim(), fact()] }, false, game); + for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'windowCardPlay'); } + 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(), windowClaim()] }, 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(windowClaim(), true, game); select(frozen.tree(), '1', 'window-effect', 'atomics'); + select(frozen.tree(), '1', 'window-source', ''); assert.equal(frozen.changes, 0); +}); + +void test('printed window guides forward exact owned Weather, Atomics and Harvester native packets out of turn', () => { + for (const effect of ['weather', 'atomics', 'harvester'] as const) { + const game = windowCard(effect), sent: Action[] = []; game.active = 'a'; + for (const key of ['windowCards', 'compiled', 'cardPlays']) + Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} guide read`); }, configurable: true }); + const button = guide({ game, busy: false, act(action) { sent.push(action); } }); assert.equal(button.props.disabled, false); press(button); + assert.equal(sent[0], game.turnActionGuide!.action); assert.equal(sent[0].turnActionPromiseEvent, event); + assert.equal(sent[0].turnActionPromisePhase, effect === 'harvester' ? 1 : 0); + assert.match(renderToStaticMarkup(button), /original native window/); + } + for (const amount of [0, 10]) { + const game = windowCard('weather', { type: 'card', card: 'controlled-weather', amount }); + let sent = 0; const button = guide({ game, busy: false, act() { sent++; } }); + assert.equal(button.props.disabled, false); press(button); assert.equal(sent, 1); + } +}); + +void test('saved window-card callbacks reject source closure, public window mutation, physical card changes and modal handoffs', () => { + for (const effect of ['weather', 'atomics', 'harvester'] as const) for (const change of [ + (game: GameView) => { if (effect === 'harvester') game.spiceWindow!.event = 'later-blow'; else game.stormCardWindowOpen = false; }, + (game: GameView) => { if (effect === 'harvester') game.spiceWindow!.amount *= 2; else game.stormPending = 7; }, + (game: GameView) => { game.turnActionPromises[0].mixed = windowClaim(effect, 'different-original-source'); }, + (game: GameView) => { game.players.find(player => player.id === 'p')!.hand = []; }, + (game: GameView) => { game.players.find(player => player.id === 'p')!.hand![0].effect = 'ghola'; }, + (game: GameView) => { game.response = { kind: 'voice', owner: 'a', passed: [] }; }, + (game: GameView) => { game.truthtrance = answering(game.phase).truthtrance; }, + ]) { + const game = windowCard(effect), button = guide({ game, busy: false, act() { assert.fail('A stale window card cannot send'); } }); + assert.equal(button.props.disabled, false); change(game); press(button); + } + const game = windowCard('harvester'), button = guide({ game, busy: false, act() { assert.fail('A replaced original blow cannot send'); } }); + game.spiceWindow = { ...game.spiceWindow! }; press(button); + const atomics = windowCard('atomics'), atomicButton = guide({ game: atomics, busy: false, act() { assert.fail('Changed native Atomics eligibility cannot send'); } }); + atomics.players.find(player => player.id === 'p')!.forces = {}; press(atomicButton); +}); + +void test('window-role guides reject unsupported packets, missing native eligibility and generic card fallback', () => { + for (const amount of [undefined, -1, 11, 1.5, NaN, Infinity]) { + const game = windowCard('weather', { type: 'card', card: 'controlled-weather', amount }); + const button = guide({ game, busy: false, act() { assert.fail('An invalid Weather distance cannot send'); } }); + assert.equal(button.props.disabled, true); press(button); + } + for (const effect of ['weather', 'atomics', 'harvester'] as const) { + for (const extra of [{ mode: 'shipment' }, { leader: 'foreign-leader' }, { forces: { 'shield_wall:8': 1 } }, + ...(effect === 'weather' ? [] : [{ amount: 2 }])]) { + const game = windowCard(effect); Object.assign(game.turnActionGuide!.action, extra); + const button = guide({ game, busy: false, act() { assert.fail('An unsupported native window packet cannot send'); } }); + assert.equal(button.props.disabled, true); press(button); + } + for (const phase of [2, 4, 5, 6]) { + const game = windowCard(effect); game.phase = phase; game.turnActionGuide!.action.turnActionPromisePhase = phase; + const button = guide({ game, busy: false, act() { assert.fail('A window role cannot bypass validation through generic preparation'); } }); + assert.equal(button.props.disabled, true); press(button); + } + const wrongSource = windowCard(effect); wrongSource.turnActionPromises[0].mixed = payment(); + assert.equal(guide({ game: wrongSource, busy: false, act() { assert.fail('A printed window guide needs its original raw source'); } }).props.disabled, true); + } + const atomics = windowCard('atomics'); atomics.players.find(player => player.id === 'p')!.forces = {}; + assert.equal(guide({ game: atomics, busy: false, act() { assert.fail('Atomics needs actual owned native forces'); } }).props.disabled, true); +}); + +void test('early owned Box guides require the exact quoted Box and live original global question window', () => { + for (const effect of ['weather', 'harvester'] as const) { + const game = windowCard(effect), me = game.players.find(player => player.id === 'p')!; + me.hand = [{ id: 'controlled-box', name: 'Nullentropy Box', kind: 'special', effect: 'nullentropyBox' }]; + game.nullentropy = { card: me.hand[0], blocked: null, search: null }; + game.turnActionGuide!.action = { type: 'card', card: me.hand[0].id, turnActionPromiseEvent: event, turnActionPromisePhase: game.phase }; + const sent: Action[] = [], 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); + game.nullentropy.blocked = 'The native Box opening is no longer legal.'; press(button); assert.equal(sent.length, 1); + game.nullentropy.blocked = null; + if (effect === 'harvester') game.spiceWindow!.harvesterClosed = true; else game.stormCardWindowOpen = false; + press(button); assert.equal(sent.length, 1); + const unrelated = windowCard(effect); unrelated.turnActionPromises[0].mixed = payment(); + unrelated.players.find(player => player.id === 'p')!.hand = me.hand; + unrelated.nullentropy = { card: me.hand[0], blocked: null, search: null }; + unrelated.turnActionGuide!.action = { ...game.turnActionGuide!.action }; + assert.equal(guide({ game: unrelated, busy: false, act() { assert.fail('An unrelated row cannot create a new early Box control'); } }).props.disabled, true); + } +}); + +void test('window Ready guides retain the exact live raw source, known Storm distance and fresh-blow callback ownership', () => { + const readyWindow = (effect: WindowCardEffect) => { + const game = windowCard(effect); game.stormPending = effect === 'harvester' ? null : 4; + game.turnActionGuide!.action = { type: 'ready', turnActionPromiseEvent: event, turnActionPromisePhase: game.phase }; + if (effect === 'harvester') { game.turnActionPromises[0].answer = false; game.turnActionPromises[0].mixed = windowClaim(effect); } + Object.defineProperty(game.turnActionPromises[0], 'windowCards', { get() { throw Error('Ready must not read private window receipts'); } }); + return game; + }; + for (const effect of ['weather', 'atomics', 'harvester'] as const) { + const game = readyWindow(effect), sent: Action[] = []; + const button = guide({ game, busy: false, act(action) { sent.push(action); } }); + assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action); + assert.match(renderToStaticMarkup(button), /Accept this .* window while preserving your whole answer/); + game.ready.push('p'); press(button); assert.equal(sent.length, 1, 'An accepted window cannot be reopened by the saved Ready callback.'); + for (const change of [ + (game: GameView) => { game.turnActionPromises[0].mixed = windowClaim(effect, 'later-original-source'); }, + (game: GameView) => { if (effect === 'harvester') game.spiceWindow!.event = 'later-blow'; else game.stormCardWindowOpen = false; }, + (game: GameView) => { if (effect === 'harvester') game.spiceWindow!.harvesterClosed = true; else game.stormPending = null; }, + ]) { + const stale = readyWindow(effect), saved = guide({ game: stale, busy: false, act() { assert.fail('A changed original Ready source cannot send'); } }); + assert.equal(saved.props.disabled, false); change(stale); press(saved); + } + const extra = readyWindow(effect); extra.turnActionGuide!.action.amount = 4; + assert.equal(guide({ game: extra, busy: false, act() { assert.fail('Window Ready accepts no extra native fields'); } }).props.disabled, true); + const unrelated = readyWindow(effect); unrelated.turnActionPromises[0].mixed = payment(); + assert.equal(guide({ game: unrelated, busy: false, act() { assert.fail('Window Ready needs its own original raw window clause'); } }).props.disabled, true); + } + const unknownStorm = readyWindow('weather'); unknownStorm.stormPending = null; + assert.equal(guide({ game: unknownStorm, busy: false, act() { assert.fail('Storm Ready waits for a known native movement'); } }).props.disabled, true); + const replaced = readyWindow('harvester'), saved = guide({ game: replaced, busy: false, act() { assert.fail('A replaced fresh blow cannot accept an old Ready callback'); } }); + replaced.spiceWindow = { ...replaced.spiceWindow! }; press(saved); +}); + void test('turn-action printed-role editor uses public names without holdings or private stages and retains the shared logical budget', () => { const game = projected(); for (const player of game.players) for (const key of ['hand', 'spice', 'traitors', 'faceDancers', 'tanks', 'leaders', 'hajrAvailable']) diff --git a/tests/truth-turn-window-card-runtime.test.ts b/tests/truth-turn-window-card-runtime.test.ts new file mode 100644 index 00000000..69774710 --- /dev/null +++ b/tests/truth-turn-window-card-runtime.test.ts @@ -0,0 +1,336 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { truthFactAnswer } from '../game/truthtrance'; +import { windowCardOpportunity, type WindowCardEffect } from '../game/window-card-play'; +import { + actTruthTurnWindowCard as act, advanceTruthTurnWindowCard as advance, + askTruthTurnWindowCard as ask, bindTruthTurnWindowCard as bind, closeTruthTurnWindowCard as close, + createTruthTurnWindowCardFixture as fixture, declareTruthTurnWindowCard as declare, + finishTruthTurnWindowCard as finish, nextTruthTurnWindowCardNative as nativeStep, + settleTruthTurnWindowCard as settle, truthTurnWindowCardClean as clean, + truthTurnWindowCardFact as fact, truthTurnWindowCardGroup as group, + truthTurnWindowCardInventory as inventory, truthTurnWindowCardPayment as payment, + truthTurnWindowCardPlay as play, truthTurnWindowCardPlayer as player, + truthTurnWindowCardPromise as promise, truthTurnWindowCardTraitors as traitors, + type TruthTurnWindowCardExpression, type TruthTurnWindowCardFixture, type TruthTurnWindowCardStep, +} from './fixture-truth-turn-window-card'; + +const effects: WindowCardEffect[] = ['weather', 'atomics', 'harvester']; +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: TruthTurnWindowCardFixture) { + assert.equal(game.discard.filter(card => card.id === f.truth).length, 1); + assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors); +} +function receipt(game: Game, f: TruthTurnWindowCardFixture) { + const stage = game.turnActionPromises![0].windowCards?.[f.effect]; assert.ok(stage); return stage; +} + +void test('window-card sources use original native Harkonnen deals, earned Wall casualties and fresh blow identities', () => { + for (const effect of effects) { + const f = fixture({ effect }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); + assert.equal(f.setup.deck.length, 33); assert.equal(f.originalCards.length, 33); assert.equal(f.originalTraitors.length, 15); + assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0)); + assert.deepEqual(player(f.afterSetup, f.target).hand.map(card => card.id), [f.card, f.filler]); + assert.ok(player(f.game, f.asker).hand.some(card => card.id === f.truth)); + assert.ok(player(f.game, f.target).hand.some(card => card.id === f.card)); + assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.selected, f.actions.length); + assert.equal(f.sourceCounts.beforeDials, f.beforeDialsActions.length); + assert.deepEqual(windowCardOpportunity(f.game, effect), f.opportunity); + assert.equal(f.game.advanced, false); assert.deepEqual(f.game.expansions, []); + assert.ok(!f.game.nexusCards && !f.game.homeworlds && !f.game.leaderSkills && !f.game.strongholdCards); + assert.equal(f.game.turnActionPromises?.length ?? 0, 0); + if (effect === 'atomics') { + assert.ok(f.wallShipment); assert.equal(f.game.turn, 2); + assert.equal(f.sourceCounts.actualWallShipment, f.wallShipment.steps.length); + assert.ok(f.wallShipment.steps.some(step => step.actor === f.target && step.action.type === 'ship' && step.action.amount === 2)); + assert.equal(player(f.game, f.target).spice, 6); assert.equal(player(f.game, f.target).forces['shield_wall:9'], 2); + } else if (effect === 'harvester') { + assert.ok(f.originalBlow?.event); assert.equal(f.source, f.originalBlow.event); + assert.equal(f.originalBlow.harvested, false); assert.ok(f.beforeBlow); + } else { + assert.equal(f.source, 'storm:1'); assert.equal(f.game.stormPending, null); assert.equal(f.game.stormCardWindowOpen, true); + } + assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors); + } +}); + +void test('each named original-window role AND payment freezes the fact and one actual play satisfies repeated correlated leaves', () => { + for (const effect of effects) { + const f = fixture({ effect }), role = play(effect, f.source); + const mixed = group('and', fact(f.spiceFact), role, role, payment(f.recipient)); + let game = bind(f, 'yes', mixed).game; + assert.deepEqual(receipt(game, f), { source: f.source, played: false, closed: false }); + game = act(game, f.target, f.payment); + assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); + const beforePlay = structuredClone(game); game = settle(act(game, f.target, f.nativeAction)); + assert.equal(receipt(game, f).played, true); assert.equal(promise(game, f.target).fulfilled, true); + assert.deepEqual(promise(game, f.target).mixed, mixed); assert.equal(player(game, f.recipient).bribes, 2); + assert.equal(game.discard.filter(card => card.id === f.card).length, 1); + assert.equal(player(beforePlay, f.target).spice, player(game, f.target).spice, 'The printed role adds no invented spice fee'); + if (effect === 'weather') assert.equal(game.stormPending, 0); + if (effect === 'atomics') { + assert.equal(game.shieldWallDestroyed, true); assert.equal(player(game, f.target).forces['shield_wall:9'] ?? 0, 0); + assert.equal(player(game, f.target).tanks - player(beforePlay, f.target).tanks, 2); + assert.equal(player(game, f.target).reserves, player(beforePlay, f.target).reserves); + assert.equal(player(game, f.target).forces['carthag:11'], 10); + } + if (effect === 'harvester') { + const blow = f.originalBlow!; const key = `${blow.territory}:${blow.sector}`; + assert.equal(game.spiceWindow?.event, f.source); assert.equal(game.spiceWindow?.amount, blow.amount * 2); + assert.equal(game.spice[key] - (beforePlay.spice[key] ?? 0), blow.amount); + } + physical(game, f); + } +}); + +void test('Weather Control promises preserve manual typed distances zero through ten before dials and after real reveal', () => { + for (const window of ['beforeDials', 'afterReveal'] as const) { + const f = fixture({ effect: 'weather', window }); + assert.equal(f.game.stormPending === null, window === 'beforeDials'); + const bound = bind(f).game; + for (const amount of [-1, 11, 1.5, '5', null]) + rejected(bound, f.target, { type: 'card', card: f.card, amount }); + for (const amount of [0, 5, 10]) { + let game = act(bound, f.target, { type: 'card', card: f.card, amount }); + assert.equal(game.stormPending, amount); assert.equal(game.stormMovementSource?.kind, 'weather'); + assert.equal(receipt(game, f).played, true); assert.ok(!promise(game, f.target).fulfilled); + game = settle(game); game = act(game, f.target, f.payment); + assert.equal(promise(game, f.target).fulfilled, true); physical(game, f); + } + } +}); + +void test('whole printed-role AND payment No preserves either order and rejects only the actual completed predicate', () => { + for (const effect of effects) { + const f = fixture({ effect }); + for (const first of ['payment', 'card'] as const) { + let game = bind(f, 'no').game; + if (first === 'payment') { + game = act(game, f.target, f.payment); rejected(game, f.target, f.nativeAction); + } else { + game = settle(act(game, f.target, f.nativeAction)); rejected(game, f.target, f.payment); + game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); + assert.equal(player(game, f.recipient).bribes, 1); + } + assert.ok(!promise(game, f.target).released); game = finish(close(game, f)); + assert.equal(promise(game, f.target).fulfilled, true); physical(game, f); + } + } +}); + +void test('whole printed-role OR payment No caps both real actions until the original window and turn actually close', () => { + for (const effect of effects) { + const f = fixture({ effect }), mixed = group('or', play(effect, f.source), payment(f.recipient)); + let game = bind(f, 'no', mixed).game; + rejected(game, f.target, f.nativeAction); rejected(game, f.target, f.payment); + game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); + game = close(game, f); assert.equal(receipt(game, f).closed, true); assert.equal(receipt(game, f).played, false); + rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); + assert.ok(!promise(game, f.target).fulfilled); game = finish(game); + assert.equal(promise(game, f.target).fulfilled, true); physical(game, f); + } +}); + +void test('a skipped printed window leaves its manual payment OR alive through actual late Mentat', () => { + for (const effect of effects) { + const f = fixture({ effect }), mixed = group('or', play(effect, f.source), payment(f.recipient)); + const bound = bind(f, 'yes', mixed).game; + const played = settle(act(bound, f.target, f.nativeAction)); + assert.equal(promise(played, f.target).fulfilled, true); assert.equal(player(played, f.recipient).bribes, 0); physical(played, f); + let game = close(bound, f); + assert.deepEqual(receipt(game, f), { source: f.source, played: false, closed: true }); + assert.ok(player(game, f.target).hand.some(card => card.id === f.card)); + assert.ok(!promise(game, f.target).fulfilled); const evidence = structuredClone(receipt(game, f)); + game = advance(game, state => state.phase === 8 && clean(state), [], true); + rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, f.payment); + assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(receipt(game, f), evidence); physical(game, f); + } +}); + +void test('definite window answers reopen actual own Ready including constants and cannot skip a required printed play', () => { + for (const effect of effects) { + const f = fixture({ effect, window: 'afterReveal' }); + const ready = act(f.game, f.target, { type: 'ready' }); assert.ok(ready.ready.includes(f.target)); + let game = bind(f, 'yes', f.mixed, ready).game; + assert.equal(game.ready.includes(f.target), false); rejected(game, f.target, { type: 'ready' }); + game = act(game, f.target, f.payment); rejected(game, f.target, { type: 'ready' }); + game = settle(act(game, f.target, f.nativeAction)); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f); + const constantYes = group('or', fact(f.spiceFact), play(effect, f.source)); + const constantNo = group('and', fact({ kind: 'spice', compare: 'gte', value: 100 }), play(effect, f.source)); + for (const [selected, mixed] of [['yes', constantYes], ['no', constantNo]] as const) { + const answered = bind(f, selected, mixed, ready).game; + assert.equal(answered.ready.includes(f.target), false, `${effect}/${selected} must reopen the concurrent original Ready`); + physical(answered, f); + } + } +}); + +void test('a real earlier Weather or Harvester play never pre-fills a newly answered future printed-play receipt', () => { + for (const effect of ['weather', 'harvester'] as const) { + const f = fixture({ effect }); let game = settle(act(f.game, f.target, f.nativeAction)); + assert.equal(game.discard.filter(card => card.id === f.card).length, 1); + assert.equal(windowCardOpportunity(game, effect)?.source, f.source); + const mixed = play(effect, f.source), asked = ask(declare(f, game).game, f.asker, f.target, mixed); + assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); + rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); + game = act(asked, f.target, { type: 'truthAnswer', answer: 'no' }); + assert.equal(receipt(game, f).played, false); game = close(game, f); + assert.equal(promise(game, f.target).fulfilled, true); assert.equal(receipt(game, f).played, false); physical(game, f); + } +}); + +void test('foreign original Weather and foreign Ready remain legal while another seat owes native Atomics', () => { + const f = fixture({ effect: 'atomics', window: 'afterReveal' }); let game = bind(f).game; + game = act(game, f.target, f.payment); + game = settle(act(game, f.observer, { type: 'card', card: f.foreignCard, amount: 0 })); + assert.equal(game.stormPending, 0); assert.equal(windowCardOpportunity(game, 'atomics')?.source, f.source); + assert.deepEqual(receipt(game, f), { source: f.source, played: false, closed: false }); + assert.equal(game.discard.filter(card => card.id === f.foreignCard).length, 1); + game = act(game, f.asker, { type: 'ready' }); game = act(game, f.observer, { type: 'ready' }); + assert.ok(game.ready.includes(f.asker) && game.ready.includes(f.observer)); + rejected(game, f.target, { type: 'ready' }); game = settle(act(game, f.target, f.nativeAction)); + assert.equal(game.shieldWallDestroyed, true); assert.equal(player(game, f.target).tanks, 2); + assert.equal(promise(game, f.target).fulfilled, true); physical(game, f); +}); + +void test('an original first-blow No cannot forbid or count the actual held Harvester in the second Advanced blow', () => { + const f = fixture({ effect: 'harvester', advanced: true }); + assert.equal(f.game.advanced, true); assert.equal(f.spiceOrder.length, 2); + let game = bind(f, 'no', play('harvester', f.source)).game; rejected(game, f.target, f.nativeAction); + game = close(game, f); const second = windowCardOpportunity(game, 'harvester'); assert.ok(second); + assert.equal(game.phase, 1); assert.equal(game.turn, f.game.turn); assert.notEqual(second.source, f.source); + assert.ok(game.spiceWindow?.event); assert.equal(second.source, game.spiceWindow.event); + assert.equal(promise(game, f.target).fulfilled, true); + const evidence = structuredClone(receipt(game, f)), amount = game.spiceWindow.amount; + game = settle(act(game, f.target, f.nativeAction)); + assert.equal(game.spiceWindow?.amount, amount * 2); assert.equal(game.spiceWindow?.event, second.source); + assert.deepEqual(receipt(game, f), evidence); assert.equal(receipt(game, f).played, false); + assert.equal(game.discard.filter(card => card.id === f.card).length, 1); physical(game, f); +}); + +void test('exact window-card guides retain native card packets and reject wrong raw sources, actors and stale events', () => { + for (const effect of effects) { + const f = fixture({ effect }), declared = declare(f); + rejected(declared.game, f.asker, { type: 'truthAsk', question: { kind: 'turnActions', target: f.target, + turn: f.game.turn, mixed: play(effect, `${f.source}:stale`) } }); + rejected(declared.game, f.observer, { type: 'truthAsk', question: f.question }); + let game = bind(f).game; game = act(game, f.target, f.payment); + const row = promise(game, f.target), guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); + assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card); + if (effect === 'weather') assert.ok(Number.isSafeInteger(guide.action.amount) && Number(guide.action.amount) >= 0 && Number(guide.action.amount) <= 10); + assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event); + assert.equal(guide.action.turnActionPromisePhase, effect === 'harvester' ? 1 : 0); + assert.ok(Object.keys(row).every(key => ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'].includes(key))); + assert.deepEqual(viewGame(game, f.asker).turnActionPromises, []); assert.equal(viewGame(game, f.asker).turnActionGuide, null); + rejected(game, f.observer, guide.action); + rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-promise' }); + rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 5 }); + const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, packet)); + rejected(game, f.target, packet); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f); + } +}); + +void test('an actually closed Storm or blow cannot be reused by a held printed card or raw old-source question', () => { + for (const effect of effects) { + const f = fixture({ effect }); let game = close(f.game, f); + assert.ok(player(game, f.target).hand.some(card => card.id === f.card)); rejected(game, f.target, f.nativeAction); + game = declare(f, game).game; + rejected(game, f.asker, { type: 'truthAsk', question: f.question }); + } +}); + +void test('two genuine questions tag the Weather guide with its matching row while an earlier Hajr promise remains live', () => { + const f = fixture({ effect: 'weather', pairedTruthAndHajr: true }); assert.ok(f.hajr); + assert.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), [f.truth]); + assert.deepEqual(player(f.afterSetup, f.target).hand.map(card => card.id), [f.card, f.hajr]); + assert.deepEqual(player(f.afterSetup, f.observer).hand.map(card => card.id), [f.foreignCard]); + assert.notEqual(f.truth, f.foreignCard); assert.equal(f.sourceActionCount, f.sourceActions.length); + assert.equal(f.sourceCounts.selected, f.sourceActions.length); + const actions: TruthTurnWindowCardStep[] = structuredClone(f.sourceActions); + const hajr: TruthTurnWindowCardExpression = { kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } }; + const firstMixed = group('and', hajr, payment(f.asker)); + let game = bind(f, 'yes', firstMixed, f.game, actions).game; + const firstEvent = game.turnActionPromises![0].event; + const secondFixture = { ...f, asker: f.observer, truth: f.foreignCard }; + const weather = play('weather', f.source); + game = bind(secondFixture, 'yes', weather, game, actions).game; + assert.equal(game.turnActionPromises!.length, 2); + const secondEvent = game.turnActionPromises![1].event; assert.notEqual(firstEvent, secondEvent); + const paying = viewGame(game, f.target).turnActionGuide; assert.ok(paying); + assert.equal(paying.event, firstEvent); assert.equal(paying.action.type, 'bribe'); + assert.equal(paying.action.target, f.asker); assert.equal(paying.action.amount, 2); + game = act(game, f.target, paying.action, actions); + assert.equal(player(game, f.asker).bribes, 2); + assert.ok(!game.turnActionPromises![0].fulfilled); assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, false); + const view = viewGame(game, f.target), guide = view.turnActionGuide; assert.ok(guide); + assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card); + assert.equal(guide.event, secondEvent); assert.equal(guide.action.turnActionPromiseEvent, secondEvent); + assert.equal(guide.action.turnActionPromisePhase, 0); + const matching = view.turnActionPromises.find(row => row.event === guide.event); assert.ok(matching); + assert.deepEqual(matching.mixed, weather); + assert.deepEqual(view.turnActionPromises.find(row => row.event === firstEvent)?.mixed, firstMixed); + const packet: Action = JSON.parse(JSON.stringify(guide.action)); + game = settle(act(game, f.target, packet, actions), actions); + assert.equal(game.turnActionPromises![1].fulfilled, true); assert.equal(game.turnActionPromises![1].windowCards?.weather?.played, true); + assert.ok(!game.turnActionPromises![0].fulfilled); + game = advance(game, state => state.turnActionPromises!.every(row => row.fulfilled), actions); + assert.equal(game.phase, 5); assert.equal(game.active, f.target); + assert.equal(game.turnActionPromises![0].cardPlays?.hajr?.played, true); + assert.ok(game.turnActionPromises!.every(row => row.fulfilled)); + assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.hajr)); + assert.equal(game.discard.filter(card => card.id === f.foreignCard).length, 1); + assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1); + assert.ok(actions.length > f.sourceActionCount); physical(game, f); +}); + +void test('minimal profiles honor No Harvester with native Ready and do not reopen an accepted blow', () => { + const f = fixture({ effect: 'harvester' }); + for (const difficulty of DIFFICULTIES) { + const game = bind(f, 'no', play(f.effect, f.source)).game; + const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty; + const guide = view.turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'ready'); + const action = botActions(view)[0]; assert.deepEqual(action, guide.action); + const ready = act(game, f.target, action); assert.ok(ready.ready.includes(f.target)); + assert.equal(receipt(ready, f).played, false); + // Controlled public bot view only: move one of its original counters to + // the fresh territory so ordinary automatic Harvester would be tempting. + // The real played Game and its original army remain unchanged. + const accepted = viewGame(ready, f.target), me = accepted.players.find(candidate => candidate.id === f.target)!; + me.bot = difficulty; me.forces = { ...me.forces, 'carthag:11': me.forces['carthag:11'] - 1, + [`${f.originalBlow!.territory}:${f.originalBlow!.sector}`]: 1 }; + assert.ok(!botActions(accepted).some(candidate => candidate.type === 'card' && candidate.card === f.card)); + const completed = close(ready, f); assert.equal(promise(completed, f.target).fulfilled, true); physical(completed, f); + } +}); + +void test('all four minimal profiles answer and execute actual named-window printed guides through payment fulfillment', () => { + for (const effect of effects) { + const f = fixture({ effect }), asked = ask(declare(f).game, f.asker, f.target, f.mixed); + for (const difficulty of DIFFICULTIES) { + let game = structuredClone(asked); const actions: TruthTurnWindowCardStep[] = []; + 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, difficulty); + assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions); + for (let count = 0; count < 28 && !promise(game, f.target).fulfilled; count++) { + const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty; + const guide = view.turnActionGuide; + if (guide) { + const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event); + assert.ok(action, `${effect}/${difficulty}`); assert.deepEqual(action, guide.action); + game = act(game, f.target, action, actions); + } else { + const next = nativeStep(game); assert.ok(next, `${effect}/${difficulty}`); + game = act(game, next.actor, next.action, actions); + } + } + assert.equal(promise(game, f.target).fulfilled, true, `${effect}/${difficulty}`); + assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.card)); + assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bribe')); physical(game, f); + } + } +}); diff --git a/tests/turn-action-promises.test.ts b/tests/turn-action-promises.test.ts index 045eb3da..e1fe4672 100644 --- a/tests/turn-action-promises.test.ts +++ b/tests/turn-action-promises.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { battlePowerOf, type BattlePowerClaim, type BattlePowerDeclaration } from '../game/battle-power-question'; +import type { WindowCardEffect } from '../game/window-card-play'; import { compileMixedActionExpression, type MixedActionExpression, @@ -12,6 +13,7 @@ import { closeTurnActionBidding, closeTurnActionCombat, closeTurnActionBattlePower, + closeTurnActionWindowCardPlay, closeTurnActionPayments, closeTurnActionRevival, closeTurnActionTransport, @@ -21,6 +23,7 @@ import { recordTurnActionBid, recordTurnActionCardPlay, recordTurnActionBattlePower, + recordTurnActionWindowCardPlay, recordTurnActionPlan, recordTurnActionTraitor, recordTurnActionMovement, @@ -56,6 +59,8 @@ const call = (call = true, battle = 'battle:1'): Expression => const card = (effect: 'hajr' | 'ghola' = '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 windowCard = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'spice-blow:1' : 'storm:1'): Expression => + ({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } }); const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } }); const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms }); const signedTravel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3, @@ -104,6 +109,16 @@ function powers(mixed: Expression, answer = true, id = 'turn-action:1'): TurnAct visit(mixed); return row; } +function windowed(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise { + const row: TurnActionPromise = { ...promise(mixed, answer, id), windowCards: {} }; + const visit = (node: Expression): void => { + if (node.kind === 'action' && node.action.kind === 'windowCardPlay') + row.windowCards![node.action.effect] = { source: node.action.source, played: false, closed: false }; + else if (node.kind !== 'action' && node.kind !== 'fact') node.terms.forEach(visit); + }; + visit(mixed); + return row; +} void test('turn-action leaves retain exact native fields, safe payment minima and no payment twenty-force cap', () => { for (const kind of ['ship', 'move'] as const) { @@ -1251,3 +1266,133 @@ void test('power predicates deduplicate only exact original clauses and retain t assert.doesNotThrow(() => turnActionValue(powers(group('or', ...Array.from({ length: 16 }, () => power()))))); assert.throws(() => turnActionValue(powers(group('or', ...Array.from({ length: 17 }, () => power())))), TurnActionError); }); + +void test('window-card raw leaves name only a printed role and exact original source, including folded clauses', () => { + for (const effect of ['weather', 'atomics', 'harvester'] as const) { + const original = { kind: 'windowCardPlay', effect, source: effect === 'harvester' ? 'spice-blow:1' : 'storm:1' }; + assert.deepEqual(parseTurnActionLeaf(original), original); + assert.notEqual(parseTurnActionLeaf(original), original); + for (const invalid of [{ ...original, source: '' }, { ...original, card: 'physical-id' }, { ...original, amount: 12 }, + { ...original, distance: 4 }, { ...original, answer: true }, { ...original, played: true }]) + assert.throws(() => parseTurnActionLeaf(invalid), TurnActionError); + assert.throws(() => turnActionValue(promise(windowCard(effect))), TurnActionError); + assert.throws(() => turnActionValue(promise(group('or', fact(), windowCard(effect)))), TurnActionError); + } + for (const effect of ['hajr', 'ghola', 'Weather Control', null]) + assert.throws(() => parseTurnActionLeaf({ kind: 'windowCardPlay', effect, source: 'storm:1' }), TurnActionError); +}); + +void test('whole window-card AND and OR keep dynamic printed-play alternatives with no effect amount', () => { + const weather = { kind: 'windowCardPlay', effect: 'weather', source: 'storm:1', answer: true }; + const atomics = { kind: 'windowCardPlay', effect: 'atomics', source: 'storm:1', answer: true }; + const and = windowed(group('and', windowCard(), windowCard('atomics'), pay())); + const or = windowed(group('or', windowCard(), windowCard('atomics')), true, 'turn-action:2'); + assert.deepEqual(branches(and), [[weather, atomics, signedPay()]]); + assert.deepEqual(branches(or), [[weather], [atomics]]); + const played = recordTurnActionWindowCardPlay([and, or], 'respondent', 1, 'weather', 'storm:1'); + assert.deepEqual(branches(played[0]), [[atomics, signedPay()]]); + assert.equal(played[1].fulfilled, true); + const both = recordTurnActionWindowCardPlay(played, 'respondent', 1, 'atomics', 'storm:1'); + assert.equal(both[0].fulfilled, false); + assert.deepEqual(branches(both[0]), [[signedPay()]]); + assert.equal(recordTurnActionPayment(both, 'respondent', 1, payment())[0].fulfilled, true); + assert.equal(and.windowCards!.weather!.played, false); +}); + +void test('whole window-card No negates the aggregate and actual plays or later payments reject atomically', () => { + const no = windowed(group('and', windowCard(), windowCard('atomics')), false); + assert.deepEqual(branches(no), [[{ kind: 'windowCardPlay', effect: 'weather', source: 'storm:1', answer: false }], + [{ kind: 'windowCardPlay', effect: 'atomics', source: 'storm:1', answer: false }]]); + const weather = recordTurnActionWindowCardPlay([no], 'respondent', 1, 'weather', 'storm:1'); + const before = structuredClone(weather); + assert.throws(() => recordTurnActionWindowCardPlay(weather, 'respondent', 1, 'atomics', 'storm:1'), TurnActionError); + assert.deepEqual(weather, before); + const orNo = windowed(group('or', windowCard(), windowCard('atomics')), false); + assert.throws(() => recordTurnActionWindowCardPlay([orNo], 'respondent', 1, 'weather', 'storm:1'), TurnActionError); + const paidNo = windowed(group('and', windowCard(), pay()), false); + const declared = recordTurnActionWindowCardPlay([paidNo], 'respondent', 1, 'weather', 'storm:1'); + assert.throws(() => recordTurnActionPayment(declared, 'respondent', 1, payment()), TurnActionError); + const yes = windowed(group('and', windowCard(), pay()), true, 'turn-action:2'); + assert.throws(() => recordTurnActionWindowCardPlay([yes, orNo], 'respondent', 1, 'weather', 'storm:1'), TurnActionError); + assert.equal(yes.windowCards!.weather!.played, false); +}); + +void test('exact player turn effect and source binding preserves separate original-window answer intervals', () => { + const mixed = group('and', windowCard('harvester'), pay()); + const original = windowed(mixed); + for (const [player, turn, effect, source] of [ + ['other', 1, 'harvester', 'spice-blow:1'], ['respondent', 2, 'harvester', 'spice-blow:1'], + ['respondent', 1, 'weather', 'spice-blow:1'], ['respondent', 1, 'harvester', 'spice-blow:2'], + ] as const) { + assert.equal(recordTurnActionWindowCardPlay([original], player, turn, effect, source)[0], original); + assert.equal(closeTurnActionWindowCardPlay([original], player, turn, effect, source)[0], original); + } + const played = recordTurnActionWindowCardPlay([original], 'respondent', 1, 'harvester', 'spice-blow:1')[0]; + const fresh = windowed(mixed, true, 'turn-action:2'); + assert.equal(fresh.windowCards!.harvester!.played, false); + const replay = recordTurnActionWindowCardPlay([played, fresh], 'respondent', 1, 'harvester', 'spice-blow:1'); + assert.equal(replay[0], played); + assert.equal(replay[1].windowCards!.harvester!.played, true); + assert.equal(recordTurnActionWindowCardPlay(replay, 'respondent', 1, 'harvester', 'spice-blow:1')[1], replay[1]); +}); + +void test('closing one native window records absence only there and retains other window and later payment alternatives', () => { + const original = windowed(group('or', windowCard(), windowCard('atomics'), pay())); + const closed = closeTurnActionWindowCardPlay([original], 'respondent', 1, 'weather', 'storm:1'); + assert.deepEqual(closed[0].windowCards!.weather, { source: 'storm:1', played: false, closed: true }); + assert.equal(closed[0].windowCards!.atomics, original.windowCards!.atomics); + assert.equal(closed[0].paymentClosed, false); + assert.equal(branches(closed[0]).length, 2); + assert.equal(recordTurnActionWindowCardPlay(closed, 'respondent', 1, 'weather', 'storm:1')[0], closed[0]); + assert.equal(recordTurnActionWindowCardPlay(closed, 'respondent', 1, 'atomics', 'storm:1')[0].fulfilled, true); + const all = closeTurnActionWindowCardPlay(closed, 'respondent', 1, 'atomics', 'storm:1'); + assert.deepEqual(branches(all[0]), [[signedPay()]]); + assert.equal(recordTurnActionPayment(all, 'respondent', 1, payment())[0].fulfilled, true); + const played = recordTurnActionWindowCardPlay([original], 'respondent', 1, 'weather', 'storm:1')[0]; + assert.equal(played.fulfilled, true); + assert.equal(closeTurnActionWindowCardPlay([played], 'respondent', 1, 'weather', 'storm:1')[0], played); +}); + +void test('full-turn expiry closes remaining window stages while payment or transport closure preserves their real deadline', () => { + const no = windowed(windowCard('harvester'), false); + assert.equal(closeTurnActionPayments([no], 'respondent', 1)[0].windowCards!.harvester, no.windowCards!.harvester); + assert.equal(closeTurnActionTransport([no], 'respondent', 1)[0].windowCards!.harvester, no.windowCards!.harvester); + const finished = finishTurnActionTurn([no], 'respondent', 1)[0]; + assert.equal(finished.fulfilled, true); + assert.deepEqual(finished.windowCards!.harvester, { source: 'spice-blow:1', played: false, closed: true }); + const yes = windowed(windowCard(), true, 'turn-action:2'); + assert.equal(finishTurnActionTurn([yes], 'respondent', 1)[0].released, true); + const terminal = [finished, finishTurnActionTurn([yes], 'respondent', 1)[0]]; + for (const transform of [ + (rows: TurnActionPromise[]) => recordTurnActionWindowCardPlay(rows, 'respondent', 1, 'harvester', 'spice-blow:1'), + (rows: TurnActionPromise[]) => closeTurnActionWindowCardPlay(rows, 'respondent', 1, 'weather', 'storm:1'), + (rows: TurnActionPromise[]) => finishTurnActionTurn(rows, 'respondent', 1), + ]) assert.ok(transform(terminal).every((row, index) => row === terminal[index])); + validateTurnActionPromises(terminal, seats); +}); + +void test('strict window stages contain only exact raw named effects and sources without extra counters or physical IDs', () => { + const original = windowed(group('and', windowCard(), pay())); + const stage = original.windowCards!.weather!; + for (const windowCards of [undefined, null, {}, { weather: null }, { weather: { ...stage, source: 'storm:2' } }, + { weather: { ...stage, played: 1 } }, { weather: { ...stage, closed: 'yes' } }, { weather: { ...stage, count: 1 } }, + { weather: stage, atomics: { source: 'storm:1', played: false, closed: false } }, { weather: stage, ghola: stage }]) + assert.throws(() => turnActionValue({ ...original, windowCards } as TurnActionPromise), TurnActionError); + assert.throws(() => turnActionValue(windowed(group('or', windowCard(), windowCard('weather', 'storm:2')))), TurnActionError); + const old = cards(group('and', card(), pay())); + assert.equal('windowCards' in recordTurnActionWindowCardPlay([old], 'respondent', 1, 'weather', 'storm:1')[0], false); + assert.equal('windowCards' in finishTurnActionTurn([old], 'respondent', 1)[0], false); + assert.throws(() => recordTurnActionWindowCardPlay([original], 'respondent', 1, 'hajr' as never, 'storm:1'), TurnActionError); + assert.throws(() => closeTurnActionWindowCardPlay([original], 'respondent', 1, 'weather', ''), TurnActionError); +}); + +void test('window-card clauses deduplicate only their exact original source and share the sixteen-leaf budget', () => { + const repeated = windowed(group('and', windowCard(), windowCard(), pay())); + assert.equal(branches(repeated)[0].length, 2); + assert.doesNotThrow(() => turnActionValue(windowed(group('or', ...Array.from({ length: 16 }, () => windowCard()))))); + assert.throws(() => turnActionValue(windowed(group('or', ...Array.from({ length: 17 }, () => windowCard())))), TurnActionError); + const foreign = { ...windowed(windowCard()), compiled: { kind: 'action', action: { + kind: 'windowCardPlay', effect: 'weather', source: 'storm:2', + } } }; + assert.throws(() => turnActionValue(foreign as TurnActionPromise), TurnActionError); +}); diff --git a/tests/window-card-play.test.ts b/tests/window-card-play.test.ts new file mode 100644 index 00000000..74d6478e --- /dev/null +++ b/tests/window-card-play.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { windowCardOpportunity, type WindowCardGame } from '../game/window-card-play'; + +function storm(): WindowCardGame { + return { status: 'playing', phase: 0, turn: 3, shieldWallDestroyed: false }; +} +function blow(): WindowCardGame { + return { ...storm(), phase: 1, spiceWindow: { event: 'spice-blow:1', territory: 'hagga_basin', sector: 5, harvested: false } }; +} + +void test('Weather and Family Atomics share the exact original current Storm source before movement', () => { + assert.deepEqual(windowCardOpportunity(storm(), 'weather'), { effect: 'weather', source: 'storm:3', turn: 3 }); + assert.deepEqual(windowCardOpportunity(storm(), 'atomics'), { effect: 'atomics', source: 'storm:3', turn: 3 }); + assert.equal(windowCardOpportunity({ ...storm(), turn: 4 }, 'weather')!.source, 'storm:4'); +}); + +void test('actual Storm close and movement resolution end the window while legacy absent open flag remains valid', () => { + for (const effect of ['weather', 'atomics'] as const) { + assert.equal(windowCardOpportunity({ ...storm(), stormCardWindowOpen: false }, effect), null); + assert.equal(windowCardOpportunity({ ...storm(), stormResolution: { event: 'storm-movement:1' } }, effect), null); + assert.equal(windowCardOpportunity({ ...storm(), phase: 1 }, effect), null); + assert.ok(windowCardOpportunity({ ...storm(), stormCardWindowOpen: true }, effect)); + } +}); + +void test('a destroyed Shield Wall closes only Family Atomics rather than the remaining Weather window', () => { + const source = { ...storm(), shieldWallDestroyed: true }; + assert.equal(windowCardOpportunity(source, 'atomics'), null); + assert.equal(windowCardOpportunity(source, 'weather')!.source, 'storm:3'); +}); + +void test('temporary responses, decisions, phase openings and Truth preserve the native global source', () => { + for (const blocker of ['response', 'decision', 'phaseOpening', 'truthtrance'] as const) { + assert.equal(windowCardOpportunity({ ...storm(), [blocker]: {} }, 'weather')!.source, 'storm:3'); + assert.equal(windowCardOpportunity({ ...blow(), [blocker]: {} }, 'harvester')!.source, 'spice-blow:1'); + } +}); + +void test('a current Harvester window binds its actual blow event and preserves later distinct blow identity', () => { + const source = blow(); + assert.deepEqual(windowCardOpportunity(source, 'harvester'), { effect: 'harvester', source: 'spice-blow:1', turn: 3 }); + source.spiceWindow!.event = 'spice-blow:2'; + assert.equal(windowCardOpportunity(source, 'harvester')!.source, 'spice-blow:2'); + assert.equal(windowCardOpportunity({ ...source, phase: 2 }, 'harvester'), null); +}); + +void test('Harvester uses existing unlimited physical stacking and replay availability without a per-window count cap', () => { + const source = blow(); + source.spiceWindow!.harvested = true; + assert.equal(windowCardOpportunity(source, 'harvester'), null, 'Legacy harvested-only windows are closed'); + for (const harvesters of [1, 2, 20, 100, Number.MAX_SAFE_INTEGER]) { + source.spiceWindow!.harvesters = harvesters; + assert.equal(windowCardOpportunity(source, 'harvester')!.source, 'spice-blow:1'); + } + source.spiceWindow!.harvesterClosed = true; + assert.equal(windowCardOpportunity(source, 'harvester'), null); +}); + +void test('legacy fresh blows get the specified deterministic source without mutating their original state', () => { + const source = blow(); delete source.spiceWindow!.event; + const before = structuredClone(source); + assert.equal(windowCardOpportunity(source, 'harvester')!.source, 'legacy-spice-blow:3:hagga_basin:5'); + assert.deepEqual(source, before); + const otherSector = { ...source, spiceWindow: { ...source.spiceWindow!, sector: 6 } }; + assert.notEqual(windowCardOpportunity(otherSector, 'harvester')!.source, windowCardOpportunity(source, 'harvester')!.source); +}); + +void test('invalid public source metadata and unavailable game windows do not create a native opportunity', () => { + for (const source of [{ ...storm(), status: 'setup' }, { ...storm(), turn: 0 }, { ...storm(), turn: 1.5 }, + { ...storm(), turn: Number.MAX_SAFE_INTEGER + 1 }]) + assert.equal(windowCardOpportunity(source, 'weather'), null); + for (const source of [{ ...blow(), spiceWindow: null }, { ...blow(), spiceWindow: { ...blow().spiceWindow!, event: '' } }, + { ...blow(), spiceWindow: { ...blow().spiceWindow!, territory: ' hagga_basin' } }, + { ...blow(), spiceWindow: { ...blow().spiceWindow!, sector: -1 } }, + { ...blow(), spiceWindow: { ...blow().spiceWindow!, harvesters: NaN } }]) + assert.equal(windowCardOpportunity(source, 'harvester'), null); + assert.equal(windowCardOpportunity(storm(), 'unknown' as never), null); +});
Choose a player with supported native Voice or Prescience, printed card plays, current battle, Traitor declaration, alliance, ordinary bid, Revival, payment, shipment or ground-movement opportunities this turn. + Weather Control, Family Atomics and Harvester refer only to their original current Storm or fresh spice-blow window. Ordinary payments may remain available after their Shipment and Movement opportunity ends.
{turnActionsError ?? truthQuestionText(question, leaderName, locationName, name)}
This printed role has no current original open native window.