Prototype Truthtrance ally funding commitments
Verify and publish checkpoint container / container (push) Successful in 10m48s

This commit is contained in:
Quintine committed 2026-10-29 05:44:01 +11:00
1 parent 68643925e7
commit c414a03a19
14 files changed
+795 -4

No files matched your search

+5
View File
@@ -1335,6 +1335,7 @@ function policyActions(g: GameView): Action[] {
}
if (g.truthCharityGuide) return [g.truthCharityGuide.action];
if (g.bribePromiseGuide) return [g.bribePromiseGuide.action];
if (g.pledgePromiseGuide) return [g.pledgePromiseGuide.action];
if (g.alliancePromiseGuide) return [g.alliancePromiseGuide.action];
if (g.revivalExpressionPromiseGuide) return [g.revivalExpressionPromiseGuide.action];
if (g.leaderRevivalPromiseGuide) return [g.leaderRevivalPromiseGuide.action];
@@ -1392,6 +1393,10 @@ function policyActions(g: GameView): Action[] {
const answer = g.truthBribeAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'pledgeAid') {
const answer = g.truthPledgeAnswers?.[0];
return answer ? [{type: 'truthAnswer', answer}] : [];
}
if (w.question.kind === 'revival') {
const answer = g.truthRevivalAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
+114
View File
@@ -18,6 +18,10 @@ import { createVictoryContinuation, validateVictoryContinuation, voteVictoryCont
import { SabotageGiftError, quoteSabotageGift, quoteSabotageGiftDiscard } from './sabotage-gift';
import { mobileStrongholdCollectionCapacity } from './mobile-stronghold-collection';
import { basicKullTechRoster, kullDefaultEligible } from './kull-default-profile';
import { PledgePromiseError, livePledgePromises, pledgePromiseRequirements, recordPromisedPledge,
reconcilePledgePromises as reconcilePledgeRows, validatePledgePromises, pledgeQuestionHistoryBlocked,
type PledgeClaim, type PledgePromise } from './pledge-promises';
import { nativePromiseScopeSupported } from './native-promise-scope';
import { advancedKullLobbyEligible } from './kull-advanced-profile';
import { SaphoTruthOrderError, quoteSaphoTruthOrder, reorderSaphoTruthOrder, validateSaphoTruthOrder } from './sapho-truth-order';
import { createSaphoArrivalOrder, validateSaphoArrivalOrder, quoteSaphoArrivalOrder,
@@ -2420,6 +2424,7 @@ export type Game = {
/** Shared accepted order for typed force and named leader answers. */
revivalPromiseSequence?: string[];
bribePromises?: BribePromise[];
pledgePromises?: PledgePromise[];
bribeExpressionPromises?: BribeExpressionPromise[];
/** Public original mixed questions; native residuals own physical payment rules. */
mixedBribeQuestions?: (MixedBribeClaim & {
@@ -16344,6 +16349,97 @@ function pledgeAid(g: Game, p: Player, requested: unknown) {
g.aid[p.id] = { recipient: p.ally, amount };
log(g, `${p.name} updated the spice available to their ally this phase.`);
}
function pledgeRule<T>(run: () => T): T {
try { return run(); }
catch (error) { if (error instanceof PledgePromiseError) throw new RuleError(error.message); throw error; }
}
function pledgeIntegrity(g: Game) {
pledgeRule(() => validatePledgePromises(g.pledgePromises ?? [], g.players.map(player => player.id)));
}
/** The first version excludes simultaneous independently funded obligations.
* These rows are public timing facts; no opponent wallet or card is inspected. */
function otherPledgeCommitments(g: Game): boolean {
const rows: unknown[] = [
...(g.shipmentPromises ?? []), ...(g.bidPromises ?? []), ...(g.phaseBidPromises ?? []),
...(g.richeseBidPromises ?? []), ...(g.earlyRicheseBidPromises ?? []),
...(g.revivalPromises ?? []), ...(g.leaderRevivalPromises ?? []), ...(g.revivalExpressionPromises ?? []),
...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? []),
...(g.alliancePromises ?? []), ...(g.allianceExpressionPromises ?? []),
...(g.movementPromises ?? []), ...(g.movementExpressionPromises ?? []),
...(g.travelPromises ?? []), ...(g.turnActionPromises ?? []), ...(g.charityPromises ?? []),
...(g.battle?.truthPromises ?? []), ...(g.futureBattleLeaderPromises ?? []),
];
return rows.some(value => {
if (!value || typeof value !== 'object') return true;
const row = value as {turn?: number; fulfilled?: boolean; released?: boolean; status?: string};
return (row.turn === undefined || row.turn === g.turn) && !row.fulfilled && !row.released &&
(row.status === undefined || row.status === 'waiting' || row.status === 'bound');
});
}
function pledgePublicWindow(g: Game): boolean {
return g.status === 'playing' && !g.advanced && (g.phase === 3 || g.phase === 5) &&
nativePromiseScopeSupported(g) && !g.kullPreview && !g.semutaPreview && !g.nexusKullPreview &&
!g.coalitionPreview && !g.coalitionState &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard &&
!g.pendingKull && !g.pendingArrivalOverlap && !g.pendingArrivalReaction?.length;
}
function pledgeQuestionWindow(g: Game): boolean {
return pledgePublicWindow(g) && !otherPledgeCommitments(g);
}
function pledgeMutualAlly(g: Game, player: string): string | null {
const owner = g.players.find(seat => seat.id === player), ally = owner?.ally;
return ally && g.players.some(seat => seat.id === ally && seat.ally === player) ? ally : null;
}
/** Ask the native pledge routine on a detached state, retaining its real floor,
* locked contributions and funding rules. This is not an automatic payment. */
function pledgeCompletion(g: Game, p: Player, promises = g.pledgePromises ?? []): Action | null {
const ally = pledgeMutualAlly(g, p.id);
if (!ally || (g.phase !== 3 && g.phase !== 5)) return null;
const required = pledgeRule(() => pledgePromiseRequirements(promises, p.id, g.turn, g.phase, ally));
if (!required) return null;
if (!required.minimum) return {type: 'pledgeAid', amount: 0};
const current = g.aid[p.id]?.amount ?? 0;
const silent = g.richeseAuction?.method === 'silent' && !g.richeseAuction.outcome &&
Object.hasOwn(g.richeseAuction.sealed, ally);
const amount = Math.max(required.minimum, committedOutgoingAid(g, p),
richeseAllyCommitment(g.richeseAuction, g.richeseFunding ?? {}, p.id), silent ? current : 0);
if (required.maximum !== undefined && amount > required.maximum) return null;
try {
const trial = structuredClone(g);
pledgeAid(trial, getPlayer(trial, p.id), amount);
return {type: 'pledgeAid', amount};
} catch (error) { if (error instanceof RuleError) return null; throw error; }
}
function pledgeTruthAnswers(g: Game, p: Player, claim: PledgeClaim): ('yes' | 'no')[] {
if (!pledgeQuestionWindow(g) || pledgeQuestionHistoryBlocked(g.truthHistory ?? [], g.turn, g.phase) ||
claim.turn !== g.turn || claim.phase !== g.phase ||
pledgeMutualAlly(g, p.id) !== claim.ally ||
(g.pledgePromises ?? []).some(row => row.turn === g.turn && row.phase === g.phase &&
!row.fulfilled && !row.released)) return [];
return (['yes', 'no'] as const).filter(answer => {
const rows = [...(g.pledgePromises ?? []), {...claim, event: 'candidate', player: p.id,
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes'}];
return !!pledgeCompletion(g, p, rows);
});
}
function bindPledgeTruth(g: Game, p: Player, claim: PledgeClaim, answer: boolean, asker: string) {
(g.pledgePromises ??= []).push({...claim, event: crypto.randomUUID(), player: p.id, asker, answer});
}
function reconcilePledgeCommitments(g: Game, voluntary?: {actor: string; action: Action}) {
if (!g.pledgePromises?.length) return;
pledgeIntegrity(g);
g.pledgePromises = pledgeRule(() => reconcilePledgeRows(g.pledgePromises!, {
turn: g.turn, phase: g.phase, mutualAlly: player => pledgeMutualAlly(g, player),
canFulfill: promise => !!pledgeCompletion(g, getPlayer(g, promise.player)),
...(voluntary ? {voluntaryActor: voluntary.actor} : {}),
}));
}
function projectedPledgeGuide(g: Game, player: Player): {action: Action} | null {
if (!pledgeQuestionWindow(g) || g.truthtrance || !livePledgePromises(g.pledgePromises ?? [], player.id, g.turn, g.phase)
.some(row => row.answer)) return null;
const action = pledgeCompletion(g, player);
return action ? {action} : null;
}
function alliancePromiseRule<T>(calculate: () => T): T {
try { return calculate(); }
catch (error) { if (error instanceof AlliancePromiseError || error instanceof AllianceExpressionPromiseError) throw new RuleError(error.message); throw error; }
@@ -41782,6 +41878,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
'Finish or cancel the faction draft before changing the lobby.',
);
victoryContinuationIntegrity(state);
pledgeIntegrity(state);
bidPromiseIntegrity(state);
phaseBidIntegrity(state);
richeseBidIntegrity(state);
@@ -42110,6 +42207,8 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
// A final counter pass settles the opposing claimant's effect; it does not
// turn that forced debit into the passer's voluntary payment or expenditure.
const voluntary = forcedThumperDraw || action.type === 'passResponse' ? undefined : { actor: id, action };
const pledgeVoluntary = initiator === id ? voluntary : undefined;
reconcilePledgeCommitments(g, pledgeVoluntary);
reconcileTravelPromises(g, promiseActor, action, state.phase);
reconcileBidPromises(g, voluntary);
reconcilePhaseBids(g, voluntary);
@@ -42248,6 +42347,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
reconcileCharityTruth(g, { actor: id, action });
reconcileRevivalPromises(g, { actor: id, action });
reconcileBribePromises(g, { actor: id, action });
reconcilePledgeCommitments(g, pledgeVoluntary);
reconcilePublicDealTruth(g, voluntary, options?.insolventDealPayment ? undefined : state);
reconcileAlliancePromises(g, { actor: id, action });
settleAutomaticContinuations(g);
@@ -42487,6 +42587,7 @@ function settleAutomaticContinuations(g: Game) {
reconcileCharityTruth(g);
reconcileRevivalPromises(g);
reconcileBribePromises(g);
reconcilePledgeCommitments(g);
reconcilePublicDealTruth(g);
reconcileGroundMovementPromises(g);
reconcileAlliancePromises(g);
@@ -42507,6 +42608,7 @@ export function normalizeAutomaticGame(state: Game): Game {
factionSwapIntegrity(state);
publicDealIntegrity(state);
victoryContinuationIntegrity(state);
pledgeIntegrity(state);
if (state.victoryContinuation) return state;
bidPromiseIntegrity(state);
phaseBidIntegrity(state);
@@ -42605,6 +42707,7 @@ export function normalizeAutomaticGame(state: Game): Game {
reconcileCharityTruth(g);
reconcileRevivalPromises(g);
reconcileBribePromises(g);
reconcilePledgeCommitments(g);
reconcilePublicDealTruth(g);
reconcileGroundMovementPromises(g);
reconcileAlliancePromises(g);
@@ -42871,6 +42974,8 @@ function applyActionInner(
bindAlliance: bindAllianceTruth,
bribeAnswers: bribeTruthAnswers,
bindBribe: bindBribeTruth,
pledgeAnswers: pledgeTruthAnswers,
bindPledge: bindPledgeTruth,
bribeExpressionAnswers: bribeExpressionTruthAnswers,
bindBribeExpression: bindBribeExpressionTruth,
mixedBribeAnswers: mixedBribeTruthAnswers,
@@ -45604,6 +45709,8 @@ function applyActionInner(
'You may fund an ally during bidding/shipment, or as advanced CHOAM during Battle.',
);
pledgeAid(g, p, action.amount);
if (g.pledgePromises?.length) g.pledgePromises = pledgeRule(() => recordPromisedPledge(
g.pledgePromises!, p.id, g.turn, g.phase, {ally: p.ally!, amount: action.amount as number}));
return g;
}
if (t === 'richeseBid') {
@@ -47015,6 +47122,7 @@ export function viewGame(state: Game, id: string, now?: number) {
factionSwapIntegrity(state);
publicDealIntegrity(state);
victoryContinuationIntegrity(state);
pledgeIntegrity(state);
revivalOrderIntegrity(state);
charityOrderIntegrity(state);
state = migrateAtomicsTargets(state);
@@ -47266,6 +47374,12 @@ export function viewGame(state: Game, id: string, now?: number) {
alliancePromises: projectedAlliancePromises(g, id),
alliancePromiseGuide: projectedAlliancePromiseGuide(g, id),
truthBribeTargets: bribeQuestionWindow(g) ? g.players.map(player => player.id) : [],
truthPledgeTargets: pledgePublicWindow(g) && !pledgeQuestionHistoryBlocked(g.truthHistory ?? [], g.turn, g.phase)
? g.players.filter(player => !!pledgeMutualAlly(g, player.id)).map(player => player.id) : [],
truthPledgeAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'pledgeAid' ? pledgeTruthAnswers(g, me, g.truthtrance.question) : null,
pledgePromises: (g.pledgePromises ?? []).filter(row => row.player === id).map(row => ({...row})),
pledgePromiseGuide: projectedPledgeGuide(g, me),
truthBribeAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'bribe' ? bribeTruthAnswers(g, me, g.truthtrance.question) : null,
bribePromises: (g.bribePromises ?? []).filter(promise => promise.player === id).map(promise => ({ ...promise })),
+146
View File
@@ -0,0 +1,146 @@
/** A future accepted update to the amount made available to an existing ally.
* This is not an actual expenditure or a promise that the ally will use it. */
export type PledgeClaim = { turn: number; phase: 3 | 5; ally: string; minimum: number };
export type PledgePromise = PledgeClaim & {
event: string;
player: string;
asker: string;
answer: boolean;
fulfilled?: boolean;
released?: boolean;
};
export type PledgeRequirement = { ally: string; minimum: number; maximum?: number };
export type PledgeUpdate = { ally: string; amount: number };
/** Only public question history determines whether a new question is offered.
* Do not reveal whether a private compiled residual is still live. */
export function pledgeQuestionHistoryBlocked(history: readonly {
turn: number; phase: number; answer: string; question: { kind: string; scope?: string };
}[], turn: number, phase: number): boolean {
return history.some(record => record.turn === turn && record.answer !== 'unknown' &&
(record.question.kind === 'pledgeAid' ? record.phase === phase :
record.question.kind !== 'fact' && !(record.question.kind === 'freeform' && record.question.scope === 'fact')));
}
export class PledgePromiseError extends Error {
constructor(message: string) {
super(message);
this.name = 'PledgePromiseError';
}
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value === value.trim();
const positiveInteger = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
const nonnegativeInteger = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
const optionalBoolean = (value: unknown): boolean => value === undefined || typeof value === 'boolean';
export function parsePledgeClaim(value: unknown): PledgeClaim {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new PledgePromiseError('Choose a current-phase ally-funding question.');
const input = value as Record<string, unknown>;
if (!positiveInteger(input.turn) || (input.phase !== 3 && input.phase !== 5) ||
!identifier(input.ally) || !positiveInteger(input.minimum))
throw new PledgePromiseError('Choose the current bidding or movement phase, your ally, and a positive safe whole funding minimum.');
return { turn: input.turn, phase: input.phase, ally: input.ally, minimum: input.minimum };
}
/** Validates the saved public answer and participant identities without reading
* another player's hand, wallet, pledged amount or private commitments. */
export function validatePledgePromises(promises: readonly PledgePromise[], players: readonly string[]): void {
if (!Array.isArray(promises) || !Array.isArray(players))
throw new PledgePromiseError('The ally-funding commitment record is invalid.');
const events = new Set<string>();
for (const promise of promises) {
parsePledgeClaim(promise);
if (!identifier(promise.event) || events.has(promise.event) ||
!identifier(promise.player) || !identifier(promise.asker) ||
!players.includes(promise.player) || !players.includes(promise.asker) || !players.includes(promise.ally) ||
promise.player === promise.asker || promise.player === promise.ally ||
typeof promise.answer !== 'boolean' || !optionalBoolean(promise.fulfilled) ||
!optionalBoolean(promise.released) || promise.fulfilled && promise.released ||
!promise.answer && promise.released)
throw new PledgePromiseError('The ally-funding commitment lost its event, participants or answer.');
events.add(promise.event);
}
}
export function livePledgePromises(promises: readonly PledgePromise[], player: string,
turn: number, phase: number): PledgePromise[] {
return promises.filter(promise => promise.player === player && promise.turn === turn &&
promise.phase === phase && !promise.fulfilled && !promise.released);
}
/** Outstanding Yes answers can share one future update; each No supplies an
* exclusive upper bound. Existing escrow never fulfills a newly asked question. */
export function pledgePromiseRequirements(promises: readonly PledgePromise[], player: string,
turn: number, phase: number, ally: string): PledgeRequirement | null {
if (!identifier(ally)) throw new PledgePromiseError('Choose a current mutual ally.');
let minimum = 0, maximum: number | undefined;
for (const promise of livePledgePromises(promises, player, turn, phase)) {
parsePledgeClaim(promise);
if (promise.ally !== ally) return null;
if (promise.answer) minimum = Math.max(minimum, promise.minimum);
else maximum = Math.min(maximum ?? Number.MAX_SAFE_INTEGER, promise.minimum - 1);
}
if (maximum !== undefined && minimum > maximum) return null;
return { ally, minimum, ...(maximum === undefined ? {} : { maximum }) };
}
/** Apply only after the original native pledge action has passed its legal
* quote. Same-value explicit updates count; unrelated earlier escrow does not.
* A rejected No leaves all original records unchanged. */
export function recordPromisedPledge(promises: readonly PledgePromise[], player: string,
turn: number, phase: number, update: PledgeUpdate): PledgePromise[] {
if (!update || typeof update !== 'object' || Array.isArray(update) ||
!identifier(update.ally) || !nonnegativeInteger(update.amount))
throw new PledgePromiseError('Retain the actual ally and nonnegative whole funding update.');
const live = livePledgePromises(promises, player, turn, phase);
for (const promise of live) {
parsePledgeClaim(promise);
if (promise.ally === update.ally && !promise.answer && update.amount >= promise.minimum)
throw new PledgePromiseError('This funding update conflicts with your Truthtrance No answer.');
}
return promises.map(promise => live.includes(promise) && promise.ally === update.ally &&
promise.answer && update.amount >= promise.minimum ? { ...promise, fulfilled: true } : promise);
}
export type PledgeReconciliation = {
turn: number;
phase: number;
/** Must return an ally only for a current reciprocal partnership. */
mutualAlly: (player: string) => string | null;
/** A conservative native future-completion quote, not a secret opponent
* choice or a requirement that an interruptible action be legal right now. */
canFulfill: (promise: PledgePromise, requirement: PledgeRequirement) => boolean;
/** The player whose voluntary accepted action led to this state, if any. */
voluntaryActor?: string;
};
/** No remains binding through the named phase and reciprocal alliance. An
* unfulfilled Yes is released only if an outside action or natural transition
* makes completion impossible; its owner cannot voluntarily create that loss.
* The engine owns the legal quote and must avoid speculative hidden funds. */
export function reconcilePledgePromises(promises: readonly PledgePromise[], context: PledgeReconciliation): PledgePromise[] {
if (!Array.isArray(promises) || !context || !positiveInteger(context.turn) ||
!Number.isSafeInteger(context.phase) || typeof context.mutualAlly !== 'function' ||
typeof context.canFulfill !== 'function' ||
context.voluntaryActor !== undefined && !identifier(context.voluntaryActor))
throw new PledgePromiseError('Retain the current ally-funding reconciliation context.');
return promises.map(promise => {
if (promise.fulfilled || promise.released) return promise;
parsePledgeClaim(promise);
const inScope = promise.turn === context.turn && promise.phase === context.phase &&
context.mutualAlly(promise.player) === promise.ally;
if (!promise.answer) return inScope ? promise : { ...promise, fulfilled: true };
const requirement = inScope
? pledgePromiseRequirements(promises, promise.player, context.turn, context.phase, promise.ally)
: null;
if (inScope && requirement && context.canFulfill(promise, requirement)) return promise;
if (context.voluntaryActor === promise.player)
throw new PledgePromiseError('You cannot voluntarily evade your Truthtrance ally-funding answer.');
return { ...promise, released: true };
});
}
+1
View File
@@ -3992,6 +3992,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
'In a new freeform game-fact question, exact personal-spice, named-card, total-hand-size and selected weapon, defense or Worthless category forms are checked as current facts. Category questions use the displayed printed primary role. Two spice or named-card facts may be joined by one AND or OR; only their aggregate answer is published. The recognized interpretation appears before asking and with the question. Other wording and current-turn promises remain player-managed; bots answer unknown. Already-pending older questions keep their original behavior.',
'In supported quiet Basic Bidding or Shipment and Movement, ask whether a player will update funding for their current mutual ally to at least a chosen amount this phase. Yes requires a future accepted update while possible; No forbids reaching the amount. An existing pledge alone does not count, a repeated same total does, and the ally chooses whether to spend it. Only one definite funding question is supported per phase; other future-action questions in that phase and optional modules remain separate.',
'For current spice, choose Exactly, At least or At most and a nonnegative whole amount. This counts spice personally held when answering, excluding separately pledged aid and incoming payments. It does not ask whether a particular action is affordable or promise that the balance will stay unchanged. A true current-spice answer does not prevent later legal spending.',
'For a concealed No-Field, choose its current printed value0,3 or5, not the prospective force pool. A missing or already revealed marker answers No, including for0; a concealed5 with empty reserves still answers Yes to5. Only the respondent sees the expected aggregate before public reply. Neither question nor answer turns up the physical token or commits later use; current-force facts continue to exclude markers.',
'The questioned player answers publicly. A definite answer discards the card. If the answer cannot be known, the holder may ask a different question or save Truthtrance for later.',
+29 -1
View File
@@ -23,6 +23,7 @@ import { parseRevivalExpressionClaim, revivalExpressionText, revivalExpressionPr
import { parseLeaderRevivalClaim, leaderRevivalClaimText, leaderRevivalPromiseModeSupported, type LeaderRevivalClaim } from './leader-revival-promises';
import { parseAllianceClaim, allianceClaimText, alliancePromiseModeSupported, type AllianceClaim } from './alliance-promises';
import { parseBribeClaim, bribeClaimText, bribePromiseModeSupported, type BribeClaim } from './bribe-promises';
import { parsePledgeClaim, type PledgeClaim } from './pledge-promises';
import { parseRevivalClaim, revivalClaimText, revivalPromiseModeSupported, type RevivalClaim } from './revival-promises';
import { parseMovementClaim, movementClaimText, movementPromiseTargetAvailable, MovementClaimError, type MovementClaim } from './movement-promises';
import { shipmentAvailable } from './shipment-opportunity';
@@ -112,6 +113,7 @@ export type TruthQuestion =
| ({ kind: 'leaderRevival'; target: string } & LeaderRevivalClaim)
| ({ kind: 'alliance'; target: string } & AllianceClaim)
| ({ kind: 'bribe'; target: string } & BribeClaim)
| ({ kind: 'pledgeAid'; target: string } & PledgeClaim)
| ({ kind: 'revival'; target: string } & RevivalClaim)
| ({ kind: 'bid'; target: string } & BidClaim)
| ({ kind: 'phaseBid'; target: string } & PhaseBidClaim)
@@ -279,6 +281,10 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
);
// A joint planner for Charity and other future-action questions is not yet
// available. Do not bind incompatible action obligations in the interim.
if (v.kind !== 'pledgeAid' && v.kind !== 'fact' && !(v.kind === 'freeform' && v.scope === 'fact') &&
g.truthHistory?.some(row => row.turn === g.turn && row.phase === g.phase && row.answer !== 'unknown' &&
row.question.kind === 'pledgeAid'))
throw new TruthError('Other future-action questions are unavailable in a phase with an ally-funding answer.');
if (v.kind !== 'charity' && v.kind !== 'fact' && v.kind !== 'freeform' &&
g.charityPromises?.length && liveCharityPromises(g.charityPromises,v.target as string,g.turn).length)
throw new TruthError('Finish this player’s current Charity answer before binding another action question.');
@@ -369,6 +375,16 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
'Choose current-turn ordinary bribe statements to non-allied participants in a supported table.');
return { kind: 'bribeExpression', target: v.target, ...claim };
}
if (v.kind === 'pledgeAid') {
check(Object.keys(v).sort().join(',') === 'ally,kind,minimum,phase,target,turn',
'Supply the current phase, respondent, ally and funding minimum.');
const claim = parsePledgeClaim(v), respondent = g.players.find(player => player.id === v.target)!;
check(g.status === 'playing' && !g.advanced && claim.turn === g.turn && claim.phase === g.phase &&
respondent.ally === claim.ally && g.players.some(player => player.id === claim.ally && player.ally === respondent.id) &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama,
'Choose a current mutual ally in a supported quiet Basic Bidding or Shipment and Movement window.');
return {kind: 'pledgeAid', target: v.target, ...claim};
}
if (v.kind === 'bribe') {
check(Object.keys(v).sort().join(',') === 'kind,minimum,recipient,target,turn',
'Supply the current turn, named bribe recipient, minimum and respondent.');
@@ -711,7 +727,8 @@ export function validateSavedTruthtrance(g: Game) {
}
if (window?.question?.kind === 'fact' || window?.question?.kind === 'movement' ||
window?.question?.kind === 'freeform' && window.question.interpreted ||
window?.question?.kind === 'traitorCall' || window?.question?.kind === 'futureBattleLeader') {
window?.question?.kind === 'traitorCall' || window?.question?.kind === 'futureBattleLeader' ||
window?.question?.kind === 'pledgeAid') {
check((window.stage === 'answer' || window.stage === 'unknown') && window.queue.length > 0,
'The saved fact question must retain its asking player and answer stage.');
check(JSON.stringify(parseQuestion(g, window.queue[0].player, window.question)) === JSON.stringify(window.question),
@@ -825,6 +842,7 @@ export function truthQuestionText(
if (q.kind === 'alliance') return `Will you ${allianceClaimText(q, playerName)}?`;
if (q.kind === 'bribeExpression') return `Will you ${bribeExpressionText(q.claim, playerName)} from now until this turn ends?`;
if (q.kind === 'bribe') return `Will you ${bribeClaimText(q, playerName)}?`;
if (q.kind === 'pledgeAid') return `Will you update funding available to ${playerName(q.ally)} to at least ${q.minimum} spice during this phase?`;
if (q.kind === 'revival') return `Will you ${revivalClaimText(q)}?`;
if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`;
if (q.kind === 'phaseBid') return `Will you ${phaseBidClaimText(q)}?`;
@@ -955,6 +973,8 @@ export function resolveTruthAction(
bindBribeExpression?: (g: Game, p: Player, claim: BribeExpressionClaim, answer: boolean, asker: string) => void;
bribeAnswers?: (g: Game, p: Player, claim: BribeClaim) => TruthAnswer[];
bindBribe?: (g: Game, p: Player, claim: BribeClaim, answer: boolean, asker: string) => void;
pledgeAnswers?: (g: Game, p: Player, claim: PledgeClaim) => TruthAnswer[];
bindPledge?: (g: Game, p: Player, claim: PledgeClaim, answer: boolean, asker: string) => void;
mixedRevivalAnswers?: (g: Game, p: Player, claim: MixedRevivalClaim) => TruthAnswer[];
bindMixedRevival?: (g: Game, p: Player, claim: MixedRevivalClaim, answer: boolean, asker: string) => void;
revivalExpressionAnswers?: (g: Game, p: Player, claim: RevivalExpressionClaim) => TruthAnswer[];
@@ -1161,6 +1181,9 @@ export function resolveTruthAction(
if (w.question.kind === 'bribeExpression')
check(effects.bribeExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a whole bribe expression compatible with earlier answers.');
if (w.question.kind === 'pledgeAid')
check(effects.pledgeAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a supported ally-funding question compatible with current commitments.');
if (w.question.kind === 'bribe')
check(effects.bribeAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a current bribe question compatible with earlier answers.');
@@ -1295,6 +1318,11 @@ export function resolveTruthAction(
'Choose a whole bribe answer consistent with actual funding and earlier promises.');
if (a.answer !== 'unknown') effects.bindBribeExpression(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'pledgeAid') {
check(effects.pledgeAnswers && effects.bindPledge && effects.pledgeAnswers(g, p, q).includes(a.answer),
'Choose an answer consistent with current ally-funding legality.');
if (a.answer !== 'unknown') effects.bindPledge(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'bribe') {
check(effects.bribeAnswers && effects.bindBribe && effects.bribeAnswers(g, p, q).includes(a.answer),
'Choose an answer consistent with actual ordinary bribe funding and earlier promises.');