Support Truthtrance questions about storm dials

This commit is contained in:
Quintine committed 2026-10-27 21:08:02 +11:00
1 parent 56b3aeab68
commit 8006e53580
17 files changed
+823 -14

No files matched your search

+23 -1
View File
@@ -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<TurnActionLeaf> = {
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&apos;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&apos;s answer or inspected value. Borrowed Nexus and special powers do not count.
@@ -129,6 +132,8 @@ const config: MixedActionConfig<TurnActionLeaf> = {
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<TurnActionLeaf> = {
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<TurnActionLeaf> = {
if (kind === 'ship' || kind === 'move') onChange({ kind,
territory: value.kind === 'ship' || value.kind === 'move' ? value.territory : 'carthag', minimum });
}}>
<option value="stormDial">Submit an exact value in this current Storm dial</option>
<option value="cardPlay">Use a named printed card role after this answer</option>
<option value="windowCardPlay">Use a printed card in this original native window or shipment-price opportunity</option>
<option value="battlePower">Declare native Voice or Prescience in this battle</option>
@@ -203,7 +213,19 @@ const config: MixedActionConfig<TurnActionLeaf> = {
<option value="ship">Ship from reserves</option>
<option value="move">Make ground movement</option>
</select>
{value.kind === 'cardPlay' ? <>
{value.kind === 'stormDial' ? (() => {
const opportunity = nativeStormDialOpportunity(game, target, value.source);
return <>
<p className="fine">The respondent’s original current Storm dial counts once. Other dials remain sealed until reveal.</p>
<label htmlFor={`${id}-storm-dial-value`}>Exact Storm dial value</label>
<Input id={`${id}-storm-dial-value`} type="number" min={opportunity?.min ?? (game.turn === 1 ? 0 : 1)}
max={opportunity?.max ?? (game.turn === 1 ? 20 : 3)} step={1} required disabled={disabled}
value={Number.isFinite(value.value) ? value.value : ''}
aria-invalid={!opportunity || !Number.isSafeInteger(value.value) || value.value < opportunity.min || value.value > opportunity.max}
onChange={event => onChange({ ...value, value: event.currentTarget.valueAsNumber })} />
{!opportunity && <p className="fine">Choose a still-unsubmitted dialer in this current Storm.</p>}
</>;
})() : value.kind === 'cardPlay' ? <>
<label htmlFor={`${id}-effect`}>Printed card role after this answer</label>
<select id={`${id}-effect`} value={value.effect} disabled={disabled}
aria-invalid={!['hajr', 'ghola', 'nullentropyBox', 'distrans', 'juiceOfSapho', 'semutaDrug', 'ornithopter', 'residualPoison'].includes(value.effect)} onChange={event => {
+15 -2
View File
@@ -10,6 +10,7 @@ import { gholaForcePreparations } from '@/game/ghola-force-preparation';
import { richeseCardDefinition } from '@/game/richese-cards';
import { nativeBattlePowerOpportunity, type NativeBattlePower } from '@/game/battle-power-question';
import { battleCardLabel, VOICE_KINDS } from '@/game/battle-cards';
import { nativeStormDialOpportunity } from '@/game/storm-dial-question';
import { karamaBattleCancelOpportunity, WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
import type { TurnActionLeaf } from '@/game/turn-action-promises';
import type { MixedActionExpression } from '@/game/mixed-action-question';
@@ -35,6 +36,9 @@ const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Co
const namesWindow = (mixed: MixedActionExpression<TurnActionLeaf>, effect: WindowCardEffect, source?: string): boolean =>
mixed.kind === 'action' ? mixed.action.kind === 'windowCardPlay' && mixed.action.effect === effect && (source === undefined || mixed.action.source === source)
: mixed.kind !== 'fact' && mixed.terms.some(term => namesWindow(term, effect, source));
const namesStormDial = (mixed: MixedActionExpression<TurnActionLeaf>, source: string): boolean =>
mixed.kind === 'action' ? mixed.action.kind === 'stormDial' && mixed.action.source === source
: mixed.kind !== 'fact' && mixed.terms.some(term => namesStormDial(term, source));
const namesBoxPlay = (mixed: MixedActionExpression<TurnActionLeaf>): boolean => mixed.kind === 'action'
? mixed.action.kind === 'cardPlay' && mixed.action.effect === 'nullentropyBox'
: mixed.kind !== 'fact' && mixed.terms.some(namesBoxPlay);
@@ -80,6 +84,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const box = game.nullentropy, search = box?.search;
const windowPromise = game.turnActionPromises.find(promise => promise.event === event && promise.player === actor && promise.turn === turn);
const windowQuestion = windowPromise && JSON.stringify(windowPromise.mixed);
const stormDialSource = action.type === 'stormDial' ? nativeStormDialOpportunity(game, actor) : null;
const semutaAction = action.type === 'semutaCommit' || action.type === 'semutaSelect' || action.type === 'semutaPass';
const semuta = semutaAction ? game.semutaReaction : null;
const semutaSource = (reaction: GameView['semutaReaction']) => reaction && JSON.stringify({
@@ -410,7 +415,14 @@ export function TurnActionGuideButton(props: TurnActionProps) {
!current.ready.includes(actor) && me.charityClaimed === false && current.charity === charity &&
charitySource(current) === charitySignature && current.charity.incomePending === false &&
Number.isSafeInteger(current.charity.amount) && current.charity.amount > 0;
const opportunity = nativeSemuta || printedResidual || printedDistrans || printedSapho || printedBox || openingReady || earlyWindowReady || earlyWindowBox || printedWindow || ownedPower || payment || paidBox || printedHajr || printedGhola || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2
const currentStormDial = action.type === 'stormDial' && !!stormDialSource && !!windowPromise &&
current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion &&
namesStormDial(windowPromise.mixed, stormDialSource.source) &&
nativeStormDialOpportunity(current, actor, stormDialSource.source)?.source === stormDialSource.source &&
Object.keys(action).every(key => ['type', 'amount', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key)) &&
typeof action.amount === 'number' && Number.isSafeInteger(action.amount) &&
action.amount >= stormDialSource.min && action.amount <= stormDialSource.max;
const opportunity = currentStormDial || nativeSemuta || printedResidual || printedDistrans || printedSapho || printedBox || openingReady || earlyWindowReady || earlyWindowBox || printedWindow || ownedPower || payment || paidBox || printedHajr || printedGhola || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2
? charityClaim || !current.ready.includes(actor) && (action.type === 'ready' || genericCard)
: phase === 3
? invoice || nativePass || requiredBid || action.type === 'passBid' && !current.richeseAuction && currentAuction && current.auction?.active === actor
@@ -445,7 +457,8 @@ export function TurnActionGuideButton(props: TurnActionProps) {
: action.scope === 'movement' ? 'remaining Movement order' : action.scope === 'battleOrder' ? 'remaining battle order'
: action.scope === 'extortion' ? 'Extortion order' : action.scope === 'revival' ? 'remaining Revival order' : 'the quoted native order';
const sector = typeof action.sector === 'number' ? `, sector ${action.sector}` : '';
const label = action.type === 'bribe' ? `Pay ${amount} spice to ${recipient} while honoring your whole answer`
const label = action.type === 'stormDial' ? `Submit Storm dial ${amount} while honoring your whole answer`
: action.type === 'bribe' ? `Pay ${amount} spice to ${recipient} while honoring your whole answer`
: action.type === 'semutaCommit' ? 'Commit Semuta Drug to this fresh discard while honoring your whole answer'
: action.type === 'semutaSelect' ? `Complete Semuta Drug by claiming ${semuta?.stage === 'select'
? semuta.candidates.find(card => card.id === action.card)?.name ?? 'the quoted card' : 'the quoted card'} while honoring your whole answer`