Support whole AND/OR ordinary bribe commitments
This commit is contained in:
1 parent
c81e00e6dd
commit
0e3c155876
21 files changed
+1778
-111
No files matched your search
@@ -1331,6 +1331,10 @@ function policyActions(g: GameView): Action[] {
|
||||
const answer = g.truthAllianceAnswers?.find(answer => answer !== 'unknown') ?? g.truthAllianceAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'bribeExpression') {
|
||||
const answer = g.truthBribeExpressionAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'bribe') {
|
||||
const answer = g.truthBribeAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { bribePromiseRequirements, liveBribePromises, type BribePromise } from './bribe-promises';
|
||||
|
||||
export { bribePromiseModeSupported as bribeExpressionPromiseModeSupported } from './bribe-promises';
|
||||
|
||||
export type BribeExpression = { kind: 'payment'; recipient: string; minimum: number } |
|
||||
{ kind: 'and' | 'or'; terms: BribeExpression[] };
|
||||
export type BribeExpressionClaim = { turn: number; claim: BribeExpression };
|
||||
export type BribeExpressionPayment = { recipient: string; amount: number };
|
||||
export type BribeExpressionProgress = BribeExpressionPayment[];
|
||||
export type BribeExpressionPromise = BribeExpressionClaim & {
|
||||
event: string; player: string; asker: string; answer: boolean;
|
||||
/** Matching actual ordinary payments after this answer, excluding their later receipt. */
|
||||
progress: BribeExpressionProgress;
|
||||
fulfilled?: boolean; released?: boolean; order?: number;
|
||||
};
|
||||
export type BribeExpressionBranchLeaf = {
|
||||
recipient: string; minimum: number; progress: number; answer: boolean;
|
||||
};
|
||||
|
||||
export const BRIBE_EXPRESSION_MAX_DEPTH = 4;
|
||||
export const BRIBE_EXPRESSION_MAX_LEAVES = 16;
|
||||
|
||||
export class BribeExpressionPromiseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'BribeExpressionPromiseError';
|
||||
}
|
||||
}
|
||||
|
||||
function check(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new BribeExpressionPromiseError(message);
|
||||
}
|
||||
function object(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||
const whole = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
const positive = (value: unknown): value is number => whole(value) && value > 0;
|
||||
const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean';
|
||||
const keys = (value: Record<string, unknown>, expected: string) => Object.keys(value).sort().join(',') === expected;
|
||||
|
||||
/** Software limits match revival expressions; spice minima have no artificial cap.
|
||||
* Every subtree is parsed before an answer can short-circuit evaluation. */
|
||||
export function parseBribeExpression(value: unknown): BribeExpression {
|
||||
let leaves = 0;
|
||||
const parse = (input: unknown, depth: number): BribeExpression => {
|
||||
check(object(input) && depth <= BRIBE_EXPRESSION_MAX_DEPTH,
|
||||
'Use an ordinary bribe expression within four nested groups.');
|
||||
if (input.kind === 'and' || input.kind === 'or') {
|
||||
check(keys(input, 'kind,terms') && Array.isArray(input.terms) && input.terms.length >= 2 &&
|
||||
input.terms.length <= BRIBE_EXPRESSION_MAX_LEAVES,
|
||||
'Join two or more ordinary bribe statements with AND or OR.');
|
||||
return { kind: input.kind, terms: input.terms.map(term => parse(term, depth + 1)) };
|
||||
}
|
||||
check(++leaves <= BRIBE_EXPRESSION_MAX_LEAVES,
|
||||
'Use at most sixteen ordinary bribe statements.');
|
||||
check(input.kind === 'payment' && keys(input, 'kind,minimum,recipient') &&
|
||||
identifier(input.recipient) && positive(input.minimum),
|
||||
'Choose an exact ordinary bribe recipient and a positive safe whole spice minimum.');
|
||||
return { kind: 'payment', recipient: input.recipient, minimum: input.minimum };
|
||||
};
|
||||
return parse(value, 0);
|
||||
}
|
||||
|
||||
export function parseBribeExpressionClaim(value: unknown): BribeExpressionClaim {
|
||||
check(object(value) && keys(value, 'claim,turn') && positive(value.turn),
|
||||
'Choose the current turn and an ordinary bribe expression.');
|
||||
return { turn: value.turn, claim: parseBribeExpression(value.claim) };
|
||||
}
|
||||
|
||||
export function bribeExpressionText(expression: BribeExpression,
|
||||
recipientName: (id: string) => string = id => id): string {
|
||||
const text = (node: BribeExpression): string => 'terms' in node ?
|
||||
`(${node.terms.map(text).join(node.kind === 'and' ? ' AND ' : ' OR ')})` :
|
||||
`pay at least ${node.minimum} spice to ${recipientName(node.recipient)} in ordinary bribes`;
|
||||
return text(parseBribeExpression(expression));
|
||||
}
|
||||
|
||||
export function bribeExpressionRecipients(expression: BribeExpression): string[] {
|
||||
const recipients = new Set<string>();
|
||||
const visit = (node: BribeExpression): void => {
|
||||
if ('terms' in node) node.terms.forEach(visit);
|
||||
else recipients.add(node.recipient);
|
||||
};
|
||||
visit(parseBribeExpression(expression));
|
||||
return [...recipients];
|
||||
}
|
||||
|
||||
function validateProgress(value: unknown): asserts value is BribeExpressionProgress {
|
||||
check(Array.isArray(value), 'Retain cumulative ordinary bribe payments by exact recipient.');
|
||||
const recipients = new Set<string>();
|
||||
for (const payment of value) {
|
||||
check(object(payment) && keys(payment, 'amount,recipient') && identifier(payment.recipient) &&
|
||||
whole(payment.amount) && !recipients.has(payment.recipient),
|
||||
'Retain unique recipients and safe whole cumulative ordinary bribe amounts.');
|
||||
recipients.add(payment.recipient);
|
||||
}
|
||||
}
|
||||
|
||||
function evaluate(expression: BribeExpression, progress: readonly BribeExpressionPayment[]): boolean {
|
||||
if ('terms' in expression) return expression.kind === 'and' ?
|
||||
expression.terms.every(term => evaluate(term, progress)) : expression.terms.some(term => evaluate(term, progress));
|
||||
return (progress.find(payment => payment.recipient === expression.recipient)?.amount ?? 0) >= expression.minimum;
|
||||
}
|
||||
|
||||
export function evaluateBribeExpression(expression: BribeExpression, progress: readonly BribeExpressionPayment[]): boolean {
|
||||
const parsed = parseBribeExpression(expression);
|
||||
validateProgress(progress);
|
||||
return evaluate(parsed, progress);
|
||||
}
|
||||
|
||||
export function liveBribeExpressionPromises(promises: readonly BribeExpressionPromise[], player: string,
|
||||
turn: number): BribeExpressionPromise[] {
|
||||
return promises.filter(promise => promise.player === player && promise.turn === turn &&
|
||||
!promise.fulfilled && !promise.released);
|
||||
}
|
||||
|
||||
function readPromise(promise: BribeExpressionPromise): BribeExpression {
|
||||
check(object(promise), 'The ordinary bribe expression commitment record is invalid.');
|
||||
const { claim } = parseBribeExpressionClaim({ turn: promise.turn, claim: promise.claim });
|
||||
validateProgress(promise.progress);
|
||||
const recipients = bribeExpressionRecipients(claim);
|
||||
check(typeof promise.answer === 'boolean' && optionalBoolean(promise.fulfilled) && optionalBoolean(promise.released) &&
|
||||
!(promise.fulfilled && promise.released) && (promise.order === undefined || positive(promise.order)) &&
|
||||
promise.progress.every(payment => recipients.includes(payment.recipient)),
|
||||
'Retain a definite whole bribe answer and its own matching future payment progress.');
|
||||
return claim;
|
||||
}
|
||||
|
||||
/** Only the completed ordinary payer action calls this function. Past payments,
|
||||
* gifts, special transfers and the later Mentat receipt never call it again. */
|
||||
export function recordBribeExpressionPayment(promises: readonly BribeExpressionPromise[], player: string,
|
||||
turn: number, payment: BribeExpressionPayment): BribeExpressionPromise[] {
|
||||
check(object(payment) && keys(payment, 'amount,recipient') && identifier(payment.recipient) && positive(payment.amount),
|
||||
'Retain the exact recipient and positive safe whole ordinary bribe payment.');
|
||||
const updates = new Map<BribeExpressionPromise, BribeExpressionPromise>();
|
||||
for (const promise of liveBribeExpressionPromises(promises, player, turn)) {
|
||||
const claim = readPromise(promise);
|
||||
if (!bribeExpressionRecipients(claim).includes(payment.recipient)) continue;
|
||||
const old = promise.progress.find(entry => entry.recipient === payment.recipient)?.amount ?? 0;
|
||||
const next = old + payment.amount;
|
||||
check(Number.isSafeInteger(next), 'The cumulative ordinary bribe amount exceeds the safe integer range.');
|
||||
const progress = promise.progress.map(entry => ({ ...entry }));
|
||||
const entry = progress.find(entry => entry.recipient === payment.recipient);
|
||||
if (entry) entry.amount = next;
|
||||
else progress.push({ recipient: payment.recipient, amount: next });
|
||||
const satisfied = evaluate(claim, progress);
|
||||
check(promise.answer || !satisfied,
|
||||
'This payment conflicts with your whole Truthtrance ordinary bribe No answer.');
|
||||
updates.set(promise, { ...promise, progress, ...(promise.answer && satisfied ? { fulfilled: true } : {}) });
|
||||
}
|
||||
return promises.map(promise => updates.get(promise) ?? promise);
|
||||
}
|
||||
|
||||
function mergeBranches(left: BribeExpressionBranchLeaf[], right: BribeExpressionBranchLeaf[]): BribeExpressionBranchLeaf[] | null {
|
||||
const merged = left.map(leaf => ({ ...leaf }));
|
||||
for (const leaf of right) {
|
||||
const same = merged.find(other => other.recipient === leaf.recipient && other.answer === leaf.answer);
|
||||
if (same) same.minimum = leaf.answer ? Math.max(same.minimum, leaf.minimum) : Math.min(same.minimum, leaf.minimum);
|
||||
else merged.push({ ...leaf });
|
||||
const yes = merged.find(other => other.recipient === leaf.recipient && other.answer);
|
||||
const no = merged.find(other => other.recipient === leaf.recipient && !other.answer);
|
||||
if (yes && no && yes.minimum >= no.minimum) return null;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Whole No uses De Morgan; a No to AND leaves alternatives. No route is stored,
|
||||
* and each leaf retains its own answer interval for later joint composition. */
|
||||
export function* bribeExpressionBranches(promise: BribeExpressionPromise): Generator<BribeExpressionBranchLeaf[]> {
|
||||
const expression = readPromise(promise);
|
||||
if (promise.fulfilled || promise.released) { yield []; return; }
|
||||
function* alternatives(node: BribeExpression, answer: boolean): Generator<BribeExpressionBranchLeaf[]> {
|
||||
if (node.kind === 'payment') {
|
||||
if (evaluate(node, promise.progress)) { if (answer) yield []; return; }
|
||||
yield [{ recipient: node.recipient, minimum: node.minimum,
|
||||
progress: promise.progress.find(entry => entry.recipient === node.recipient)?.amount ?? 0, answer }];
|
||||
return;
|
||||
}
|
||||
const conjunction = node.kind === 'and' ? answer : !answer;
|
||||
if (!conjunction) {
|
||||
for (const term of node.terms) yield* alternatives(term, answer);
|
||||
return;
|
||||
}
|
||||
const terms = node.terms;
|
||||
function* combine(index: number, prefix: BribeExpressionBranchLeaf[]): Generator<BribeExpressionBranchLeaf[]> {
|
||||
if (index === terms.length) { yield prefix; return; }
|
||||
for (const suffix of alternatives(terms[index], answer)) {
|
||||
const merged = mergeBranches(prefix, suffix);
|
||||
if (merged) yield* combine(index + 1, merged);
|
||||
}
|
||||
}
|
||||
yield* combine(0, []);
|
||||
}
|
||||
if (promise.answer && evaluate(expression, promise.progress)) { yield []; return; }
|
||||
yield* alternatives(expression, promise.answer);
|
||||
}
|
||||
|
||||
type Bounds = Map<string, { minimum: number; maximum: number }>;
|
||||
function addBound(bounds: Bounds, recipient: string, minimum: number, maximum: number): Bounds | null {
|
||||
const old = bounds.get(recipient) ?? { minimum: 0, maximum: Number.MAX_SAFE_INTEGER };
|
||||
const next = { minimum: Math.max(old.minimum, minimum), maximum: Math.min(old.maximum, maximum) };
|
||||
if (next.minimum > next.maximum) return null;
|
||||
const merged = new Map(bounds);
|
||||
merged.set(recipient, next);
|
||||
return merged;
|
||||
}
|
||||
function addLeaf(bounds: Bounds, leaf: BribeExpressionBranchLeaf): Bounds | null {
|
||||
return addBound(bounds, leaf.recipient, leaf.answer ? Math.max(0, leaf.minimum - leaf.progress) : 0,
|
||||
leaf.answer ? Number.MAX_SAFE_INTEGER - leaf.progress : leaf.minimum - leaf.progress - 1);
|
||||
}
|
||||
|
||||
/** Minimal additional payments per legal route across scalar and whole answers.
|
||||
* Same-recipient requirements share payments; distinct recipients spend separate
|
||||
* spice. The caller owns purse availability and actual ordinary payment timing. */
|
||||
export function* bribeCompletionCandidates(atomics: readonly BribePromise[], expressions: readonly BribeExpressionPromise[],
|
||||
player: string, turn: number): Generator<BribeExpressionPayment[]> {
|
||||
if (bribePromiseRequirements(atomics, player, turn) === null) return;
|
||||
let initial: Bounds | null = new Map();
|
||||
for (const promise of liveBribePromises(atomics, player, turn)) {
|
||||
initial = addLeaf(initial, promise);
|
||||
if (!initial) return;
|
||||
}
|
||||
const live = liveBribeExpressionPromises(expressions, player, turn);
|
||||
for (const promise of live) {
|
||||
readPromise(promise);
|
||||
// An allocation to another answer must also fit nonselected-branch ledgers.
|
||||
for (const payment of promise.progress) {
|
||||
initial = addBound(initial, payment.recipient, 0, Number.MAX_SAFE_INTEGER - payment.amount);
|
||||
if (!initial) return;
|
||||
}
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
function* combine(index: number, bounds: Bounds): Generator<BribeExpressionPayment[]> {
|
||||
if (index === live.length) {
|
||||
const payments: BribeExpressionPayment[] = [];
|
||||
let total = 0;
|
||||
for (const [recipient, { minimum }] of bounds) {
|
||||
total += minimum;
|
||||
if (!Number.isSafeInteger(total)) return;
|
||||
if (minimum > 0) payments.push({ recipient, amount: minimum });
|
||||
}
|
||||
const key = JSON.stringify([...payments].sort((left, right) => left.recipient.localeCompare(right.recipient)));
|
||||
if (!seen.has(key)) { seen.add(key); yield payments; }
|
||||
return;
|
||||
}
|
||||
for (const branch of bribeExpressionBranches(live[index])) {
|
||||
let next: Bounds | null = bounds;
|
||||
for (const leaf of branch) {
|
||||
next = addLeaf(next, leaf);
|
||||
if (!next) break;
|
||||
}
|
||||
if (next) yield* combine(index + 1, next);
|
||||
}
|
||||
}
|
||||
yield* combine(0, initial);
|
||||
}
|
||||
|
||||
export function validateBribeExpressionPromises(promises: readonly BribeExpressionPromise[], players: readonly string[]): void {
|
||||
check(Array.isArray(promises), 'The ordinary bribe expression commitment record is invalid.');
|
||||
const events = new Set<string>();
|
||||
for (const promise of promises) {
|
||||
const claim = readPromise(promise);
|
||||
const satisfied = evaluate(claim, promise.progress);
|
||||
check(identifier(promise.event) && !events.has(promise.event) && identifier(promise.player) && identifier(promise.asker) &&
|
||||
players.includes(promise.player) && players.includes(promise.asker) && promise.player !== promise.asker &&
|
||||
bribeExpressionRecipients(claim).every(recipient => players.includes(recipient) && recipient !== promise.player) &&
|
||||
(promise.answer || !satisfied) && (!promise.answer || promise.released || !!promise.fulfilled === satisfied),
|
||||
'The ordinary bribe expression lost its event, participants or whole-answer progress.');
|
||||
events.add(promise.event);
|
||||
}
|
||||
}
|
||||
+157
-107
@@ -1,3 +1,6 @@
|
||||
import { BribeExpressionPromiseError, bribeExpressionRecipients, bribeCompletionCandidates,
|
||||
liveBribeExpressionPromises, recordBribeExpressionPayment, validateBribeExpressionPromises,
|
||||
type BribeExpressionClaim, type BribeExpressionPromise } from './bribe-expression-promises';
|
||||
import { RevivalExpressionPromiseError, liveRevivalExpressionPromises, revivalExpressionBranches,
|
||||
evaluateRevivalExpression, recordRevivalExpressionReturn, validateRevivalExpressionPromises,
|
||||
type RevivalExpression, type RevivalExpressionClaim, type RevivalExpressionPromise, type RevivalExpressionReturn } from './revival-expression-promises';
|
||||
@@ -7,7 +10,7 @@ import { LeaderRevivalPromiseError, liveLeaderRevivalPromises, leaderRevivalRequ
|
||||
import { AlliancePromiseError, alliancePromiseModeSupported, liveAlliancePromises, alliancePromiseRequirements,
|
||||
alliancePromisesMatch, findAlliancePromisePlan, validateAlliancePromises, type AllianceClaim, type AllianceOpportunity, type AlliancePromise } from './alliance-promises';
|
||||
import { BribePromiseError, bribePromiseModeSupported, liveBribePromises, recordPromisedBribe,
|
||||
bribePromiseRequirements, validateBribePromises, type BribeClaim, type BribePromise } from './bribe-promises';
|
||||
validateBribePromises, type BribeClaim, type BribePromise } from './bribe-promises';
|
||||
import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income';
|
||||
import { createRevivalOrder, validateRevivalOrder, revivalOrderBlock, startRevivalOrder,
|
||||
saphoRevivalOrderEvent, reorderSaphoRevivalOrder,
|
||||
@@ -2154,6 +2157,8 @@ export type Game = {
|
||||
/** Shared accepted order for typed force and named leader answers. */
|
||||
revivalPromiseSequence?: string[];
|
||||
bribePromises?: BribePromise[];
|
||||
bribeExpressionPromises?: BribeExpressionPromise[];
|
||||
bribePromiseSequence?: string[];
|
||||
alliancePromises?: AlliancePromise[];
|
||||
movementPromises?: MovementPromise[];
|
||||
/** Server-only certainty of movement-relevant cards still in the discard. */
|
||||
@@ -14932,6 +14937,12 @@ function normalNexusAllianceQuote(g: Game, p: Player, target: string | null) {
|
||||
requireRule(!liveBribePromises(g.bribePromises ?? [], p.id, g.turn)
|
||||
.some(promise => promise.answer && promise.recipient === other.id),
|
||||
'Pay your promised ordinary bribe before offering that recipient an alliance.');
|
||||
if (liveBribeExpressionPromises(g.bribeExpressionPromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
|
||||
const future = structuredClone(g), actor = getPlayer(future, p.id);
|
||||
actor.ally = other.id;
|
||||
requireRule(!!bribePromiseCompletion(future, actor),
|
||||
'Pay the necessary ordinary bribe before offering an alliance that would prevent your whole answer.');
|
||||
}
|
||||
const blocked = homeworldAllianceReason(g, p.id, other.id);
|
||||
requireRule(!blocked, blocked ?? 'This alliance is unavailable.');
|
||||
}
|
||||
@@ -14953,15 +14964,15 @@ function alliancePromisePlan(g: Game, p: Player, promises = g.alliancePromises ?
|
||||
// An already owed, currently payable bribe may precede accepting its named
|
||||
// recipient. Keep that payment on its existing guide and native action.
|
||||
const accepts = actions.find(action => action.target !== null)?.target;
|
||||
if (accepts && liveBribePromises(trial.bribePromises ?? [], p.id, g.turn)
|
||||
.some(promise => promise.answer && promise.recipient === accepts)) {
|
||||
const future = structuredClone(trial);
|
||||
if (accepts) getPlayer(future, p.id).ally = accepts;
|
||||
if (accepts && hasBribePromises(trial, p.id, true) && !bribePromiseCompletion(future, getPlayer(future, p.id))) {
|
||||
const payment = bribePromiseCompletion(trial, actor)?.find(action => action.target === accepts);
|
||||
if (!payment) return null;
|
||||
const amount = payment.amount as number;
|
||||
actor.spice -= amount;
|
||||
getPlayer(trial, accepts).bribes += amount;
|
||||
trial.bribePromises = bribePromiseRule(() => recordPromisedBribe(trial.bribePromises ?? [], p.id,
|
||||
g.turn, { recipient: accepts, amount }));
|
||||
recordBribePayment(trial, p.id, accepts, amount);
|
||||
bribePreparation = true;
|
||||
}
|
||||
for (const action of actions) {
|
||||
@@ -15059,60 +15070,80 @@ function projectedAlliancePromiseGuide(g: Game, player: string): { action: Actio
|
||||
const action: Action = plan.actions[0] ?? { type: 'ready' };
|
||||
return { action: { ...action, alliancePromiseEvent: promise.event } };
|
||||
}
|
||||
function bribePromiseRule<T>(calculate: () => T): T {
|
||||
try { return calculate(); }
|
||||
catch (error) { if (error instanceof BribePromiseError) throw new RuleError(error.message); throw error; }
|
||||
function bribePromiseRule<T>(run: () => T): T {
|
||||
try { return run(); }
|
||||
catch (error) {
|
||||
if (error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function bribePromiseIntegrity(g: Game) {
|
||||
bribePromiseRule(() => validateBribePromises(g.bribePromises ?? [], g.players.map(player => player.id)));
|
||||
bribePromiseRule(() => {
|
||||
const players = g.players.map(player => player.id);
|
||||
validateBribePromises(g.bribePromises ?? [], players);
|
||||
validateBribeExpressionPromises(g.bribeExpressionPromises ?? [], players);
|
||||
});
|
||||
const events = [...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? [])].map(promise => promise.event);
|
||||
requireRule(new Set(events).size === events.length, 'Keep each bribe answer bound to its original event.');
|
||||
}
|
||||
function hasBribePromises(g: Game, player: string, positive = false): boolean {
|
||||
return [...liveBribePromises(g.bribePromises ?? [], player, g.turn),
|
||||
...liveBribeExpressionPromises(g.bribeExpressionPromises ?? [], player, g.turn)]
|
||||
.some(promise => !positive || promise.answer);
|
||||
}
|
||||
function recordBribePayment(g: Game, player: string, recipient: string, amount: number) {
|
||||
if (amount === 0) return;
|
||||
bribePromiseRule(() => {
|
||||
if (g.bribePromises?.length) g.bribePromises = recordPromisedBribe(g.bribePromises, player, g.turn, { recipient, amount });
|
||||
if (g.bribeExpressionPromises?.length) g.bribeExpressionPromises = recordBribeExpressionPayment(
|
||||
g.bribeExpressionPromises, player, g.turn, { recipient, amount });
|
||||
});
|
||||
}
|
||||
function bribePromiseTurn(g: Game): boolean {
|
||||
return g.status === 'playing' && g.phase >= 0 && g.phase <= 8 && bribePromiseModeSupported(g);
|
||||
}
|
||||
function bribeQuestionWindow(g: Game): boolean {
|
||||
// A current Double side can fix No without authorizing any payment. Every
|
||||
// original pending-decision guard still applies to the structured question.
|
||||
return bribePromiseTurn(g) && !bribeTimingBlock({ ...g, truthtrance: null,
|
||||
inflation: g.inflation?.side === 'double' ? null : g.inflation });
|
||||
}
|
||||
/** First funded native prototype: reserve a jointly legal payment now, rather
|
||||
* than inventing future income or moving incoming bribes before Mentat. */
|
||||
function bribePromiseCompletion(g: Game, p: Player, promises = g.bribePromises ?? []): Action[] | null {
|
||||
/** Shared funded completion; alternatives are quotations and never become a
|
||||
* stored route. Record its future payments before checking the remaining purse
|
||||
* so a satisfied bribe is not reserved for a second time by another family. */
|
||||
function bribePromiseCompletion(g: Game, p: Player, promises = g.bribePromises ?? [],
|
||||
expressions = g.bribeExpressionPromises ?? []): Action[] | null {
|
||||
if (!bribePromiseTurn(g)) return null;
|
||||
const required = bribePromiseRequirements(promises, p.id, g.turn);
|
||||
if (!required) return null;
|
||||
if (!required.length) return [];
|
||||
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
|
||||
trial.truthtrance = null;
|
||||
if (bribeTimingBlock(trial)) return null;
|
||||
const total = required.reduce((sum, payment) => sum + payment.amount, 0);
|
||||
if (total > uncommittedSpice(trial, actor)) return null;
|
||||
trial.bribePromises = structuredClone(promises);
|
||||
for (const payment of required) {
|
||||
if (payment.recipient === p.id || payment.recipient === p.ally ||
|
||||
!g.players.some(player => player.id === payment.recipient)) return null;
|
||||
trial.bribePromises = bribePromiseRule(() => recordPromisedBribe(trial.bribePromises ?? [], p.id,
|
||||
g.turn, payment));
|
||||
for (const required of bribePromiseRule(() => bribeCompletionCandidates(promises, expressions, p.id, g.turn))) {
|
||||
if (!required.length) return [];
|
||||
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
|
||||
trial.truthtrance = null;
|
||||
if (bribeTimingBlock(trial)) return null;
|
||||
const total = required.reduce((sum, payment) => sum + payment.amount, 0);
|
||||
if (!Number.isSafeInteger(total) || total > uncommittedSpice(trial, actor)) continue;
|
||||
trial.bribePromises = structuredClone(promises);
|
||||
trial.bribeExpressionPromises = structuredClone(expressions);
|
||||
try {
|
||||
for (const payment of required) {
|
||||
if (payment.recipient === p.id || payment.recipient === p.ally ||
|
||||
!g.players.some(player => player.id === payment.recipient)) throw new RuleError('Choose a non-allied bribe recipient.');
|
||||
recordBribePayment(trial, p.id, payment.recipient, payment.amount);
|
||||
}
|
||||
actor.spice -= total;
|
||||
const voluntary = { actor: p.id, action: { type: 'bribe' } };
|
||||
for (const payment of required)
|
||||
checkBureaucratContributors(trial, payment.recipient, [{ payer: p.id, amount: payment.amount }]);
|
||||
reconcileBattlePromises(trial, voluntary);
|
||||
reconcileShipmentPromises(trial, voluntary);
|
||||
reconcileGroundMovementPromises(trial, p.id, voluntary.action, g.phase);
|
||||
reconcileBidPromises(trial, voluntary);
|
||||
reconcileRevivalPromises(trial, voluntary);
|
||||
return required.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount }));
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
actor.spice -= total;
|
||||
// This is a private compatibility quotation. It creates neither a payment
|
||||
// receipt nor recipient cash; the real native action owns both operations.
|
||||
try {
|
||||
const voluntary = { actor: p.id, action: { type: 'bribe' } };
|
||||
for (const payment of required)
|
||||
checkBureaucratContributors(trial, payment.recipient, [{ payer: p.id, amount: payment.amount }]);
|
||||
reconcileBattlePromises(trial, voluntary);
|
||||
reconcileShipmentPromises(trial, voluntary);
|
||||
reconcileGroundMovementPromises(trial, p.id, voluntary.action, g.phase);
|
||||
reconcileBidPromises(trial, voluntary);
|
||||
reconcileRevivalPromises(trial, voluntary);
|
||||
return required.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount }));
|
||||
} catch (error) { if (error instanceof RuleError) return null; throw error; }
|
||||
return null;
|
||||
}
|
||||
function bribeCompatibleLaterPromises(g: Game, p: Player,
|
||||
later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises' | 'revivalPromises' | 'leaderRevivalPromises' | 'revivalExpressionPromises' | 'battle'>): boolean {
|
||||
if (!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) return true;
|
||||
return !!bribePromiseCompletion({ ...g, ...later }, p);
|
||||
return !hasBribePromises(g, p.id, true) || !!bribePromiseCompletion({ ...g, ...later }, p);
|
||||
}
|
||||
function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no' | 'unknown')[] {
|
||||
if (!bribeQuestionWindow(g) || claim.turn !== g.turn || claim.recipient === p.id ||
|
||||
@@ -15121,37 +15152,65 @@ function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no'
|
||||
{ ...claim, event: 'candidate', player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host,
|
||||
answer: answer === 'yes', progress: 0 }]));
|
||||
}
|
||||
function bindBribeTruth(g: Game, p: Player, claim: BribeClaim, answer: boolean, asker: string) {
|
||||
requireRule(bribeTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
|
||||
'Choose a bribe answer compatible with the current funds and earlier answers.');
|
||||
(g.bribePromises ??= []).push({ ...claim, event: crypto.randomUUID(), player: p.id, asker, answer, progress: 0 });
|
||||
function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionClaim): ('yes' | 'no' | 'unknown')[] {
|
||||
if (!bribeQuestionWindow(g) || claim.turn !== g.turn || bribeExpressionRecipients(claim.claim)
|
||||
.some(recipient => recipient === p.id || recipient === p.ally || !g.players.some(player => player.id === recipient))) return [];
|
||||
return (['yes', 'no'] as const).filter(answer => !!bribePromiseCompletion(g, p, g.bribePromises ?? [],
|
||||
[...(g.bribeExpressionPromises ?? []), { ...claim, event: 'candidate', player: p.id,
|
||||
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: [] }]));
|
||||
}
|
||||
function reopenBribePayer(g: Game, p: Player, answer: boolean) {
|
||||
if (answer && (g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g)) && g.ready.includes(p.id)) {
|
||||
g.ready = g.ready.filter(player => player !== p.id);
|
||||
log(g, `${p.name} has a new Truthtrance bribe commitment and must finish ${g.phase === 8 ? 'Mentat Pause' : 'Collection'} again after paying it.`);
|
||||
}
|
||||
}
|
||||
function bindBribeTruth(g: Game, p: Player, claim: BribeClaim, answer: boolean, asker: string) {
|
||||
requireRule(bribeTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
|
||||
'Choose a bribe answer compatible with the current funds and earlier answers.');
|
||||
const promise: BribePromise = { ...claim, event: crypto.randomUUID(), player: p.id, asker, answer, progress: 0 };
|
||||
(g.bribePromises ??= []).push(promise);
|
||||
(g.bribePromiseSequence ??= []).push(promise.event);
|
||||
reopenBribePayer(g, p, answer);
|
||||
}
|
||||
function bindBribeExpressionTruth(g: Game, p: Player, claim: BribeExpressionClaim, answer: boolean, asker: string) {
|
||||
requireRule(bribeExpressionTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
|
||||
'Choose a whole bribe answer compatible with current funds and earlier answers.');
|
||||
const promise: BribeExpressionPromise = { ...structuredClone(claim), event: crypto.randomUUID(), player: p.id, asker, answer, progress: [] };
|
||||
(g.bribeExpressionPromises ??= []).push(promise);
|
||||
(g.bribePromiseSequence ??= []).push(promise.event);
|
||||
reopenBribePayer(g, p, answer);
|
||||
}
|
||||
function reconcileBribePromises(g: Game, voluntary?: { actor: string; action: Action }) {
|
||||
if (!g.bribePromises?.some(promise => !promise.fulfilled && !promise.released)) return;
|
||||
type Promise = BribePromise | BribeExpressionPromise;
|
||||
const pendingPromises: Promise[] = [...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? [])]
|
||||
.filter(promise => !promise.fulfilled && !promise.released);
|
||||
if (!pendingPromises.length) return;
|
||||
bribePromiseIntegrity(g);
|
||||
if (g.truthtrance) return;
|
||||
const kept: BribePromise[] = [];
|
||||
const no = g.bribePromises.filter(promise => !promise.answer && !promise.fulfilled && !promise.released);
|
||||
const sequence = g.bribePromiseSequence ?? [];
|
||||
pendingPromises.sort((a, b) => sequence.indexOf(a.event) - sequence.indexOf(b.event));
|
||||
const no = pendingPromises.filter(promise => !promise.answer), kept: Promise[] = [];
|
||||
const isExpression = (promise: Promise): promise is BribeExpressionPromise => 'claim' in promise;
|
||||
const pending = g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard ||
|
||||
g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal;
|
||||
if (pending && voluntary) {
|
||||
const required = bribePromiseRequirements(g.bribePromises, voluntary.actor, g.turn);
|
||||
requireRule(required && required.reduce((sum, payment) => sum + payment.amount, 0) <=
|
||||
Math.max(0, uncommittedSpice(g, getPlayer(g, voluntary.actor))),
|
||||
const available = Math.max(0, uncommittedSpice(g, getPlayer(g, voluntary.actor)));
|
||||
let affordable = false;
|
||||
for (const required of bribeCompletionCandidates(g.bribePromises ?? [], g.bribeExpressionPromises ?? [], voluntary.actor, g.turn)) {
|
||||
if (required.reduce((sum, payment) => sum + payment.amount, 0) <= available) { affordable = true; break; }
|
||||
}
|
||||
requireRule(affordable,
|
||||
'Keep the current spice needed by your Truthtrance bribe answer.');
|
||||
}
|
||||
if (pending) return;
|
||||
for (const promise of g.bribePromises) {
|
||||
if (promise.fulfilled || promise.released) continue;
|
||||
for (const promise of pendingPromises) {
|
||||
const same = bribePromiseTurn(g) && promise.turn === g.turn;
|
||||
if (!promise.answer) { if (!same) promise.fulfilled = true; continue; }
|
||||
if (!same || !bribePromiseCompletion(g, getPlayer(g, promise.player), [...no, ...kept, promise])) {
|
||||
requireRule(voluntary?.actor !== promise.player,
|
||||
'You cannot voluntarily evade your Truthtrance bribe answer.');
|
||||
const considered = [...no, ...kept, promise];
|
||||
if (!same || !bribePromiseCompletion(g, getPlayer(g, promise.player),
|
||||
considered.filter((p): p is BribePromise => !isExpression(p)), considered.filter(isExpression))) {
|
||||
requireRule(voluntary?.actor !== promise.player, 'You cannot voluntarily evade your Truthtrance bribe answer.');
|
||||
promise.released = true;
|
||||
log(g, `${getPlayer(g, promise.player).name} can no longer fulfill the Truthtrance bribe answer; that answer is no longer binding.`);
|
||||
} else kept.push(promise);
|
||||
@@ -15161,56 +15220,42 @@ function projectedBribePromiseGuide(g: Game, player: string): { action: Action }
|
||||
if (!bribeQuestionWindow(g) || g.truthtrance) return null;
|
||||
const action = bribePromiseCompletion(g, getPlayer(g, player))?.[0];
|
||||
if (!action) return null;
|
||||
const promise = liveBribePromises(g.bribePromises ?? [], player, g.turn)
|
||||
.find(promise => promise.answer && promise.recipient === action.target);
|
||||
return promise ? { action: { ...action, bribePromiseEvent: promise.event } } : null;
|
||||
const candidates = [...liveBribePromises(g.bribePromises ?? [], player, g.turn),
|
||||
...liveBribeExpressionPromises(g.bribeExpressionPromises ?? [], player, g.turn)]
|
||||
.filter(promise => promise.answer && ('claim' in promise ? bribeExpressionRecipients(promise.claim).includes(action.target as string)
|
||||
: promise.recipient === action.target));
|
||||
const sequence = g.bribePromiseSequence ?? [];
|
||||
candidates.sort((a, b) => sequence.indexOf(a.event) - sequence.indexOf(b.event));
|
||||
return candidates[0] ? { action: { ...action, bribePromiseEvent: candidates[0].event } } : null;
|
||||
}
|
||||
function bribePaymentPreservesPromises(g: Game, p: Player, recipient: string, amount: number): boolean {
|
||||
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
|
||||
try {
|
||||
trial.bribePromises = recordPromisedBribe(trial.bribePromises ?? [], p.id, g.turn, { recipient, amount });
|
||||
recordBribePayment(trial, p.id, recipient, amount);
|
||||
actor.spice -= amount;
|
||||
return !!bribePromiseCompletion(trial, actor);
|
||||
} catch (error) { if (error instanceof BribePromiseError || error instanceof RuleError) return false; throw error; }
|
||||
const voluntary = { actor: p.id, action: { type: 'bribe' } };
|
||||
reconcileBattlePromises(trial, voluntary);
|
||||
reconcileShipmentPromises(trial, voluntary);
|
||||
if (liveMovementPromises(trial.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer) &&
|
||||
!groundMovementCompletion(trial, p.id)) return false;
|
||||
reconcileRevivalPromises(trial, voluntary);
|
||||
return !hasBribePromises(trial, p.id) || !!bribePromiseCompletion(trial, actor);
|
||||
} catch (error) { if (error instanceof BribeExpressionPromiseError || error instanceof BribePromiseError || error instanceof RuleError) return false; throw error; }
|
||||
}
|
||||
function projectedBribes(g: Game, p: Player): BribeOptions {
|
||||
const available = Math.max(0, uncommittedSpice(g, p));
|
||||
const blocked = bribeTimingBlock(g);
|
||||
const battlePromises = g.battle?.truthPromises?.some(promise =>
|
||||
promise.player === p.id && !promise.released && !g.battle?.revealed && !g.battle?.plans[p.id]);
|
||||
const shipmentPromises = liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length > 0;
|
||||
const movementPromises = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer);
|
||||
const revivalPromises = liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
|
||||
const available = Math.max(0, uncommittedSpice(g, p)), blocked = bribeTimingBlock(g);
|
||||
const constrained = hasBribePromises(g, p.id) ||
|
||||
g.battle?.truthPromises?.some(promise => promise.player === p.id && !promise.released && !g.battle?.revealed && !g.battle?.plans[p.id]) ||
|
||||
liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
|
||||
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
|
||||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
|
||||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
|
||||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).some(promise => promise.answer);
|
||||
const bribePromises = liveBribePromises(g.bribePromises ?? [], p.id, g.turn).length > 0;
|
||||
// Reuse the authoritative promise checks on an isolated resource-only copy.
|
||||
// No payment, receipt or live continuation is created by this projection.
|
||||
const maximum = !blocked && (battlePromises || shipmentPromises || movementPromises || revivalPromises)
|
||||
? maximumBribe(available, amount => {
|
||||
const probe = structuredClone(g);
|
||||
getPlayer(probe, p.id).spice -= amount;
|
||||
try {
|
||||
if (battlePromises) reconcileBattlePromises(probe, { actor: p.id, action: { type: 'bribe' } });
|
||||
if (shipmentPromises) reconcileShipmentPromises(probe, { actor: p.id, action: { type: 'bribe' } });
|
||||
if (movementPromises && !groundMovementCompletion(probe, p.id)) return false;
|
||||
if (revivalPromises) reconcileRevivalPromises(probe, { actor: p.id, action: { type: 'bribe' } });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof RuleError) return false;
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
: available;
|
||||
return {
|
||||
available, incoming: p.bribes, blocked,
|
||||
return { available, incoming: p.bribes, blocked,
|
||||
targets: g.players.filter(target => target.id !== p.id && target.id !== p.ally).map(target => {
|
||||
let limit = maximum;
|
||||
if (!blocked && bribePromises) limit = maximumBribe(limit,
|
||||
amount => bribePaymentPreservesPromises(g, p, target.id, amount));
|
||||
let limitation: string | null = maximum < available
|
||||
? 'Some spice must remain available to fulfill your Truthtrance answer.' : null;
|
||||
if (limit < maximum) limitation = 'Your Truthtrance bribe answers limit this recipient’s payment.';
|
||||
let limit = !blocked && constrained ? maximumBribe(available,
|
||||
amount => bribePaymentPreservesPromises(g, p, target.id, amount)) : available;
|
||||
let limitation: string | null = limit < available ? 'Your Truthtrance answers limit this recipient’s payment.' : null;
|
||||
if (!blocked && limit >= 5) {
|
||||
try { checkBureaucratContributors(g, target.id, [{ payer: p.id, amount: limit }]); }
|
||||
catch (error) {
|
||||
@@ -15417,7 +15462,7 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []
|
||||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length ||
|
||||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length ||
|
||||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length ||
|
||||
liveBribePromises(g.bribePromises ?? [], p.id, g.turn).length) {
|
||||
hasBribePromises(g, p.id)) {
|
||||
const trial = structuredClone(g);
|
||||
trial.truthtrance = null;
|
||||
trial.auction!.bid = action.amount as number;
|
||||
@@ -30517,7 +30562,7 @@ function guardPromisedLeaderRevival(g: Game, p: Player, action: Action,
|
||||
!liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length &&
|
||||
!liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length &&
|
||||
!liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length &&
|
||||
!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) return;
|
||||
!hasBribePromises(g, p.id, true)) return;
|
||||
if (!quote) {
|
||||
requireRule(leaderRevivalPromisesSatisfied(g.leaderRevivalPromises ?? [], p.id, g.turn, []),
|
||||
'Complete the named leader returns required by your Truthtrance Yes before finishing Revival.');
|
||||
@@ -30614,7 +30659,7 @@ function guardPromisedRevival(g: Game, p: Player, action: Action, revival?: Pend
|
||||
'This revival commitment guide is stale.');
|
||||
if (!live.length && !liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length &&
|
||||
!liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length &&
|
||||
!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) return;
|
||||
!hasBribePromises(g, p.id, true)) return;
|
||||
if (!revival) {
|
||||
requireRule(revivalPromisesSatisfied(g.revivalPromises ?? [], p.id, g.turn, { amount: 0, elite: 0 }),
|
||||
'Complete the force returns required by your Truthtrance Yes before finishing Revival.');
|
||||
@@ -36259,6 +36304,8 @@ function applyActionInner(
|
||||
bindAlliance: bindAllianceTruth,
|
||||
bribeAnswers: bribeTruthAnswers,
|
||||
bindBribe: bindBribeTruth,
|
||||
bribeExpressionAnswers: bribeExpressionTruthAnswers,
|
||||
bindBribeExpression: bindBribeExpressionTruth,
|
||||
revivalExpressionAnswers: revivalExpressionTruthAnswers,
|
||||
bindRevivalExpression: bindRevivalExpressionTruth,
|
||||
leaderRevivalAnswers: leaderRevivalTruthAnswers,
|
||||
@@ -36276,7 +36323,7 @@ function applyActionInner(
|
||||
)
|
||||
return g;
|
||||
} catch (error) {
|
||||
if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
|
||||
if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
|
||||
throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
@@ -38576,8 +38623,7 @@ function applyActionInner(
|
||||
);
|
||||
const n = integer(action.amount, 1, uncommittedSpice(g, p), 'Spice');
|
||||
checkBureaucratContributors(g,target.id,[{payer:p.id,amount:n}]);
|
||||
if (liveBribePromises(g.bribePromises ?? [], p.id, g.turn).length)
|
||||
g.bribePromises = bribePromiseRule(() => recordPromisedBribe(g.bribePromises ?? [], p.id, g.turn, { recipient: target.id, amount: n }));
|
||||
recordBribePayment(g, p.id, target.id, n);
|
||||
p.spice -= n;
|
||||
log(
|
||||
g,
|
||||
@@ -38749,7 +38795,7 @@ function applyActionInner(
|
||||
if (t === 'bid') {
|
||||
const quotedBid = normalBidQuote(g, p, action.amount, action.allyPayment);
|
||||
if (liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
|
||||
liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
|
||||
hasBribePromises(g, p.id, true)) {
|
||||
const trial = structuredClone(g);
|
||||
trial.auction!.bid = quotedBid.amount;
|
||||
trial.auction!.bidder = p.id;
|
||||
@@ -39901,7 +39947,7 @@ function applyActionInner(
|
||||
if (t === 'ready') {
|
||||
if (allianceQuestionWindow(g)) guardAlliancePromiseReady(g, p);
|
||||
const bribeWindowCloses = g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g);
|
||||
if (bribeWindowCloses) requireRule(!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer),
|
||||
if (bribeWindowCloses) requireRule(!hasBribePromises(g, p.id, true),
|
||||
g.phase === 8 ? 'Complete your Truthtrance bribe payments before finishing Mentat Pause.'
|
||||
: 'Pay your Truthtrance bribes before the known Inflation Double opening prevents them.');
|
||||
if (g.phase === 4) { guardPromisedRevival(g, p, action); guardPromisedLeaderRevival(g, p, action); guardRevivalExpressionReady(g, p); }
|
||||
@@ -40112,6 +40158,10 @@ export function viewGame(state: Game, id: string) {
|
||||
g.truthtrance.question.kind === 'bribe' ? bribeTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
bribePromises: (g.bribePromises ?? []).filter(promise => promise.player === id).map(promise => ({ ...promise })),
|
||||
bribePromiseGuide: projectedBribePromiseGuide(g, id),
|
||||
truthBribeExpressionTargets: bribeQuestionWindow(g) ? g.players.map(player => player.id) : [],
|
||||
truthBribeExpressionAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'bribeExpression' ? bribeExpressionTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
bribeExpressionPromises: (g.bribeExpressionPromises ?? []).filter(promise => promise.player === id).map(promise => structuredClone(promise)),
|
||||
truthRevivalTargets: revivalQuestionWindow(g) ? g.players.filter(player => revivalOpportunity(g, player.id)).map(player => player.id) : [],
|
||||
truthRevivalAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'revival' ? revivalTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
|
||||
@@ -3258,6 +3258,31 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused pure, original deal/payment/Mentat/next-turn runtime and controlled/native UI evidence. Native alliance campaigns, optional compositions, future preparations and final browser/deployed acceptance remain open.', evidence: ['tests/fixture-mentat-bribes.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/fixture-truth-bribe.ts', 'tests/truth-bribe-runtime.test.ts', 'tests/truth-bribe-controls.test.tsx'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-bribe-expression',
|
||||
title: 'Truthtrance: combined ordinary bribe payments',
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Combine cumulative future ordinary payments with full AND/OR while keeping whole No, dynamic alternatives and the original shared purse.',
|
||||
steps: [
|
||||
'Choose Combine ordinary bribe payments with AND/OR. Add, wrap, remove or nest named non-allied recipient/minimum conditions. AND requires every child; OR at least one. The complete editor supports sixteen leaves and depth four; these are software input limits.',
|
||||
'Each leaf counts actual matching ordinary payer payments after this answer until the current turn ends. Split payments add together. Earlier payments, gifts, allied aid and later recipient collection do not count. Repeated conditions for one recipient share that answer’s cumulative progress.',
|
||||
'No negates the whole expression. No to paying A AND B may permit either partial route, while forbidding completion of both; No to A OR B forbids either qualifying route. A Yes becomes fulfilled only when actual payments make the whole expression true.',
|
||||
'The server rebuilds currently funded alternatives after each action; no suggested OR route is saved. Several questions and earlier scalar bribe, bid, revival, shipment, movement and battle commitments keep their actual budgets and accepted order. Unknown future income or foreign agreement is not assumed.',
|
||||
'Persistent own progress and the exact ordinary-payment guide connect to native payments and all four minimal policies. Another legal route may be paid manually. Stale, modified or foreign guide packets reject; rejected actions leave the original state intact.',
|
||||
'Quiet phases0–8 in registered bare native Basic/Advanced support this prototype. Yes blocks finishing Mentat; a new Yes reopens only its already-ready payer. Known automatic Inflation Double closure retains the earlier Collection deadline. No lasts until actual turn advance. Late Mentat receipts wait for next turn’s opening.',
|
||||
'This cumulative funded interval and accepted-order composition is provisional application of general Truthtrance authority. Optional modules, future preparations, cross-family expressions and final browser/deployed acceptance remain unfinished.',
|
||||
],
|
||||
related: ['card-truthtrance', 'truthtrance-bribe', 'bribes', 'truthtrance-revival-expression', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Strict full-tree parsing, whole-answer progress and lazy scalar/expression allocations use actual native payments and one shared purse.', evidence: ['game/bribe-expression-promises.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Full controlled expression editor, public preview, private answers, persistent own progress and exact native guide are connected.', evidence: ['components/bribe-expression-fields.tsx', 'components/bribe-expression-promises.tsx', 'components/bribe-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer the whole question and execute native legal payments. Strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-bribe-expression-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Source authority, cumulative interpretation, funding, original receipt timing and remaining scope are explicit.', evidence: ['docs/TRUTHTRANCE_BRIBE_EXPRESSIONS.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused pure, controlled editor/guide and original native payment cases; broader compositions and full mode acceptance remain open.', evidence: ['tests/bribe-expression-promises.test.ts', 'tests/truth-bribe-expression-controls.test.tsx', 'tests/fixture-truth-bribe-expression.ts', 'tests/truth-bribe-expression-runtime.test.ts', 'tests/truth-bribe-expression-funding.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-revival-expression',
|
||||
title: 'Truthtrance: grouped force and leader revivals',
|
||||
@@ -3520,6 +3545,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.',
|
||||
'A current-Nexus alliance question binds actual final membership with a named partner at that Nexus close. Yes uses existing partnership or an actual invitation; offers alone do not form alliances. No may require a later break before Ready. Unknown can save the card until consent exists. Other players retain their original withdrawal/break rights. See the alliance-promise guide for its provisional interval and scope.',
|
||||
'A bribe-payment question binds cumulative future ordinary spice paid to one named non-allied recipient during this turn. Only the real payer action counts; the recipient still spends its receipt at Mentat. Split payments add together, and earlier obligations retain their funding. See the ordinary-bribe guide for the funded prototype and unfinished preparations.',
|
||||
'A grouped ordinary-bribe question combines named future payment minima with full AND/OR. No negates the whole question; real payments rebuild legal funded alternatives, and next-opening recipient collection adds no new progress. See the combined bribe guide for its provisional interval and scope.',
|
||||
'A grouped Revival question combines future ordinary force minima and named native leader/Kwisatz returns with full AND/OR. No negates the whole expression, not every leaf. Legal alternatives are rebuilt after actual returns without fixing one branch. See the grouped Revival guide for input limits, source and provisional scope.',
|
||||
'A named leader-revival question binds an actual future personal native return before your Revival opportunity ends, including Kwisatz where eligible. Force/leader promises share costs and slots; prior/card/special/foreign-ghola returns do not count. See the named-leader guide for provisional scope and preparation.',
|
||||
'An ordinary force-revival question binds cumulative future total, normal or elite returns in native Revival later this turn, from supported phases0–4. Earlier and special returns do not count. Yes retains the original costs and limits; No also applies across smaller requests. See the revival-promise guide for exact timing and unfinished compositions.',
|
||||
@@ -3544,6 +3570,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'truthtrance-leader-revival',
|
||||
'truthtrance-revival-expression',
|
||||
'truthtrance-bribe',
|
||||
'truthtrance-bribe-expression',
|
||||
'truthtrance-alliance',
|
||||
'richese-no-field',
|
||||
'card-reinforcements',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { parseBribeExpressionClaim, bribeExpressionRecipients, bribeExpressionText,
|
||||
type BribeExpressionClaim } from './bribe-expression-promises';
|
||||
import { parseRevivalExpressionClaim, revivalExpressionText, revivalExpressionPromiseModeSupported, type RevivalExpressionClaim } from './revival-expression-promises';
|
||||
import { parseLeaderRevivalClaim, leaderRevivalClaimText, leaderRevivalPromiseModeSupported, type LeaderRevivalClaim } from './leader-revival-promises';
|
||||
import { parseAllianceClaim, allianceClaimText, alliancePromiseModeSupported, type AllianceClaim } from './alliance-promises';
|
||||
@@ -65,6 +67,7 @@ export type TruthFact =
|
||||
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
|
||||
| { kind: 'and' | 'or'; terms: TruthFact[] };
|
||||
export type TruthQuestion =
|
||||
| ({ kind: 'bribeExpression'; target: string } & BribeExpressionClaim)
|
||||
| ({ kind: 'revivalExpression'; target: string } & RevivalExpressionClaim)
|
||||
| ({ kind: 'leaderRevival'; target: string } & LeaderRevivalClaim)
|
||||
| ({ kind: 'alliance'; target: string } & AllianceClaim)
|
||||
@@ -252,6 +255,18 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
'Choose the current ordinary Nexus and another named partner in a supported table.');
|
||||
return { kind: 'alliance', target: v.target, ...claim };
|
||||
}
|
||||
if (v.kind === 'bribeExpression') {
|
||||
check(Object.keys(v).sort().join(',') === 'claim,kind,target,turn',
|
||||
'Supply the current turn, complete bribe expression and respondent.');
|
||||
const claim = parseBribeExpressionClaim({ turn: v.turn, claim: v.claim });
|
||||
const respondent = g.players.find(player => player.id === v.target)!;
|
||||
check(bribePromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 8 &&
|
||||
claim.turn === g.turn && bribeExpressionRecipients(claim.claim).every(recipient =>
|
||||
recipient !== respondent.id && recipient !== respondent.ally && g.players.some(player => player.id === recipient)) &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama,
|
||||
'Choose current-turn ordinary bribe statements to non-allied participants in a supported table.');
|
||||
return { kind: 'bribeExpression', 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.');
|
||||
@@ -514,6 +529,7 @@ export function truthQuestionText(
|
||||
if (q.kind === 'revivalExpression') return `Will you ${revivalExpressionText(q.claim, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))} from now until your Revival opportunity ends this turn?`;
|
||||
if (q.kind === 'leaderRevival') return `Will you ${leaderRevivalClaimText(q, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))}?`;
|
||||
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 === 'revival') return `Will you ${revivalClaimText(q)}?`;
|
||||
if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`;
|
||||
@@ -564,6 +580,8 @@ export function resolveTruthAction(
|
||||
bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void;
|
||||
allianceAnswers?: (g: Game, p: Player, claim: AllianceClaim) => TruthAnswer[];
|
||||
bindAlliance?: (g: Game, p: Player, claim: AllianceClaim, answer: boolean, asker: string) => void;
|
||||
bribeExpressionAnswers?: (g: Game, p: Player, claim: BribeExpressionClaim) => TruthAnswer[];
|
||||
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;
|
||||
revivalExpressionAnswers?: (g: Game, p: Player, claim: RevivalExpressionClaim) => TruthAnswer[];
|
||||
@@ -701,6 +719,9 @@ export function resolveTruthAction(
|
||||
if (w.question.kind === 'alliance')
|
||||
check(effects.allianceAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a current Nexus question with a compatible answer.');
|
||||
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 === '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.');
|
||||
@@ -775,6 +796,11 @@ export function resolveTruthAction(
|
||||
'Choose an answer consistent with actual alliance consent and earlier commitments.');
|
||||
if (a.answer !== 'unknown') effects.bindAlliance(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'bribeExpression') {
|
||||
check(effects.bribeExpressionAnswers && effects.bindBribeExpression && effects.bribeExpressionAnswers(g, p, q).includes(a.answer),
|
||||
'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 === '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.');
|
||||
|
||||
Reference in new issue
Block a user