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
+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 :
|
||||
|
||||
Reference in new issue
Block a user