From 8006e53580f080ddfd3d6826b7368376ab6b6db3 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:08:02 +1100 Subject: [PATCH] Support Truthtrance questions about storm dials --- components/turn-action-fields.tsx | 24 ++- components/turn-action-promises.tsx | 17 +- docs/CURRENT_STATUS.md | 9 + docs/IMPLEMENTATION_STATUS.md | 8 + docs/RULE_DECISIONS.md | 8 + docs/TRUTHTRANCE_KNOWLEDGE.md | 2 + docs/TRUTHTRANCE_TURN_ACTIONS.md | 24 +++ game/engine.ts | 60 +++++- game/reference.ts | 2 + game/storm-dial-question.ts | 86 ++++++++ game/truthtrance.ts | 7 + game/turn-action-promises.ts | 88 ++++++++- tests/fixture-truth-storm-dial.ts | 86 ++++++++ tests/storm-dial-question.test.ts | 72 +++++++ tests/truth-storm-dial-runtime.test.ts | 187 ++++++++++++++++++ .../turn-action-storm-dial-controls.test.tsx | 66 +++++++ tests/turn-action-storm-dial.test.ts | 91 +++++++++ 17 files changed, 823 insertions(+), 14 deletions(-) create mode 100644 game/storm-dial-question.ts create mode 100644 tests/fixture-truth-storm-dial.ts create mode 100644 tests/storm-dial-question.test.ts create mode 100644 tests/truth-storm-dial-runtime.test.ts create mode 100644 tests/turn-action-storm-dial-controls.test.tsx create mode 100644 tests/turn-action-storm-dial.test.ts diff --git a/components/turn-action-fields.tsx b/components/turn-action-fields.tsx index 4a0de2dc..42ac8fd8 100644 --- a/components/turn-action-fields.tsx +++ b/components/turn-action-fields.tsx @@ -7,6 +7,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 { nativeStormDialOpportunity } from '@/game/storm-dial-question'; import { karamaBattleCancelOpportunity, WINDOW_CARD_EFFECTS, 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'; @@ -74,6 +75,8 @@ const config: MixedActionConfig = { An open plan commitment settles when both plans are revealed, not merely when your own plan is sealed. Traitor conditions concern one original Reveal or decline declaration in the current revealed battle. The declaration counts before a later Karama or betrayal response can prevent its effect. + A Storm dial condition names one exact value the respondent will submit in their original current Storm dial. + The first Storm allows 0–20; later Basic storms allow 1–3. The other player's value stays sealed until both dials reveal. Native Voice and Prescience conditions concern your original use or explicit decline, exact Voice command, or requested Prescience field in this battle. They count before later Karama prevention. A requested field is not the opponent's answer or inspected value. Borrowed Nexus and special powers do not count. @@ -129,6 +132,8 @@ const config: MixedActionConfig = { const action = parseTurnActionLeaf(input); if (windowCardOpportunity(game, 'karamaBattleCancel') && (action.kind !== 'windowCardPlay' || action.effect !== 'karamaBattleCancel')) throw new Error('During this response, choose only a printed Karama cancellation of the current Voice or Prescience; current facts may still be combined.'); + if (action.kind === 'stormDial' && !nativeStormDialOpportunity(game, target, action.source)) + throw new Error('Choose the respondent’s still-unsubmitted current Storm dial.'); if (action.kind === 'payment' && !recipients(game, target).some(recipient => recipient.id === action.recipient)) throw new Error('Choose a public non-allied recipient for the future ordinary payment.'); if (action.kind === 'bid' && currentLot(game)?.lot !== action.lot) @@ -168,6 +173,10 @@ const config: MixedActionConfig = { const kind = event.currentTarget.value; if (kind === value.kind) return; const minimum = 'minimum' in value ? value.minimum : 1; + if (kind === 'stormDial') { + const opportunity = nativeStormDialOpportunity(game, target); + onChange({ kind, source: opportunity?.source ?? '', value: opportunity?.min ?? 0 }); + } if (kind === 'cardPlay') onChange({ kind, effect: 'hajr' }); if (kind === 'windowCardPlay') { const effect = battleCancelOpportunity(game, target) ? 'karamaBattleCancel' @@ -190,6 +199,7 @@ const config: MixedActionConfig = { if (kind === 'ship' || kind === 'move') onChange({ kind, territory: value.kind === 'ship' || value.kind === 'move' ? value.territory : 'carthag', minimum }); }}> + @@ -203,7 +213,19 @@ const config: MixedActionConfig = { - {value.kind === 'cardPlay' ? <> + {value.kind === 'stormDial' ? (() => { + const opportunity = nativeStormDialOpportunity(game, target, value.source); + return <> +

The respondent’s original current Storm dial counts once. Other dials remain sealed until reveal.

+ + opportunity.max} + onChange={event => onChange({ ...value, value: event.currentTarget.valueAsNumber })} /> + {!opportunity &&

Choose a still-unsubmitted dialer in this current Storm.

} + ; + })() : value.kind === 'cardPlay' ? <>