Support Truthtrance questions about storm dials
This commit is contained in:
1 parent
56b3aeab68
commit
8006e53580
17 files changed
+823
-14
No files matched your search
@@ -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'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<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 => {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -20,6 +20,15 @@ open mode gates.
|
||||
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
||||
Prototype labels and final acceptance gates remain.
|
||||
|
||||
**Truthtrance upcoming Basic storm dial — 27 October. Prototyped / Partial.**
|
||||
An exact-value condition binds an eligible player's original unsubmitted native
|
||||
dial, with the normal first-turn or later range. The same sealed submission
|
||||
satisfies all accepted whole answers; the owner-only guide and minimal AI use
|
||||
legal native choices and the normal reveal is retained. Current facts and
|
||||
supported whole-action alternatives remain available. Advanced and wider
|
||||
combinations and final rules/deployed acceptance remain open.
|
||||
[Scope](TRUTHTRANCE_TURN_ACTIONS.md#upcoming-basic-storm-dial-choice--27-october-2026).
|
||||
|
||||
**Truthtrance battle cancellation — 27 October. Prototyped / Partial.**
|
||||
Supported Basic native tables can ask a seated non-owner whether they will
|
||||
spend a printed Karama to cancel the current Voice or Prescience. The original
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 27 October 2026 — Truthtrance upcoming Basic storm dial
|
||||
|
||||
A source-bound current-turn question can bind an eligible Basic player's
|
||||
unsubmitted native storm-dial value. Human and minimal bot guides submit the
|
||||
actual legal dial; multiple answers share the same physical choice and normal
|
||||
sealed reveal. Advanced and wider combination acceptance remain open.
|
||||
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#upcoming-basic-storm-dial-choice--27-october-2026).
|
||||
|
||||
## 27 October 2026 — Truthtrance battle cancellation
|
||||
|
||||
Supported Basic native Voice and Prescience responses admit a source-bound
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Rule decision index
|
||||
|
||||
## Truthtrance and the upcoming Basic storm dial — 27 October 2026
|
||||
|
||||
A question about an eligible player's future native dial binds their exact
|
||||
submission in the original Basic turn. This is a provisional application of
|
||||
the current-turn FAQ permission, distinct from a recorded-dial fact and not
|
||||
a separate publisher timing ruling.
|
||||
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#upcoming-basic-storm-dial-choice--27-october-2026).
|
||||
|
||||
## Truthtrance and current battle cancellation — 27 October 2026
|
||||
|
||||
For a current native Basic Voice or Prescience response, the provisional
|
||||
|
||||
@@ -20,6 +20,8 @@ part of that broad permission; they are not additional printed restrictions.
|
||||
- **Submitted storm dial:** compare this respondent's locked value while the
|
||||
current Storm phase and its submission remain active. A not-yet-submitted or
|
||||
expired dial is unknown; zero is a real first-turn value, not missing data.
|
||||
The separate combined turn-action question can bind an eligible Basic player’s
|
||||
future exact dial; it does not turn an unsubmitted dial into a recorded fact.
|
||||
- **Known forecast:** compare the current storm card only for native Advanced
|
||||
Fremen when the existing knowledge flag is true. Canceled peeks, unknown cards
|
||||
and other factions receive unknown; no new deck lookup or borrowed knowledge
|
||||
|
||||
@@ -1033,3 +1033,27 @@ explicitly labelled; no game database, server or saved room is involved.
|
||||
|
||||
Advanced, optional overlays, broader response/action combinations and final
|
||||
rules, browser and deployed acceptance remain open.
|
||||
|
||||
## Upcoming Basic storm-dial choice — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** The publisher FAQ (November 2020, p. 8) permits
|
||||
current-turn Yes/No questions. This event-bound exact future choice is the
|
||||
project's provisional online procedure for that permission, not a separately
|
||||
printed publisher protocol. It names a current eligible, unsubmitted Basic storm
|
||||
dialer, this turn and one legal value: 0–20 on turn one, 1–3 later. The server
|
||||
validates the actual original opportunity without reading another player's sealed
|
||||
dial. A later turn or already submitted dial is a different or unavailable
|
||||
source; an ordinary recorded-dial fact retains its existing separate meaning.
|
||||
|
||||
A definite answer binds the entire expression. The actual native submission
|
||||
records the value once; a whole Yes must preserve a completion, while a whole No
|
||||
cannot make its expression true. Multiple signed conditions and earlier whole
|
||||
answers share one physical choice. A player cannot deliberately use their own
|
||||
Weather Control to remove a required dial; another player's Weather may end
|
||||
that opportunity and release an otherwise impossible answer. The owner-only
|
||||
guide and minimal AI consume a legal native value. The other dialer's own input and ordinary simultaneous
|
||||
reveal remain native; question and answer can naturally disclose logical
|
||||
information about the named value but do not publish a sealed dial receipt.
|
||||
Current facts and other supported whole-action alternatives keep their existing
|
||||
limitations. Advanced later Storm uses its native deck and remains outside this
|
||||
Basic question prototype; full rule, browser and deployed acceptance are open.
|
||||
+52
-8
@@ -7,6 +7,7 @@ import { AuctionClockError, AUCTION_BID_MS, quoteAuctionClock, viewAuctionClock,
|
||||
type AuctionClock, type AuctionClockView } from './auction-clock';
|
||||
import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay,
|
||||
type PrintedCardEffect, type PrintedCardPlayCounts } from './printed-card-play';
|
||||
import { nativeStormDialOpportunity } from './storm-dial-question';
|
||||
import { battlePowerOf, battlePowerMatches, nativeBattlePowerOpportunity,
|
||||
type NativeBattlePower, type BattlePowerDeclaration } from './battle-power-question';
|
||||
import { WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, karamaShipmentOpportunities,
|
||||
@@ -17,6 +18,7 @@ import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurn
|
||||
closeTurnActionRevival, settleTurnActionAlliance, recordTurnActionPlan, recordTurnActionTraitor, closeTurnActionCombat,
|
||||
recordTurnActionBattlePower, closeTurnActionBattlePower,
|
||||
recordTurnActionWindowCardPlay, closeTurnActionWindowCardPlay,
|
||||
recordTurnActionStormDial, closeTurnActionStormDial, turnActionStormDialChoices,
|
||||
turnActionPromiseModeSupported, type TurnActionPlanValue, type TurnActionCombat, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation, type TurnActionWindowCards } from './turn-action-promises';
|
||||
import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShipment, recordTravelMovement,
|
||||
finishTravelTurn, validateTravelPromises, type TravelClaim, type TravelPromise, type TravelBranchLeaf } from './travel-expression-promises';
|
||||
@@ -20759,7 +20761,7 @@ function turnActionAllianceCanClose(g: Game, player: Player): boolean {
|
||||
return !!turnActionCompletion(source, player.id);
|
||||
} catch (error) { if (error instanceof RuleError) return false; throw error; }
|
||||
}
|
||||
type TurnActionCompletion = MovementCompletion & { payments: Action[]; paymentsAfterPreparations?: boolean; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action; cardPreparations?: Action[]; battlePower?: Action; freshDiscard?: Action };
|
||||
type TurnActionCompletion = MovementCompletion & { stormDial?: Action; payments: Action[]; paymentsAfterPreparations?: boolean; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action; cardPreparations?: Action[]; battlePower?: Action; freshDiscard?: Action };
|
||||
/** Forecast deterministic opening work only. Auction/deck draws and foreign
|
||||
* choices stay at the real table; fixed Mentat receipts arrive after Amal. */
|
||||
function projectTurnActionOpeningEnd(g: Game) {
|
||||
@@ -20913,6 +20915,11 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const dialLeaves = leaves.filter((leaf): leaf is Extract<TurnActionBranchLeaf, { kind: 'stormDial' }> => leaf.kind === 'stormDial');
|
||||
const dialOpportunity = dialLeaves.length ? nativeStormDialOpportunity(source, actor) : null;
|
||||
if (dialLeaves.length && (!dialOpportunity || dialLeaves.some(leaf => leaf.source !== dialOpportunity.source))) return null;
|
||||
const dialValues = dialOpportunity ? turnActionStormDialChoices(dialLeaves, dialOpportunity.source) : [];
|
||||
if (dialOpportunity && !dialValues.length) return null;
|
||||
let trial = structuredClone(source);
|
||||
trial.truthtrance = null;
|
||||
delete trial.turnActionPromises;
|
||||
@@ -21063,7 +21070,8 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
|
||||
if (completion) {
|
||||
if (alliancePlan?.uncertain) { unknownConsent!.value = true; continue; }
|
||||
const cardPreparations = [...opening.actions, ...cardSeed.actions];
|
||||
return { ...completion, ...(cardPreparations.length ? { cardPreparations } : {}),
|
||||
return { ...completion, ...(dialOpportunity ? { stormDial: { type: 'stormDial', amount: dialValues[0] } } : {}),
|
||||
...(cardPreparations.length ? { cardPreparations } : {}),
|
||||
...(cardSeed.cardsFirst ? { paymentsAfterPreparations: true } : {}), ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
|
||||
...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}),
|
||||
...(power.action ? { battlePower: power.action } : {}),
|
||||
@@ -21088,9 +21096,14 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
const printedCards = new Set<PrintedCardEffect>();
|
||||
const powers = new Set<NativeBattlePower>();
|
||||
const windowCards: TurnActionWindowCards = {};
|
||||
let stormDialSource: string | undefined;
|
||||
const visit = (node: TurnActionClaim['mixed']): void => {
|
||||
if (node.kind === 'action') {
|
||||
if (node.action.kind === 'bid') bid = true;
|
||||
if (node.action.kind === 'stormDial') {
|
||||
requireRule(!stormDialSource || stormDialSource === node.action.source, 'Retain the same original storm dial.');
|
||||
stormDialSource = node.action.source;
|
||||
}
|
||||
if (node.action.kind === 'alliance') alliance = true;
|
||||
if (node.action.kind === 'battlePlan') plan = true;
|
||||
if (node.action.kind === 'traitorCall') traitor = true;
|
||||
@@ -21103,6 +21116,8 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
} else if (node.kind !== 'fact') node.terms.forEach(visit);
|
||||
};
|
||||
visit(claim.mixed);
|
||||
const dial = stormDialSource ? nativeStormDialOpportunity(g, p.id, stormDialSource) : null;
|
||||
requireRule(!stormDialSource || !!dial, 'Retain your original unsubmitted storm dial.');
|
||||
const lot = bid ? bidOpportunity(g) : null;
|
||||
requireRule(!bid || !!lot, 'Retain the original current ordinary auction lot.');
|
||||
const nexus = alliance ? allianceOpportunity(g) : null;
|
||||
@@ -21130,6 +21145,7 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
closed: false, call: null } : null } : undefined;
|
||||
let promise: TurnActionPromise = { turn: claim.turn, mixed: structuredClone(claim.mixed), event, player: p.id, asker, answer, compiled,
|
||||
...(Object.keys(windowCards).length ? { windowCards } : {}),
|
||||
...(dial ? { stormDial: { source: dial.source, closed: false, actual: null } } : {}),
|
||||
...(printedCards.size ? { cardPlays: { hajr: printedCards.has('hajr') ? { played: false, closed: !open } : null,
|
||||
ghola: printedCards.has('ghola') ? { played: false, closed: false } : null,
|
||||
...Object.fromEntries([...printedCards].filter(effect => effect !== 'hajr' && effect !== 'ghola')
|
||||
@@ -21223,6 +21239,21 @@ function bindTurnActionTruth(g: Game, p: Player, claim: TurnActionClaim, answer:
|
||||
function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) {
|
||||
reconcileTurnActionCombat(g);
|
||||
reconcileTurnActionWindowCards(g);
|
||||
for (const row of g.turnActionPromises ?? []) {
|
||||
if (!row.stormDial || row.stormDial.closed || row.fulfilled || row.released ||
|
||||
nativeStormDialOpportunity(g, row.player, row.stormDial.source)) continue;
|
||||
g.turnActionPromises = turnActionRule(() => closeTurnActionStormDial(g.turnActionPromises!,
|
||||
row.player, row.turn, row.stormDial!.source));
|
||||
const closed = g.turnActionPromises.find(promise => promise.event === row.event);
|
||||
// A source that disappeared cannot satisfy this leaf. Release only when
|
||||
// the whole expression has no remaining branch, even during a pending
|
||||
// discard continuation; alternatives may still be fulfilled later.
|
||||
if (closed && !closed.fulfilled && !closed.released && turnActionRule(() => turnActionBranches(closed).next().done)) {
|
||||
requireRule(actor !== closed.player, 'You cannot voluntarily close your required storm-dial answer.');
|
||||
closed.released = true;
|
||||
log(g, `${getPlayer(g, closed.player).name} can no longer fulfill an accepted Truthtrance commitment; that answer is no longer binding.`);
|
||||
}
|
||||
}
|
||||
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 &&
|
||||
@@ -21326,6 +21357,13 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
||||
return row && action ? { event: row.event, action: { ...action,
|
||||
turnActionPromiseEvent: row.event, turnActionPromisePhase: g.phase } } : null;
|
||||
}
|
||||
if (completion.stormDial && !g.phaseOpening && nativeStormDialOpportunity(g, player.id)) {
|
||||
const row = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(item =>
|
||||
item.stormDial && !item.stormDial.closed &&
|
||||
nativeStormDialOpportunity(g, player.id, item.stormDial.source));
|
||||
if (row) return { event: row.event, action: { ...completion.stormDial,
|
||||
turnActionPromiseEvent: row.event, turnActionPromisePhase: g.phase } };
|
||||
}
|
||||
if (g.phaseOpening) {
|
||||
const opportunity = windowCardOpportunity(g, 'amal');
|
||||
const row = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row =>
|
||||
@@ -42906,12 +42944,18 @@ function applyActionInner(
|
||||
g.stormDials[id] === undefined,
|
||||
'Your storm dial is already locked.',
|
||||
);
|
||||
g.stormDials[id] = integer(
|
||||
action.amount,
|
||||
g.turn === 1 ? 0 : 1,
|
||||
g.turn === 1 ? 20 : 3,
|
||||
'Storm dial',
|
||||
);
|
||||
const amount = integer(action.amount, g.turn === 1 ? 0 : 1, g.turn === 1 ? 20 : 3, 'Storm dial');
|
||||
const opportunity = nativeStormDialOpportunity(g, id);
|
||||
if (opportunity && liveTurnActionPromises(g.turnActionPromises ?? [], id, g.turn).some(row =>
|
||||
row.stormDial && !row.stormDial.closed && row.stormDial.source === opportunity.source)) {
|
||||
const trial = structuredClone(g);
|
||||
trial.turnActionPromises = turnActionRule(() => recordTurnActionStormDial(trial.turnActionPromises!,
|
||||
id, g.turn, opportunity.source, amount));
|
||||
trial.stormDials[id] = amount;
|
||||
requireRule(!!turnActionCompletion(trial, id), 'Choose a storm dial that preserves your whole Truthtrance answer.');
|
||||
g.turnActionPromises = trial.turnActionPromises;
|
||||
}
|
||||
g.stormDials[id] = amount;
|
||||
if (g.stormDialers.every((x) => g.stormDials[x] !== undefined)) {
|
||||
g.stormPending = Object.values(g.stormDials).reduce((a, b) => a + b, 0);
|
||||
g.stormMovementSource = nexusRule(() => createStormSource(g.turn,'dials',g.stormPending!));
|
||||
|
||||
@@ -3251,6 +3251,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Choose a recorded prediction faction, compare its turn, or compare the target’s current submitted storm dial or known Fremen forecast. Exactly, at least and at most are available for numeric facts.',
|
||||
'These questions describe a choice already recorded or information currently known. They do not require anyone to win on a future turn, change a choice, or make the storm move a promised distance.',
|
||||
'A missing prediction, an unsubmitted or expired storm dial, or an unknown forecast yields I don’t know. A forecast question never gives another faction the Fremen’s private knowledge.',
|
||||
'For a supported Basic dialer who has not submitted, the separate combined turn-action question can ask about their future exact choice; that is a commitment, not a fact about an already sealed dial.',
|
||||
'Only the target sees the verified answer before replying. The published response gives the combined result, without identifying which clause matched or separately displaying the stored values.',
|
||||
'As with other Truthtrance questions, an unknown answer lets the holder ask something else or save the card. A definite answer spends the physical card once and restores the interrupted interaction.',
|
||||
],
|
||||
@@ -3279,6 +3280,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Basic Revival No lasts to phase close; ordered Advanced finishes at the respondent’s actual own turn end. Own Ready must retain a whole completion, including a funded later payment alternative. An owned Karama invoice guide appears only at the actual payment decision, never while another bidder is active.',
|
||||
'A battle-plan condition names an atomic part of your actual plan in this current battle. An open condition settles when both original plans reveal; a plan already sealed fixes its value at the answer. The human guide fills your existing plan for review before sealing. Native leaders, physical cards, Voice, costs and ordered preparation still apply.',
|
||||
'A Traitor condition describes the original eligible voter’s Reveal or Decline declaration in this revealed battle, before any later Karama prevention. A prevented Reveal stays a declared Reveal. No missing battle or declaration is invented as a zero plan or Decline, and an absent matching Traitor never creates one.',
|
||||
'A future storm-dial condition names a current Basic dialer who has not submitted and one exact legal value. Turn 1 permits 0–20; later Basic turns permit 1–3. A Yes binds that exact native choice, while a No excludes it. Existing whole-answer alternatives still apply. The other dial remains sealed, and the normal sum is revealed only after both dialers submit.',
|
||||
'A native-power condition names your original Voice or Prescience opportunity in this battle. Ask about actual use or explicit Decline, an exact Voice category and require/forbid command, or the requested Prescience field. A requested field is not the inspected answer. The declaration counts before later Karama prevention.',
|
||||
'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.',
|
||||
'In a supported Basic battle response, ask whether a named non-owner will spend a physical printed Karama to cancel the current Voice or Prescience. The question may combine this exact response with current facts; other future action families remain unavailable while the response is open. Yes requires that actual card use while possible; No prevents it. A different player may cancel first, releasing an impossible Yes. Passing does not itself surrender a still-open response, but the target cannot close their last opportunity while a required Yes remains.',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/** A future choice names only the actor's original native dial opportunity.
|
||||
* The accepted value stays in the normal private storm dial until reveal. */
|
||||
export type StormDialSource = { turn: number; player: string };
|
||||
export type StormDialClaim = { kind: 'stormDial'; source: string; value: number };
|
||||
export type StormDialOpportunity = StormDialSource & { source: string; min: number; max: number };
|
||||
export type StormDialGame = {
|
||||
status: string;
|
||||
advanced: boolean;
|
||||
phase: number;
|
||||
turn: number;
|
||||
players: readonly { id: string }[];
|
||||
stormDialers: readonly string[];
|
||||
stormPending: number | null;
|
||||
/** Public view: identities that have sealed, never their unrevealed values. */
|
||||
stormSubmitted?: readonly string[];
|
||||
/** Server state: only the presence of a property is inspected. */
|
||||
stormDials?: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export class StormDialQuestionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'StormDialQuestionError';
|
||||
}
|
||||
}
|
||||
function check(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new StormDialQuestionError(message);
|
||||
}
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||
const positive = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||||
const object = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
/** Public canonical source, still decodable after the original dial is sealed. */
|
||||
export function parseStormDialSource(source: unknown): StormDialSource | null {
|
||||
if (!identifier(source)) return null;
|
||||
let tuple: unknown;
|
||||
try { tuple = JSON.parse(source); } catch { return null; }
|
||||
if (!Array.isArray(tuple) || tuple.length !== 3 || tuple[0] !== 'stormDial' ||
|
||||
!positive(tuple[1]) || !identifier(tuple[2]) || JSON.stringify(tuple) !== source) return null;
|
||||
return { turn: tuple[1], player: tuple[2] };
|
||||
}
|
||||
|
||||
/** Parse only an exact future native choice within that turn's real dial range. */
|
||||
export function parseStormDialClaim(value: unknown): StormDialClaim {
|
||||
check(object(value) && Object.keys(value).sort().join(',') === 'kind,source,value' && value.kind === 'stormDial',
|
||||
'Choose one original storm-dial opportunity and exact value.');
|
||||
const source = parseStormDialSource(value.source);
|
||||
check(source && typeof value.value === 'number' && Number.isSafeInteger(value.value) &&
|
||||
value.value >= (source.turn === 1 ? 0 : 1) && value.value <= (source.turn === 1 ? 20 : 3),
|
||||
'Choose a value within the original turn’s native storm-dial range.');
|
||||
return { kind: 'stormDial', source: value.source as string, value: value.value };
|
||||
}
|
||||
|
||||
/** Compare the actual accepted native submission, never an invented default. */
|
||||
export function stormDialMatches(claim: StormDialClaim, actual: unknown): boolean {
|
||||
const parsed = parseStormDialClaim(claim), source = parseStormDialSource(parsed.source)!;
|
||||
check(typeof actual === 'number' && Number.isSafeInteger(actual) &&
|
||||
actual >= (source.turn === 1 ? 0 : 1) && actual <= (source.turn === 1 ? 20 : 3),
|
||||
'Retain the actual native storm-dial submission.');
|
||||
return parsed.value === actual;
|
||||
}
|
||||
|
||||
/** Public eligibility only. Reads whether a value is sealed, never that value.
|
||||
* Original phase/card/response gates and projected owner actions remain native. */
|
||||
export function nativeStormDialOpportunity(game: StormDialGame, player: string,
|
||||
expectedSource?: string): StormDialOpportunity | null {
|
||||
if (game.status !== 'playing' || game.advanced !== false || game.phase !== 0 ||
|
||||
!positive(game.turn) || game.stormPending !== null || !identifier(player) ||
|
||||
!Array.isArray(game.players) || !game.players.length ||
|
||||
game.players.some(row => !row || !identifier(row.id)) ||
|
||||
new Set(game.players.map(row => row.id)).size !== game.players.length ||
|
||||
!Array.isArray(game.stormDialers) || !game.stormDialers.length ||
|
||||
!game.stormDialers.every(id => identifier(id) && game.players.some(row => row.id === id)) ||
|
||||
new Set(game.stormDialers).size !== game.stormDialers.length || !game.stormDialers.includes(player)) return null;
|
||||
if (game.stormSubmitted !== undefined) {
|
||||
if (!Array.isArray(game.stormSubmitted) || !game.stormSubmitted.every(id =>
|
||||
identifier(id) && game.stormDialers.includes(id)) ||
|
||||
new Set(game.stormSubmitted).size !== game.stormSubmitted.length || game.stormSubmitted.includes(player)) return null;
|
||||
} else if (!object(game.stormDials) || Object.hasOwn(game.stormDials, player)) return null;
|
||||
const source = JSON.stringify(['stormDial', game.turn, player]);
|
||||
if (expectedSource !== undefined && expectedSource !== source) return null;
|
||||
return { source, turn: game.turn, player, min: game.turn === 1 ? 0 : 1, max: game.turn === 1 ? 20 : 3 };
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES,
|
||||
turnActionPromiseModeSupported, type TurnActionClaim } from './turn-action-promises';
|
||||
import { battlePowerOf, nativeBattlePowerOpportunity } from './battle-power-question';
|
||||
import { parseKaramaShipmentSource, parseKaramaBattleCancelSource, karamaBattleCancelOpportunity, windowCardOpportunity } from './window-card-play';
|
||||
import { nativeStormDialOpportunity } from './storm-dial-question';
|
||||
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';
|
||||
@@ -431,6 +432,11 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
const action = parseTurnActionLeaf(input);
|
||||
if (battleCancel) check(action.kind === 'windowCardPlay' && action.effect === 'karamaBattleCancel' &&
|
||||
action.source === battleCancel.source, 'Ask only about the current printed Karama cancellation in this response.');
|
||||
if (action.kind === 'stormDial') {
|
||||
const opportunity = nativeStormDialOpportunity(g, respondent.id, action.source);
|
||||
check(opportunity && action.value >= opportunity.min && action.value <= opportunity.max,
|
||||
'Choose this player’s original unsubmitted native storm dial and legal value.');
|
||||
}
|
||||
if (action.kind === 'windowCardPlay') {
|
||||
const opportunity = windowCardOpportunity(g, action.effect);
|
||||
check(opportunity && opportunity.turn === g.turn && opportunity.source === action.source,
|
||||
@@ -788,6 +794,7 @@ export function truthQuestionText(
|
||||
};
|
||||
if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
||||
action => {
|
||||
if (action.kind === 'stormDial') return `you submit exactly ${action.value} on your original storm dial this turn`;
|
||||
if (action.kind === 'windowCardPlay') {
|
||||
if (action.effect === 'karamaBattleCancel') {
|
||||
const response = parseKaramaBattleCancelSource(action.source);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { parseBattleActionClaim, type MixedBattleActionClaim } from './mixed-bat
|
||||
import { PlanClaimError } from './battle-promises';
|
||||
import { PRINTED_CARD_EFFECTS, type PrintedCardEffect } from './printed-card-play';
|
||||
import { WINDOW_CARD_EFFECTS, type WindowCardEffect } from './window-card-play';
|
||||
import { StormDialQuestionError, parseStormDialClaim, parseStormDialSource, stormDialMatches, type StormDialClaim } from './storm-dial-question';
|
||||
import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope';
|
||||
import { BattlePowerQuestionError, battlePowerMatches, battlePowerOf, parseBattlePowerClaim,
|
||||
type BattlePowerClaim, type BattlePowerDeclaration, type NativeBattlePower } from './battle-power-question';
|
||||
@@ -38,7 +39,7 @@ export function turnActionPromiseModeSupported(game: NativePromiseScope & {
|
||||
!game.nexusKullPreview && !game.richeseBetrayalPreview;
|
||||
}
|
||||
|
||||
export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: string; minimum: number } |
|
||||
export type TurnActionLeaf = TravelActionClaim | StormDialClaim | { kind: 'payment'; recipient: string; minimum: number } |
|
||||
{ kind: 'bid'; lot: number; minimum: number } | RevivalActionClaim | { kind: 'alliance'; partner: string } |
|
||||
{ kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim } |
|
||||
{ kind: 'traitorCall'; battle: string; territory: string; call: boolean } |
|
||||
@@ -48,6 +49,7 @@ export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: s
|
||||
export type TurnActionClaim = { turn: number; mixed: MixedActionExpression<TurnActionLeaf> };
|
||||
export type TurnActionPayment = { recipient: string; amount: number };
|
||||
export type TurnActionBranchLeaf = TravelBranchLeaf |
|
||||
(StormDialClaim & { answer: boolean }) |
|
||||
{ kind: 'payment'; recipient: string; minimum: number; progress: number; answer: boolean } |
|
||||
{ kind: 'bid'; lot: number; source: string; minimum: number; answer: boolean } |
|
||||
{ kind: 'forces'; counter: 'total' | 'normal' | 'elite'; minimum: number; progress: number; answer: boolean } |
|
||||
@@ -107,6 +109,8 @@ export type TurnActionPromise = TurnActionClaim & {
|
||||
/** Accepted printed-role plays after this answer only, never earlier counts. */
|
||||
cardPlays?: TurnActionCardPlays;
|
||||
windowCards?: TurnActionWindowCards;
|
||||
/** A submitted dial is private to its respondent until the native reveal. */
|
||||
stormDial?: { source: string; closed: boolean; actual: number | null };
|
||||
};
|
||||
|
||||
export class TurnActionError extends Error {
|
||||
@@ -156,6 +160,7 @@ 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 === 'stormDial') return parseStormDialClaim(value);
|
||||
if (value.kind === 'windowCardPlay') {
|
||||
check(keys(value, 'effect,kind,source') && windowEffect(value.effect) && identifier(value.source),
|
||||
'Choose one original phase-opening, Storm, first-reveal, fresh-blow, ordinary-lot or shipment-price printed-card play after this answer.');
|
||||
@@ -196,7 +201,7 @@ export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
|
||||
return { kind: 'payment', recipient: claim.recipient, minimum: claim.minimum };
|
||||
} catch (error) {
|
||||
if (error instanceof TravelPromiseError || error instanceof BribePromiseError || error instanceof BidPromiseError ||
|
||||
error instanceof RevivalExpressionPromiseError || error instanceof AlliancePromiseError)
|
||||
error instanceof RevivalExpressionPromiseError || error instanceof AlliancePromiseError || error instanceof StormDialQuestionError)
|
||||
throw new TurnActionError(error.message);
|
||||
throw error;
|
||||
}
|
||||
@@ -217,6 +222,7 @@ function storedFact(value: unknown, depth = 0): TruthFact {
|
||||
}
|
||||
|
||||
function actionKey(action: TurnActionLeaf): string {
|
||||
if (action.kind === 'stormDial') return JSON.stringify([action.kind, action.source, action.value]);
|
||||
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]);
|
||||
@@ -269,6 +275,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
if ('combat' in promise) fields.push('combat');
|
||||
if ('cardPlays' in promise) fields.push('cardPlays');
|
||||
if ('windowCards' in promise) fields.push('windowCards');
|
||||
if ('stormDial' in promise) fields.push('stormDial');
|
||||
check(keys(promise, fields.sort().join(',')) &&
|
||||
positive(promise.turn) && identifier(promise.event) && identifier(promise.player) && identifier(promise.asker),
|
||||
'Retain the complete original turn-action commitment and its metadata.');
|
||||
@@ -293,6 +300,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
let hasTraitor = false;
|
||||
const cardEffects = new Set<PrintedCardEffect>();
|
||||
const windowSources = new Map<WindowCardEffect, Set<string>>();
|
||||
const stormSources = new Set<string>();
|
||||
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
|
||||
if (node.kind === 'action') {
|
||||
originals.add(actionKey(node.action));
|
||||
@@ -306,6 +314,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
if (node.action.kind === 'battlePlan') planClaims.add(planKey(node.action.claim));
|
||||
if (node.action.kind === 'traitorCall') hasTraitor = true;
|
||||
if (node.action.kind === 'cardPlay') cardEffects.add(node.action.effect);
|
||||
if (node.action.kind === 'stormDial') stormSources.add(node.action.source);
|
||||
if (node.action.kind === 'battlePower') powerClaims[battlePowerOf(node.action.claim)].add(powerKey(node.action.claim));
|
||||
if (node.action.kind === 'windowCardPlay') {
|
||||
const sources = windowSources.get(node.action.effect) ?? new Set<string>();
|
||||
@@ -322,6 +331,17 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
check(!hasTraitor || !!promise.combat?.traitor, 'Retain the original private Traitor declaration stage.');
|
||||
for (const power of ['voice', 'prescience'] as const) check(!powerClaims[power].size || !!promise.combat?.powers?.[power],
|
||||
'Retain the original private native battle-power stage, including folded original clauses.');
|
||||
check(!stormSources.size || !!promise.stormDial, 'Retain the original native storm-dial interval, including folded clauses.');
|
||||
if ('stormDial' in promise) {
|
||||
const stage = promise.stormDial;
|
||||
const source = stage && parseStormDialSource(stage.source);
|
||||
check(object(stage) && keys(stage, 'actual,closed,source') && source && source.turn === promise.turn &&
|
||||
source.player === promise.player && stormSources.size === 1 && stormSources.has(stage.source) &&
|
||||
typeof stage.closed === 'boolean' && (stage.actual === null || typeof stage.actual === 'number' &&
|
||||
Number.isSafeInteger(stage.actual) && stage.actual >= (source.turn === 1 ? 0 : 1) &&
|
||||
stage.actual <= (source.turn === 1 ? 20 : 3)) && (stage.closed || stage.actual === null),
|
||||
'Retain the respondent’s exact original storm source and actual submitted value only after closure.');
|
||||
}
|
||||
for (const effect of cardEffects) check(!!promise.cardPlays?.[effect], 'Retain the original future interval for each printed-card play clause.');
|
||||
for (const effect of windowSources.keys()) check(!!promise.windowCards?.[effect],
|
||||
'Retain the original native window for every printed-card window clause, including folded ones.');
|
||||
@@ -451,6 +471,8 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
}
|
||||
|
||||
function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
||||
if (action.kind === 'stormDial') return promise.stormDial?.source === action.source &&
|
||||
promise.stormDial.actual !== null && stormDialMatches(action, promise.stormDial.actual);
|
||||
if (action.kind === 'windowCardPlay') return promise.windowCards?.[action.effect]?.played ?? false;
|
||||
if (action.kind === 'battlePower') return promise.combat?.powers?.[battlePowerOf(action.claim)]?.values?.find(entry =>
|
||||
powerKey(entry.claim) === powerKey(action.claim))?.value ?? false;
|
||||
@@ -478,6 +500,7 @@ function evaluate(node: CompiledMixedAction<TurnActionLeaf>, promise: TurnAction
|
||||
}
|
||||
|
||||
function actionClosed(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
||||
if (action.kind === 'stormDial') return promise.stormDial!.closed;
|
||||
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;
|
||||
@@ -520,6 +543,7 @@ export function liveTurnActionPromises<T extends TurnActionPromise>(promises: re
|
||||
|
||||
function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): boolean {
|
||||
if (left.kind !== right.kind) return false;
|
||||
if (left.kind === 'stormDial') return right.kind === 'stormDial' && left.source === right.source && left.value === right.value;
|
||||
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 &&
|
||||
@@ -538,6 +562,18 @@ function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): bo
|
||||
return (right.kind === 'ship' || right.kind === 'move') && left.territory === right.territory;
|
||||
}
|
||||
|
||||
/** Legal values for all signed leaves tied to the same original dial. A caller
|
||||
* may combine leaves from several accepted promises before quoting one action. */
|
||||
export function turnActionStormDialChoices(leaves: readonly TurnActionBranchLeaf[], source: string): number[] {
|
||||
const decoded = parseStormDialSource(source);
|
||||
if (!decoded) return [];
|
||||
const named = leaves.filter((leaf): leaf is Extract<TurnActionBranchLeaf, { kind: 'stormDial' }> =>
|
||||
leaf.kind === 'stormDial' && leaf.source === source);
|
||||
const min = decoded.turn === 1 ? 0 : 1, max = decoded.turn === 1 ? 20 : 3;
|
||||
return Array.from({ length: max - min + 1 }, (_, index) => index + min)
|
||||
.filter(value => named.every(leaf => (value === leaf.value) === leaf.answer));
|
||||
}
|
||||
|
||||
/** Called within one record only: payment progress belongs to that interval. */
|
||||
function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf[]): TurnActionBranchLeaf[] | null {
|
||||
const merged = left.map(leaf => ({ ...leaf }));
|
||||
@@ -549,6 +585,15 @@ function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf
|
||||
if (!prior) merged.push({ ...leaf });
|
||||
continue;
|
||||
}
|
||||
if (leaf.kind === 'stormDial') {
|
||||
const prior = merged.find(other => sameAction(other, leaf));
|
||||
if (prior && prior.answer !== leaf.answer) return null;
|
||||
if (leaf.answer && merged.some(other => other.kind === 'stormDial' && other.source === leaf.source &&
|
||||
other.answer && other.value !== leaf.value)) return null;
|
||||
if (!prior) merged.push({ ...leaf });
|
||||
if (!turnActionStormDialChoices(merged, leaf.source).length) return null;
|
||||
continue;
|
||||
}
|
||||
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));
|
||||
@@ -622,6 +667,41 @@ function settle<T extends TurnActionPromise>(promise: T, update: T): T {
|
||||
? { ...update, fulfilled: true } : update;
|
||||
}
|
||||
|
||||
/** Record one actual sealed native dial against its exact original source.
|
||||
* Closing the stage never publishes its private numeric receipt. */
|
||||
export function recordTurnActionStormDial<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
turn: number, source: string, actual: number): T[] {
|
||||
const identity = parseStormDialSource(source);
|
||||
check(identity && identity.turn === turn && identity.player === player,
|
||||
'Retain the original respondent’s storm-dial source.');
|
||||
try { stormDialMatches({ kind: 'stormDial', source, value: actual }, actual); }
|
||||
catch (error) { if (error instanceof StormDialQuestionError) throw new TurnActionError(error.message); throw error; }
|
||||
const updates = new Map<T, T>();
|
||||
for (const promise of liveTurnActionPromises(promises, player, turn)) {
|
||||
readPromise(promise);
|
||||
const stage = promise.stormDial;
|
||||
if (!stage || stage.closed || stage.source !== source) continue;
|
||||
updates.set(promise, settle(promise, { ...promise, stormDial: { ...stage, closed: true, actual } }));
|
||||
}
|
||||
return promises.map(promise => updates.get(promise) ?? promise);
|
||||
}
|
||||
|
||||
/** Closing without a submission records absence, never a synthetic zero. */
|
||||
export function closeTurnActionStormDial<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
turn: number, source: string): T[] {
|
||||
const identity = parseStormDialSource(source);
|
||||
check(identity && identity.turn === turn && identity.player === player,
|
||||
'Retain the original respondent’s storm-dial source.');
|
||||
const updates = new Map<T, T>();
|
||||
for (const promise of liveTurnActionPromises(promises, player, turn)) {
|
||||
readPromise(promise);
|
||||
const stage = promise.stormDial;
|
||||
if (!stage || stage.closed || stage.source !== source) continue;
|
||||
updates.set(promise, settle(promise, { ...promise, stormDial: { ...stage, closed: true } }));
|
||||
}
|
||||
return promises.map(promise => updates.get(promise) ?? promise);
|
||||
}
|
||||
|
||||
/** 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<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
@@ -987,7 +1067,8 @@ export function finishTurnActionTurn<T extends TurnActionPromise>(promises: read
|
||||
} } : {}), ...(promise.cardPlays ? { cardPlays: Object.fromEntries(PRINTED_CARD_EFFECTS
|
||||
.filter(effect => Object.hasOwn(promise.cardPlays!, effect)).map(effect => [effect, promise.cardPlays![effect]
|
||||
? { ...promise.cardPlays![effect]!, closed: true } : null])) as TurnActionCardPlays } : {}),
|
||||
...(promise.windowCards ? { windowCards } : {}) };
|
||||
...(promise.windowCards ? { windowCards } : {}),
|
||||
...(promise.stormDial ? { stormDial: { ...promise.stormDial, closed: true } } : {}) };
|
||||
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);
|
||||
@@ -1012,6 +1093,7 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
|
||||
(!promise.combat?.powers?.voice || promise.combat.powers.voice.closed) &&
|
||||
(!promise.combat?.powers?.prescience || promise.combat.powers.prescience.closed) &&
|
||||
WINDOW_CARD_EFFECTS.every(effect => promise.windowCards?.[effect]?.closed ?? true) &&
|
||||
(!promise.stormDial || promise.stormDial.closed) &&
|
||||
PRINTED_CARD_EFFECTS.every(effect => promise.cardPlays?.[effect]?.closed ?? true);
|
||||
const recipientsValid = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
|
||||
? node.action.kind === 'alliance' ? players.includes(node.action.partner) && node.action.partner !== promise.player :
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { type Action, type Game } from '../game/engine';
|
||||
import { actTruthMovement, createTruthMovementFixture, truthMovementInventory } from './fixture-truth-movement';
|
||||
|
||||
export const actTruthStormDial = actTruthMovement;
|
||||
|
||||
/** Genuine Basic setup and starting deal from the existing movement fixture.
|
||||
* Only unopened original cards were reordered for the original deal. This
|
||||
* source uses its actual post-setup, pre-first-dial state; no wallet, force,
|
||||
* response, dial, or card was installed or duplicated. */
|
||||
export function createTruthStormDialFixture(value = 7) {
|
||||
const base = createTruthMovementFixture();
|
||||
const game = structuredClone(base.afterSetup);
|
||||
const { asker, target, truth } = base;
|
||||
assert.equal(game.status, 'playing'); assert.equal(game.phase, 0); assert.equal(game.turn, 1);
|
||||
assert.equal(game.advanced, false); assert.equal(game.stormPending, null);
|
||||
assert.deepEqual(new Set(game.stormDialers), new Set([asker, target]));
|
||||
assert.deepEqual(game.stormDials, {});
|
||||
assert.ok(game.players.find(player => player.id === asker)?.hand.some(card => card.id === truth));
|
||||
assert.deepEqual(truthMovementInventory(game), base.originalCards);
|
||||
const source = JSON.stringify(['stormDial', game.turn, target]);
|
||||
const mixed = { kind: 'action' as const, action: { kind: 'stormDial' as const, source, value } };
|
||||
return { base, game, asker, target, truth, source, value, mixed };
|
||||
}
|
||||
export type TruthStormDialFixture = ReturnType<typeof createTruthStormDialFixture>;
|
||||
|
||||
export function declareStormDialQuestion(f: TruthStormDialFixture, source: Game = f.game) {
|
||||
let game = actTruthStormDial(source, f.asker, { type: 'card', card: f.truth });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const player = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id));
|
||||
assert.ok(player); game = actTruthStormDial(game, player.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return game;
|
||||
}
|
||||
export function askStormDial(game: Game, f: TruthStormDialFixture, mixed: unknown = f.mixed, target = f.target) {
|
||||
return actTruthStormDial(game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
|
||||
} } as Action);
|
||||
}
|
||||
export function bindStormDial(f: TruthStormDialFixture, answer: 'yes' | 'no', source: Game = f.game) {
|
||||
const declared = declareStormDialQuestion(f, source), asked = askStormDial(declared, f);
|
||||
const game = actTruthStormDial(asked, f.target, { type: 'truthAnswer', answer });
|
||||
return { declared, asked, game };
|
||||
}
|
||||
|
||||
/** Controlled supplement: move the second original physical Truthtrance from
|
||||
* the unopened deck into the asker's existing hand. This preserves every card
|
||||
* identity and the native hand limit; it is not represented as an in-game sale
|
||||
* or transfer. Native queue, questions, answers and the dial are real actions. */
|
||||
export function declareTwoStormDialQuestions(f: TruthStormDialFixture, source: Game = f.game) {
|
||||
let game = structuredClone(source);
|
||||
const owner = game.players.find(player => player.id === f.asker); assert.ok(owner);
|
||||
const index = game.deck.findIndex(card => card.effect === 'truthtrance' && card.id !== f.truth);
|
||||
assert.ok(index >= 0, 'The second physical Truthtrance must remain in the unopened deck');
|
||||
assert.ok(owner.hand.length < 4, 'The controlled extra card must fit the normal hand limit');
|
||||
const second = game.deck.splice(index, 1)[0]; owner.hand.push(second);
|
||||
assert.deepEqual(truthMovementInventory(game), f.base.originalCards);
|
||||
game = actTruthStormDial(game, f.asker, { type: 'card', card: f.truth, cards: [second.id] });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const player = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id));
|
||||
assert.ok(player); game = actTruthStormDial(game, player.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return { game, second: second.id };
|
||||
}
|
||||
|
||||
/** Controlled physical location swap for this one card interaction: exchange
|
||||
* the target's original dealt card with the original unopened Weather card.
|
||||
* For the foreign-owner branch, move that same acquired Weather to the asker.
|
||||
* Neither setup adjustment is presented as an in-game transaction; every
|
||||
* original card remains in exactly one location and both hands stay legal. */
|
||||
export function sourceWithStormWeather(f: TruthStormDialFixture, holder: 'target' | 'asker') {
|
||||
const game = structuredClone(f.game);
|
||||
const target = game.players.find(player => player.id === f.target); assert.ok(target?.hand.length);
|
||||
const asker = game.players.find(player => player.id === f.asker); assert.ok(asker);
|
||||
const index = game.deck.findIndex(card => card.effect === 'weather'); assert.ok(index >= 0);
|
||||
const weather = game.deck[index];
|
||||
[target.hand[0], game.deck[index]] = [weather, target.hand[0]];
|
||||
if (holder === 'asker') {
|
||||
assert.ok(asker.hand.length < 4);
|
||||
asker.hand.push(target.hand.splice(0, 1)[0]);
|
||||
}
|
||||
assert.deepEqual(truthMovementInventory(game), f.base.originalCards);
|
||||
return { game, weather: weather.id, player: holder === 'target' ? f.target : f.asker };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
nativeStormDialOpportunity, parseStormDialClaim, parseStormDialSource,
|
||||
stormDialMatches, type StormDialGame,
|
||||
} from '../game/storm-dial-question';
|
||||
|
||||
const first: StormDialGame = {
|
||||
status: 'playing', advanced: false, phase: 0, turn: 1,
|
||||
players: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
|
||||
stormDialers: ['a', 'b'], stormPending: null, stormDials: {},
|
||||
};
|
||||
const source = (turn: number, player: string) => JSON.stringify(['stormDial', turn, player]);
|
||||
const claim = (turn: number, player: string, value: number) =>
|
||||
({ kind: 'stormDial' as const, source: source(turn, player), value });
|
||||
|
||||
void test('public opportunity selects the real Basic unsubmitted dialer and exact turn', () => {
|
||||
assert.deepEqual(nativeStormDialOpportunity(first, 'a'), {
|
||||
turn: 1, player: 'a', source: source(1, 'a'), min: 0, max: 20,
|
||||
});
|
||||
assert.equal(nativeStormDialOpportunity(first, 'c'), null);
|
||||
assert.equal(nativeStormDialOpportunity(first, 'a', source(2, 'a')), null);
|
||||
assert.equal(nativeStormDialOpportunity({ ...first, phase: 1 }, 'a'), null);
|
||||
assert.equal(nativeStormDialOpportunity({ ...first, status: 'finished' }, 'a'), null);
|
||||
assert.equal(nativeStormDialOpportunity({ ...first, advanced: true }, 'a'), null);
|
||||
assert.equal(nativeStormDialOpportunity({ ...first, stormPending: 0 }, 'a'), null);
|
||||
assert.deepEqual(nativeStormDialOpportunity({ ...first, turn: 2 }, 'a'), {
|
||||
turn: 2, player: 'a', source: source(2, 'a'), min: 1, max: 3,
|
||||
});
|
||||
});
|
||||
|
||||
void test('server and public view eligibility test submission without reading sealed values', () => {
|
||||
const hidden = Object.defineProperty({}, 'a', { enumerable: true, get() { throw Error('read sealed value'); } });
|
||||
const state = { ...first, stormDials: hidden };
|
||||
assert.equal(nativeStormDialOpportunity(state, 'a'), null);
|
||||
assert.ok(nativeStormDialOpportunity(state, 'b'));
|
||||
const { stormDials: _hidden, ...publicView } = state;
|
||||
assert.equal(nativeStormDialOpportunity({ ...publicView, stormSubmitted: ['a'] }, 'a'), null);
|
||||
assert.ok(nativeStormDialOpportunity({ ...publicView, stormSubmitted: ['a'] }, 'b'));
|
||||
assert.equal(nativeStormDialOpportunity({ ...publicView, stormSubmitted: ['c'] }, 'b'), null);
|
||||
});
|
||||
|
||||
void test('canonical source and claim enforce actual turn-specific ranges and no extra keys', () => {
|
||||
assert.deepEqual(parseStormDialSource(source(1, 'a')), { turn: 1, player: 'a' });
|
||||
for (const invalid of [' ["stormDial",1,"a"]', source(0, 'a'), source(1, ''), '["stormDial",1,"a",0]', 'not-json'])
|
||||
assert.equal(parseStormDialSource(invalid), null);
|
||||
assert.deepEqual(parseStormDialClaim(claim(1, 'a', 0)), claim(1, 'a', 0));
|
||||
assert.deepEqual(parseStormDialClaim(claim(1, 'a', 20)), claim(1, 'a', 20));
|
||||
assert.deepEqual(parseStormDialClaim(claim(2, 'a', 3)), claim(2, 'a', 3));
|
||||
for (const invalid of [claim(1, 'a', -1), claim(1, 'a', 21), claim(2, 'a', 0),
|
||||
claim(2, 'a', 4), claim(2, 'a', 2.5), { ...claim(1, 'a', 4), reveal: true }])
|
||||
assert.throws(() => parseStormDialClaim(invalid));
|
||||
});
|
||||
|
||||
void test('match evaluates only an actual legal submission in the named native range', () => {
|
||||
assert.equal(stormDialMatches(claim(1, 'a', 0), 0), true);
|
||||
assert.equal(stormDialMatches(claim(1, 'a', 0), 1), false);
|
||||
assert.equal(stormDialMatches(claim(2, 'a', 3), 3), true);
|
||||
for (const invalid of [undefined, null, -1, 0, 4, 1.5, '3'])
|
||||
assert.throws(() => stormDialMatches(claim(2, 'a', 2), invalid));
|
||||
});
|
||||
|
||||
void test('malformed or inconsistent public seat and dialer sets have no opportunity', () => {
|
||||
for (const game of [
|
||||
{ ...first, stormDialers: ['a', 'a'] },
|
||||
{ ...first, stormDialers: ['a', 'unknown'] },
|
||||
{ ...first, players: [{ id: 'a' }, { id: 'a' }] },
|
||||
{ ...first, stormDialers: [] },
|
||||
{ ...first, stormDials: undefined },
|
||||
{ ...first, stormSubmitted: ['a', 'a'] },
|
||||
]) assert.equal(nativeStormDialOpportunity(game, 'a'), null);
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { TurnActionGuideButton } from '../components/turn-action-promises';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { advanceTruthMovement, truthMovementInventory } from './fixture-truth-movement';
|
||||
import {
|
||||
actTruthStormDial as act, askStormDial as ask, bindStormDial as bind,
|
||||
createTruthStormDialFixture as fixture, declareStormDialQuestion as declare,
|
||||
declareTwoStormDialQuestions as declareTwo, sourceWithStormWeather,
|
||||
} from './fixture-truth-storm-dial';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function privateDials(game: Game, player: string) {
|
||||
const view = viewGame(game, player);
|
||||
assert.equal(view.stormRevealed, null);
|
||||
assert.equal('stormDials' in view, false);
|
||||
return view;
|
||||
}
|
||||
|
||||
void test('a Yes binds the current original dial and guides the native human and all four bots', () => {
|
||||
const f = fixture(), asked = ask(declare(f), f);
|
||||
assert.ok(viewGame(asked, f.target).truthTurnActionAnswers?.includes('yes'));
|
||||
let game = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(game.truthtrance, null); assert.equal(game.stormPending, null);
|
||||
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||
const view = privateDials(game, f.target), guide = view.turnActionGuide;
|
||||
assert.ok(guide); assert.equal(guide.action.type, 'stormDial'); assert.equal(guide.action.amount, f.value);
|
||||
const sent: Action[] = [];
|
||||
const props = { game: view, busy: false, act: (action: Action) => sent.push(action) };
|
||||
const button = TurnActionGuideButton(props); assert.ok(button); assert.equal(button.props.disabled, false);
|
||||
assert.match(renderToStaticMarkup(createElement(TurnActionGuideButton, props)), /storm|dial/i);
|
||||
button.props.onClick(); assert.deepEqual(sent, [guide.action]);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const bot = viewGame(game, f.target);
|
||||
bot.players.find(player => player.id === f.target)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(bot)[0], guide.action);
|
||||
}
|
||||
rejected(game, f.target, { type: 'stormDial', amount: f.value + 1 });
|
||||
game = act(game, f.target, guide.action);
|
||||
assert.equal(game.stormDials[f.target], f.value); assert.equal(game.stormPending, null);
|
||||
assert.ok(game.turnActionPromises?.find(row => row.player === f.target)?.fulfilled);
|
||||
const observer = privateDials(game, f.asker);
|
||||
assert.ok(observer.stormSubmitted.includes(f.target));
|
||||
game = act(game, f.asker, { type: 'stormDial', amount: 3 });
|
||||
assert.equal(game.stormPending, f.value + 3);
|
||||
assert.deepEqual(viewGame(game, f.asker).stormRevealed, { [f.target]: f.value, [f.asker]: 3 });
|
||||
assert.deepEqual(truthMovementInventory(game), f.base.originalCards);
|
||||
});
|
||||
|
||||
void test('a No forbids only the named value and the other real dialer remains sealed', () => {
|
||||
const f = fixture();
|
||||
const otherDialed = act(f.game, f.asker, { type: 'stormDial', amount: 3 });
|
||||
assert.equal(otherDialed.stormPending, null);
|
||||
const targetView = privateDials(otherDialed, f.target);
|
||||
assert.ok(targetView.stormSubmitted.includes(f.asker));
|
||||
let game = bind(f, 'no', otherDialed).game;
|
||||
rejected(game, f.target, { type: 'stormDial', amount: f.value });
|
||||
const guide = viewGame(game, f.target).turnActionGuide;
|
||||
assert.ok(guide); assert.equal(guide.action.type, 'stormDial'); assert.notEqual(guide.action.amount, f.value);
|
||||
game = act(game, f.target, guide.action);
|
||||
assert.equal(game.stormPending, 3 + Number(guide.action.amount));
|
||||
assert.notEqual(game.stormDials[f.target], f.value);
|
||||
assert.ok(game.turnActionPromises?.find(row => row.player === f.target)?.fulfilled);
|
||||
assert.deepEqual(truthMovementInventory(game), f.base.originalCards);
|
||||
});
|
||||
|
||||
void test('stale and ineligible original dial questions are rejected without changing state', () => {
|
||||
const f = fixture(), declared = declare(f);
|
||||
const stale = { kind: 'action', action: { kind: 'stormDial', source: JSON.stringify(['stormDial', 0, f.target]), value: f.value } };
|
||||
rejected(declared, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target: f.target, turn: declared.turn, mixed: stale,
|
||||
} });
|
||||
rejected(declared, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target: f.target, turn: declared.turn,
|
||||
mixed: { kind: 'action', action: { kind: 'stormDial', source: f.source, value: 21 } },
|
||||
} });
|
||||
const alreadyDialed = act(f.game, f.target, { type: 'stormDial', amount: 1 });
|
||||
const late = declare(f, alreadyDialed);
|
||||
rejected(late, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target: f.target, turn: late.turn, mixed: f.mixed,
|
||||
} });
|
||||
});
|
||||
|
||||
void test('two actual Truthtrances bind one dial: Yes A remains compatible with No B but excludes a second conflicting Yes', () => {
|
||||
const f = fixture(7), queued = declareTwo(f);
|
||||
let game = ask(queued.game, f);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
const second = { kind: 'action', action: { kind: 'stormDial', source: f.source, value: 4 } };
|
||||
game = ask(game, f, second);
|
||||
const answers = viewGame(game, f.target).truthTurnActionAnswers;
|
||||
assert.ok(answers?.includes('no')); assert.ok(!answers?.includes('yes'));
|
||||
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
assert.equal(game.truthtrance, null);
|
||||
assert.equal(game.turnActionPromises?.filter(row => row.player === f.target).length, 2);
|
||||
rejected(game, f.target, { type: 'stormDial', amount: 4 });
|
||||
rejected(game, f.target, { type: 'stormDial', amount: 5 });
|
||||
game = act(game, f.target, { type: 'stormDial', amount: 7 });
|
||||
assert.equal(game.stormDials[f.target], 7);
|
||||
assert.ok(game.turnActionPromises?.filter(row => row.player === f.target).every(row => row.fulfilled));
|
||||
assert.equal(game.discard.filter(card => card.id === f.truth || card.id === queued.second).length, 2);
|
||||
assert.deepEqual(truthMovementInventory(game), f.base.originalCards);
|
||||
});
|
||||
|
||||
void test('a genuine later Basic Storm cannot accept a whole No that forbids every native dial value', () => {
|
||||
const first = fixture();
|
||||
// The existing original programme uses real first-turn setup, payment and
|
||||
// movement; finish that actual actor, then use only the native continuation.
|
||||
let game = act(first.base.game, first.target, { type: 'endMovement' });
|
||||
game = advanceTruthMovement(game, state => state.turn === 2 && state.phase === 0);
|
||||
assert.equal(game.advanced, false); assert.equal(game.stormPending, null);
|
||||
assert.ok(game.stormDialers.includes(first.target));
|
||||
assert.ok(game.players.find(player => player.id === first.asker)?.hand.some(card => card.id === first.truth));
|
||||
const later = { ...first, game, source: JSON.stringify(['stormDial', 2, first.target]), value: 1,
|
||||
mixed: { kind: 'action' as const, action: { kind: 'stormDial' as const,
|
||||
source: JSON.stringify(['stormDial', 2, first.target]), value: 1 } } };
|
||||
const clauses = [1, 2, 3].map(value => ({ kind: 'action' as const, action: {
|
||||
kind: 'stormDial' as const, source: later.source, value,
|
||||
} }));
|
||||
game = ask(declare(later), later, { kind: 'or', terms: clauses });
|
||||
const answers = viewGame(game, later.target).truthTurnActionAnswers;
|
||||
assert.ok(answers?.includes('yes')); assert.ok(!answers?.includes('no'));
|
||||
rejected(game, later.target, { type: 'truthAnswer', answer: 'no' });
|
||||
game = act(game, later.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
rejected(game, later.target, { type: 'stormDial', amount: 0 });
|
||||
rejected(game, later.target, { type: 'stormDial', amount: 4 });
|
||||
const guide = viewGame(game, later.target).turnActionGuide; assert.ok(guide);
|
||||
assert.ok([1, 2, 3].includes(Number(guide.action.amount)));
|
||||
game = act(game, later.target, guide.action);
|
||||
assert.ok([1, 2, 3].includes(game.stormDials[later.target]));
|
||||
assert.deepEqual(truthMovementInventory(game), later.base.originalCards);
|
||||
});
|
||||
|
||||
void test('a required dial prevents own Weather Control but a different player can supersede and release it', () => {
|
||||
const f = fixture(7);
|
||||
const own = sourceWithStormWeather(f, 'target');
|
||||
let game = bind(f, 'yes', own.game).game;
|
||||
rejected(game, own.player, { type: 'card', card: own.weather, amount: 2 });
|
||||
assert.equal(game.stormPending, null);
|
||||
assert.ok(game.players.find(player => player.id === own.player)?.hand.some(card => card.id === own.weather));
|
||||
game = act(game, f.target, { type: 'stormDial', amount: 7 });
|
||||
assert.ok(game.turnActionPromises?.find(row => row.player === f.target)?.fulfilled);
|
||||
assert.deepEqual(truthMovementInventory(game), f.base.originalCards);
|
||||
|
||||
const foreign = sourceWithStormWeather(f, 'asker');
|
||||
game = bind(f, 'yes', foreign.game).game;
|
||||
game = act(game, foreign.player, { type: 'card', card: foreign.weather, amount: 2 });
|
||||
assert.equal(game.stormPending, 2);
|
||||
assert.equal(game.stormMovementSource?.kind, 'weather');
|
||||
assert.equal(game.stormDials[f.target], undefined);
|
||||
assert.ok(game.turnActionPromises?.find(row => row.player === f.target)?.released);
|
||||
assert.equal(game.discard.filter(card => card.id === foreign.weather).length, 1);
|
||||
assert.deepEqual(truthMovementInventory(game), f.base.originalCards);
|
||||
});
|
||||
|
||||
void test('foreign Weather leaves an affordable payment alternative active until the actual bribe fulfills it', () => {
|
||||
const f = fixture(7), foreign = sourceWithStormWeather(f, 'asker');
|
||||
const mixed = { kind: 'or', terms: [f.mixed, { kind: 'action', action: {
|
||||
kind: 'payment', recipient: f.asker, minimum: 2,
|
||||
} }] };
|
||||
let game = ask(declare(f, foreign.game), f, mixed);
|
||||
assert.ok(viewGame(game, f.target).truthTurnActionAnswers?.includes('yes'));
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const payer = game.players.find(player => player.id === f.target),
|
||||
recipient = game.players.find(player => player.id === f.asker);
|
||||
assert.ok(payer && recipient && payer.spice >= 2); assert.notEqual(payer.ally, f.asker);
|
||||
const beforeSpice = payer.spice, beforeBribes = recipient.bribes;
|
||||
game = act(game, foreign.player, { type: 'card', card: foreign.weather, amount: 2 });
|
||||
assert.equal(game.stormPending, 2); assert.equal(game.stormMovementSource?.kind, 'weather');
|
||||
let promise = game.turnActionPromises?.find(row => row.player === f.target); assert.ok(promise);
|
||||
assert.equal(promise.released, false); assert.equal(promise.fulfilled, false);
|
||||
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 2 });
|
||||
promise = game.turnActionPromises?.find(row => row.player === f.target); assert.ok(promise);
|
||||
assert.equal(promise.released, false); assert.equal(promise.fulfilled, true);
|
||||
assert.equal(game.players.find(player => player.id === f.target)?.spice, beforeSpice - 2);
|
||||
assert.equal(game.players.find(player => player.id === f.asker)?.bribes, beforeBribes + 2);
|
||||
assert.deepEqual(truthMovementInventory(game), f.base.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { TurnActionFields, turnActionInputError } from '../components/turn-action-fields';
|
||||
import { TurnActionGuideButton } from '../components/turn-action-promises';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { TurnActionLeaf } from '../game/turn-action-promises';
|
||||
|
||||
type Expression = MixedActionExpression<TurnActionLeaf>;
|
||||
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
|
||||
const dial = (turn: number, value: number): Expression => ({ kind: 'action', action: {
|
||||
kind: 'stormDial', source: JSON.stringify(['stormDial', turn, 'p']), value,
|
||||
} });
|
||||
/** Controlled public projection: this exercises controls, not native setup. */
|
||||
function projection(turn: number): GameView {
|
||||
const game = createGame('STORMDIALCONTROL', newPlayer('p', 'Dialer', 'atreides'));
|
||||
joinGame(game, newPlayer('a', 'Asker', 'emperor'));
|
||||
const view = viewGame(game, 'p');
|
||||
view.status = 'playing'; view.turn = turn; view.phase = 0; view.advanced = false;
|
||||
view.stormPending = null; view.stormDialers = ['p', 'a']; view.stormSubmitted = [];
|
||||
return view;
|
||||
}
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
void test('editor selects the exact original Storm dial and enforces native turn ranges', () => {
|
||||
for (const [turn, min, max] of [[1, 0, 20], [2, 1, 3]] as const) {
|
||||
const game = projection(turn); let value: Expression = { kind: 'action', action: { kind: 'ship', territory: 'carthag', minimum: 1 } };
|
||||
const tree = TurnActionFields({ game, value, target: 'p', disabled: false, onChange(next) { value = next; } });
|
||||
const select = elements(tree).find(element => element.props.id === 'truth-turn-action-1-action'); assert.ok(select);
|
||||
(select.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value: 'stormDial' } });
|
||||
assert.deepEqual(value, dial(turn, min));
|
||||
assert.equal(turnActionInputError(value, game, 'p'), null);
|
||||
assert.equal(turnActionInputError(dial(turn, max), game, 'p'), null);
|
||||
assert.notEqual(turnActionInputError(dial(turn, max + 1), game, 'p'), null);
|
||||
assert.notEqual(turnActionInputError(dial(turn, min - 1), game, 'p'), null);
|
||||
const html = renderToStaticMarkup(createElement(TurnActionFields, { game, value, target: 'p', disabled: false, onChange() {} }));
|
||||
assert.match(html, /Exact Storm dial value/); assert.match(html, /remain sealed/);
|
||||
game.stormSubmitted = ['p'];
|
||||
assert.notEqual(turnActionInputError(value, game, 'p'), null);
|
||||
}
|
||||
});
|
||||
void test('native Storm guide dispatches exact current owner action and rejects stale submission', () => {
|
||||
const view = projection(1), event = 'controlled-dial-promise', mixed = dial(1, 7);
|
||||
view.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 1, mixed, answer: true, fulfilled: false, released: false }];
|
||||
const action: Action = { type: 'stormDial', amount: 7, turnActionPromiseEvent: event, turnActionPromisePhase: 0 };
|
||||
view.turnActionGuide = { event, action };
|
||||
const sent: Action[] = [], props = { game: view, busy: false, act: (next: Action) => sent.push(next) };
|
||||
const button = TurnActionGuideButton(props); assert.ok(button); assert.equal(button.props.disabled, false);
|
||||
assert.match(renderToStaticMarkup(createElement(TurnActionGuideButton, props)), /Submit Storm dial 7/);
|
||||
button.props.onClick(); assert.deepEqual(sent, [action]);
|
||||
view.stormSubmitted = ['p']; button.props.onClick(); assert.deepEqual(sent, [action]);
|
||||
});
|
||||
void test('other player sealing a dial does not expose or invalidate this owner’s current guide', () => {
|
||||
const view = projection(1), event = 'controlled-dial-promise', mixed = dial(1, 7);
|
||||
view.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 1, mixed, answer: true, fulfilled: false, released: false }];
|
||||
const action: Action = { type: 'stormDial', amount: 7, turnActionPromiseEvent: event, turnActionPromisePhase: 0 };
|
||||
view.turnActionGuide = { event, action };
|
||||
const sent: Action[] = [], button = TurnActionGuideButton({ game: view, busy: false, act: next => sent.push(next) }); assert.ok(button);
|
||||
view.stormSubmitted = ['a']; button.props.onClick(); assert.deepEqual(sent, [action]);
|
||||
assert.equal(view.stormRevealed, null); assert.equal('stormDials' in view, false);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { compileMixedActionExpression, type MixedActionExpression } from '../game/mixed-action-question';
|
||||
import {
|
||||
TurnActionError, closeTurnActionStormDial, finishTurnActionTurn, parseTurnActionLeaf,
|
||||
recordTurnActionStormDial, turnActionBranches, turnActionStormDialChoices, turnActionValue,
|
||||
validateTurnActionPromises, type TurnActionBranchLeaf, type TurnActionLeaf, type TurnActionPromise,
|
||||
} from '../game/turn-action-promises';
|
||||
|
||||
const source = (turn = 2, player = 'dialer') => JSON.stringify(['stormDial', turn, player]);
|
||||
const dial = (value: number, turn = 2): MixedActionExpression<TurnActionLeaf> =>
|
||||
({ kind: 'action', action: { kind: 'stormDial', source: source(turn), value } });
|
||||
const group = (kind: 'and' | 'or', ...terms: MixedActionExpression<TurnActionLeaf>[]): MixedActionExpression<TurnActionLeaf> =>
|
||||
({ kind, terms });
|
||||
function promise(mixed: MixedActionExpression<TurnActionLeaf>, answer = true, turn = 2): TurnActionPromise {
|
||||
return { turn, mixed, event: 'promise', player: 'dialer', asker: 'asker', answer,
|
||||
compiled: compileMixedActionExpression(mixed, () => 'yes', answer),
|
||||
shipment: { pending: false, event: null }, progress: [], payments: [], transportClosed: true,
|
||||
paymentClosed: false, fulfilled: false, released: false,
|
||||
stormDial: { source: source(turn), closed: false, actual: null } };
|
||||
}
|
||||
const branches = (row: TurnActionPromise) => [...turnActionBranches(row)];
|
||||
|
||||
void test('storm-dial leaves use a canonical original actor and the turn’s actual numeric bounds', () => {
|
||||
assert.deepEqual(parseTurnActionLeaf({ kind: 'stormDial', source: source(1), value: 0 }),
|
||||
{ kind: 'stormDial', source: source(1), value: 0 });
|
||||
assert.deepEqual(parseTurnActionLeaf({ kind: 'stormDial', source: source(2), value: 3 }),
|
||||
{ kind: 'stormDial', source: source(2), value: 3 });
|
||||
for (const claim of [
|
||||
{ kind: 'stormDial', source: source(2), value: 0 },
|
||||
{ kind: 'stormDial', source: source(2), value: 4 },
|
||||
{ kind: 'stormDial', source: source(1), value: 21 },
|
||||
{ kind: 'stormDial', source: 'not-json', value: 1 },
|
||||
{ kind: 'stormDial', source: source(1), value: 1, privateValue: 2 },
|
||||
]) assert.throws(() => parseTurnActionLeaf(claim), TurnActionError);
|
||||
});
|
||||
|
||||
void test('boolean alternatives share exactly one dial and reject contradictory or exhausted ranges', () => {
|
||||
assert.deepEqual(branches(promise(group('and', dial(1), dial(2)))), []);
|
||||
assert.deepEqual(branches(promise(group('or', dial(1), dial(2), dial(3)), false)), []);
|
||||
const row = promise(group('and', dial(1), dial(2)), false);
|
||||
const alternatives = branches(row);
|
||||
assert.equal(alternatives.length, 2);
|
||||
for (const branch of alternatives) assert.ok(turnActionStormDialChoices(branch, source()).length > 0);
|
||||
const signed: TurnActionBranchLeaf[] = [
|
||||
{ kind: 'stormDial', source: source(), value: 1, answer: true },
|
||||
{ kind: 'stormDial', source: source(), value: 2, answer: false },
|
||||
];
|
||||
assert.deepEqual(turnActionStormDialChoices(signed, source()), [1]);
|
||||
assert.deepEqual(turnActionStormDialChoices([...signed, { kind: 'stormDial', source: source(), value: 1, answer: false }], source()), []);
|
||||
assert.equal(turnActionStormDialChoices([], source(1)).length, 21);
|
||||
});
|
||||
|
||||
void test('the native sealed value settles the whole answer and a No cannot be evaded', () => {
|
||||
const yes = promise(dial(2));
|
||||
const [satisfied] = recordTurnActionStormDial([yes], 'dialer', 2, source(), 2);
|
||||
assert.equal(satisfied.fulfilled, true);
|
||||
assert.deepEqual(satisfied.stormDial, { source: source(), closed: true, actual: 2 });
|
||||
assert.equal(turnActionValue(satisfied), true);
|
||||
const no = promise(dial(2), false);
|
||||
assert.throws(() => recordTurnActionStormDial([no], 'dialer', 2, source(), 2), TurnActionError);
|
||||
assert.equal(no.stormDial?.actual, null);
|
||||
const [satisfiedNo] = recordTurnActionStormDial([no], 'dialer', 2, source(), 1);
|
||||
assert.equal(satisfiedNo.fulfilled, true);
|
||||
assert.equal(turnActionValue(satisfiedNo), false);
|
||||
validateTurnActionPromises([satisfied, satisfiedNo].map((row, index) => ({ ...row, event: `event:${index}` })), ['dialer', 'asker']);
|
||||
});
|
||||
|
||||
void test('a closed missing dial stays absent and an unsatisfied Yes releases only at actual turn expiry', () => {
|
||||
const [closedYes] = closeTurnActionStormDial([promise(dial(3))], 'dialer', 2, source());
|
||||
assert.equal(closedYes.stormDial?.actual, null);
|
||||
assert.equal(closedYes.fulfilled, false);
|
||||
assert.deepEqual(branches(closedYes), []);
|
||||
const [expired] = finishTurnActionTurn([closedYes], 'dialer', 2);
|
||||
assert.equal(expired.released, true);
|
||||
const [closedNo] = closeTurnActionStormDial([promise(dial(3), false)], 'dialer', 2, source());
|
||||
assert.equal(closedNo.fulfilled, true);
|
||||
assert.equal(closedNo.stormDial?.actual, null);
|
||||
});
|
||||
|
||||
void test('stored stages bind original leaves to the respondent and preserve legacy promises', () => {
|
||||
const original = promise(dial(2));
|
||||
validateTurnActionPromises([JSON.parse(JSON.stringify(original))], ['dialer', 'asker']);
|
||||
const wrong = { ...original, stormDial: { source: source(2, 'asker'), closed: false, actual: null } };
|
||||
assert.throws(() => validateTurnActionPromises([wrong], ['dialer', 'asker']), TurnActionError);
|
||||
assert.throws(() => validateTurnActionPromises([{ ...original, stormDial: undefined }], ['dialer', 'asker']), TurnActionError);
|
||||
assert.throws(() => recordTurnActionStormDial([original], 'asker', 2, source(), 2), TurnActionError);
|
||||
const legacy = promise({ kind: 'action', action: { kind: 'payment', recipient: 'other', minimum: 1 } });
|
||||
delete legacy.stormDial;
|
||||
validateTurnActionPromises([legacy], ['dialer', 'asker', 'other']);
|
||||
});
|
||||
Reference in new issue
Block a user