Combine current facts with native bribe payments

This commit is contained in:
Quintine committed 2026-10-26 16:39:30 +11:00
1 parent b63a60da64
commit 6b2dfb84a2
23 files changed
+1445 -170

No files matched your search

+4
View File
@@ -1332,6 +1332,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 === 'mixedBribe') {
const answer = g.truthMixedBribeAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'bribeExpression') {
const answer = g.truthBribeExpressionAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
+64 -2
View File
@@ -1,9 +1,10 @@
import type { BribePaymentClaim, MixedBribeClaim } from './mixed-bribe-question';
import { compileMixedActionExpression, MixedActionQuestionError, type CompiledMixedAction } from './mixed-action-question';
import { MovementExpressionPromiseError, liveMovementExpressionPromises, recordMovementExpressionEvent,
validateMovementExpressionPromises, type MovementExpression, type MovementExpressionClaim, type MovementExpressionPromise } from './movement-expression-promises';
import { BribeExpressionPromiseError, bribeExpressionRecipients, bribeCompletionCandidates,
liveBribeExpressionPromises, recordBribeExpressionPayment, validateBribeExpressionPromises,
type BribeExpressionClaim, type BribeExpressionPromise } from './bribe-expression-promises';
type BribeExpression, 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';
@@ -2162,6 +2163,10 @@ export type Game = {
revivalPromiseSequence?: string[];
bribePromises?: BribePromise[];
bribeExpressionPromises?: BribeExpressionPromise[];
/** Public original mixed questions; native residuals own physical payment rules. */
mixedBribeQuestions?: (MixedBribeClaim & {
event: string; player: string; asker: string; answer: boolean; binding: boolean;
})[];
bribePromiseSequence?: string[];
alliancePromises?: AlliancePromise[];
movementPromises?: MovementPromise[];
@@ -6908,6 +6913,9 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
battle: g.battle,
shipmentPromises: g.shipmentPromises,
movementPromises: g.movementPromises,
...(next.record.question.kind === 'mixedBribe' ? {
mixedBribeQuestions: g.mixedBribeQuestions, bribeExpressionPromises: g.bribeExpressionPromises,
} : {}),
...(next.record.question.kind === 'mixedMovement' ? {
mixedMovementQuestions: g.mixedMovementQuestions, movementExpressionPromises: g.movementExpressionPromises,
} : {}),
@@ -15194,6 +15202,54 @@ function bindBribeExpressionTruth(g: Game, p: Player, claim: BribeExpressionClai
(g.bribePromiseSequence ??= []).push(promise.event);
reopenBribePayer(g, p, answer);
}
function compileMixedBribe(g: Game, p: Player, claim: MixedBribeClaim, answer: boolean) {
const compiled = compileMixedActionExpression(claim.mixed,
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
if (compiled.kind === 'constant') return { constant: compiled.value };
const payments = (node: CompiledMixedAction<BribePaymentClaim>): BribeExpression => {
if (node.kind === 'action') return { kind: 'payment', ...node.action };
requireRule(node.kind !== 'constant', 'Retain the full privately compiled mixed-bribe obligation.');
return { kind: node.kind, terms: node.terms.map(payments) };
};
return { residual: { turn: claim.turn, claim: payments(compiled) } };
}
function mixedBribeTruthAnswers(g: Game, p: Player, claim: MixedBribeClaim): TruthAnswer[] {
if (claim.turn !== g.turn || !bribeQuestionWindow(g)) return [];
const answers: TruthAnswer[] = [];
for (const answer of [true, false]) {
const compiled = compileMixedBribe(g, p, claim, answer);
if (compiled.constant !== undefined ? compiled.constant === answer :
bribeExpressionTruthAnswers(g, p, compiled.residual!).includes(answer ? 'yes' : 'no')) answers.push(answer ? 'yes' : 'no');
}
return answers.length ? answers : ['unknown'];
}
function bindMixedBribeTruth(g: Game, p: Player, claim: MixedBribeClaim, answer: boolean, asker: string) {
requireRule(mixedBribeTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'Choose a mixed bribe answer compatible with the whole current-fact and payment question.');
const compiled = compileMixedBribe(g, p, claim, answer);
let event: string;
if (compiled.residual) {
bindBribeExpressionTruth(g, p, compiled.residual, answer, asker);
event = g.bribeExpressionPromises!.at(-1)!.event;
} else {
requireRule(compiled.constant === answer, 'Retain the truthful whole mixed-bribe answer.');
reopenBribePayer(g, p, answer);
event = crypto.randomUUID();
}
(g.mixedBribeQuestions ??= []).push({ turn: claim.turn, mixed: structuredClone(claim.mixed),
event, player: p.id, asker, answer, binding: !!compiled.residual });
}
function isMixedBribeEvent(g: Game, event: string): boolean {
return !!g.mixedBribeQuestions?.some(question => question.event === event);
}
function projectedMixedBribePromises(g: Game, player: string) {
return (g.mixedBribeQuestions ?? []).filter(question => question.player === player).map(question => {
const bound = g.bribeExpressionPromises?.find(promise => promise.event === question.event);
return { event: question.event, player: question.player, asker: question.asker, turn: question.turn,
mixed: structuredClone(question.mixed), answer: question.answer, progress: structuredClone(bound?.progress ?? []),
fulfilled: !question.binding || bound?.fulfilled === true, released: bound?.released === true };
});
}
function reconcileBribePromises(g: Game, voluntary?: { actor: string; action: Action }) {
type Promise = BribePromise | BribeExpressionPromise;
const pendingPromises: Promise[] = [...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? [])]
@@ -36455,6 +36511,8 @@ function applyActionInner(
bindBribe: bindBribeTruth,
bribeExpressionAnswers: bribeExpressionTruthAnswers,
bindBribeExpression: bindBribeExpressionTruth,
mixedBribeAnswers: mixedBribeTruthAnswers,
bindMixedBribe: bindMixedBribeTruth,
revivalExpressionAnswers: revivalExpressionTruthAnswers,
bindRevivalExpression: bindRevivalExpressionTruth,
leaderRevivalAnswers: leaderRevivalTruthAnswers,
@@ -40319,7 +40377,11 @@ export function viewGame(state: Game, id: string) {
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)),
bribeExpressionPromises: (g.bribeExpressionPromises ?? []).filter(promise => promise.player === id && !isMixedBribeEvent(g, promise.event)).map(promise => structuredClone(promise)),
truthMixedBribeTargets: bribeQuestionWindow(g) ? g.players.map(player => player.id) : [],
truthMixedBribeAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'mixedBribe' ? mixedBribeTruthAnswers(g, me, g.truthtrance.question) : null,
mixedBribePromises: projectedMixedBribePromises(g, id),
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,
+15
View File
@@ -0,0 +1,15 @@
import { BribePromiseError, parseBribeClaim, type BribeClaim } from './bribe-promises';
import type { MixedActionExpression } from './mixed-action-question';
export type BribePaymentClaim = Pick<BribeClaim, 'recipient' | 'minimum'>;
export type MixedBribeClaim = { turn: number; mixed: MixedActionExpression<BribePaymentClaim> };
/** Reuse scalar recipient/minimum decoding without accepting question metadata.
* The neutral turn validates only this leaf; engine owns the actual interval. */
export function parseBribePaymentClaim(value: unknown): BribePaymentClaim {
if (!value || typeof value !== 'object' || Array.isArray(value) ||
Object.keys(value).sort().join(',') !== 'minimum,recipient')
throw new BribePromiseError('Supply only an exact ordinary-payment recipient and positive spice minimum.');
const { recipient, minimum } = parseBribeClaim({ ...value, turn: 1 });
return { recipient, minimum };
}
+28 -1
View File
@@ -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-mixed-bribe',
title: 'Truthtrance: current facts and ordinary payments',
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Combine answer-time facts with cumulative future ordinary payments while keeping one native purse, deferred receipts and the private aggregate.',
steps: [
'Choose Combine current facts and ordinary payments. Build full AND/OR groups with at least one current fact and one named non-allied recipient/minimum condition. One16-leaf/depth4 software budget includes nested fact groups.',
'Current facts freeze when answered. If you truthfully hold at least10 spice then promise payments totaling5, the fact remains the answer-time fact after your supply drops to5. Later spending or card custody creates no new factual answer.',
'Only the complete yes/no answer and original question are public. A definite Yes cannot rely on an unknown fact being true; No cannot rely on it being false. The remaining funded payment obligation must guarantee the aggregate. If neither definite answer is currently supportable, the unanswered card may be saved or asked differently.',
'A fact can already settle the whole answer, needing no later payment. Otherwise actual ordinary payer payments after the answer add together per recipient; earlier payments, gifts, allied aid and later Mentat collection do not count. No negates the whole mixed expression.',
'Remaining payments use the original scalar/grouped bribe system, current uncommitted funds, nonally restrictions, previous budgets and accepted order. Dynamic OR recipients remain available. Answering grants no spice and pays nobody.',
'Recipient shares stay deferred to the next actual Mentat opening; a late Mentat payment waits for next turn’s opening. Yes cannot evade by finishing Mentat; No remains binding until actual turn advance. Existing known Inflation Double closure retains its earlier Collection deadline.',
'Full shared editing, guarded raw preview/Ask, private answers, own cumulative progress and one exact native payment guide connect to four minimal policies. Historical fulfilled panels do not duplicate or hide a new live guide. Native Basic/Advanced phases0–8 retain their existing quiet supported scope; other action families, correlated unknowns and optional compositions remain unfinished.',
],
related: ['card-truthtrance', 'truthtrance-bribe-expression', 'truthtrance-bribe', 'truthtrance-mixed-movement', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Generic answer-time compilation binds only a necessary native payment expression; raw aggregate projection hides compiled clauses and keeps native funding/receipt rules.', evidence: ['game/mixed-action-question.ts', 'game/mixed-bribe-question.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Reusable mixed editor serves movement/payments with stable movement controls; raw aggregate panel and one shared exact payment guide are connected.', evidence: ['components/mixed-action-fields.tsx', 'components/mixed-bribe-fields.tsx', 'components/mixed-bribe-promises.tsx', 'components/bribe-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer the aggregate and pay through exact native guides. Strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-mixed-bribe-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Snapshot/unknown composition, original current-purse/receipt/deadline and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_MIXED_BRIBES.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Original native payment/receipt/funding and controlled shared editor/guide cases supplement existing scalar and grouped proof; wider composition/final deployed acceptance remain open.', evidence: ['tests/fixture-truth-mixed-bribe.ts', 'tests/truth-mixed-bribe-runtime.test.ts', 'tests/truth-mixed-bribe-funding.test.ts', 'tests/truth-mixed-bribe-controls.test.tsx', 'tests/truth-mixed-movement-controls.test.tsx'] },
],
},
{
id: 'truthtrance-bribe-expression',
title: 'Truthtrance: combined ordinary bribe payments',
@@ -3401,7 +3426,7 @@ export const RULE_TOPICS: RuleTopic[] = [
related: ['card-truthtrance', 'truthtrance-fact-expression', 'truthtrance-movement-expression', 'truthtrance-movement', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Strict generic mixed parser/compiler freezes facts conservatively and binds only the resulting native movement obligation; immediate aggregate answers create none.', evidence: ['game/mixed-action-question.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Full mixed editor with reused fact controls, guarded raw preview/Ask, private answers, aggregate panel and shared exact guide.', evidence: ['components/mixed-movement-fields.tsx', 'components/mixed-movement-promises.tsx', 'components/movement-expression-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx'] },
{ area: 'Player controls', status: 'Partial', detail: 'Full mixed editor with reused fact controls, guarded raw preview/Ask, private answers, aggregate panel and shared exact guide.', evidence: ['components/mixed-action-fields.tsx', 'components/mixed-movement-fields.tsx', 'components/mixed-movement-promises.tsx', 'components/movement-expression-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer the aggregate and follow native movement guides. Strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-mixed-movement-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Snapshot, conservative unknown semantics, original physical obligations and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_MIXED_MOVEMENT.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused generic, controlled editor/guide and original native known/unknown/Hajr/Charity cases. Broader composition and final browser/deployed acceptance remain open.', evidence: ['tests/mixed-action-question.test.ts', 'tests/fixture-truth-mixed-movement.ts', 'tests/truth-mixed-movement-runtime.test.ts', 'tests/truth-mixed-movement-controls.test.tsx'] },
@@ -3595,6 +3620,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 mixed current-fact and ordinary-payment question freezes facts before later debits and keeps only the funded native payment obligation needed by the whole answer. The recipient still waits for its original Mentat opening; individual frozen clauses remain private. See the mixed payment guide for its provisional scope.',
'A mixed current-fact and ground-movement question freezes the fact at answer time and keeps only the native movement obligation needed for the whole answer. Later card disposal does not rewrite the fact, and unknown clauses require a guaranteed aggregate. See the mixed movement guide for its provisional scope.',
'A grouped ground-movement question combines future physical group minima with full AND/OR. One move must meet each leaf; smaller moves never add together. Actual owned entitlements can fulfill several leaves over several moves, while No negates the whole expression. See the combined movement guide for its provisional opportunity and scope.',
'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.',
@@ -3625,6 +3651,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'truthtrance-revival-expression',
'truthtrance-bribe',
'truthtrance-bribe-expression',
'truthtrance-mixed-bribe',
'truthtrance-alliance',
'richese-no-field',
'card-reinforcements',
+28
View File
@@ -1,3 +1,4 @@
import { parseBribePaymentClaim, type MixedBribeClaim } from './mixed-bribe-question';
import { parseMixedActionExpression, mixedActionExpressionText, type MixedActionExpression } from './mixed-action-question';
import { parseMovementExpressionClaim, movementExpressionText, type MovementExpressionClaim } from './movement-expression-promises';
import { parseBribeExpressionClaim, bribeExpressionRecipients, bribeExpressionText,
@@ -70,6 +71,7 @@ export type TruthFact =
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression<MovementClaim> };
export type TruthQuestion =
| ({ kind: 'mixedBribe'; target: string } & MixedBribeClaim)
| ({ kind: 'mixedMovement'; target: string } & MixedMovementClaim)
| ({ kind: 'movementExpression'; target: string } & MovementExpressionClaim)
| ({ kind: 'bribeExpression'; target: string } & BribeExpressionClaim)
@@ -260,6 +262,20 @@ 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 === 'mixedBribe') {
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && v.turn === g.turn &&
bribePromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 8 &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama,
'Supply the current turn, complete mixed bribe question and a supported quiet native window.');
const respondent = g.players.find(player => player.id === v.target)!;
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), input => {
const action = parseBribePaymentClaim(input);
check(action.recipient !== respondent.id && action.recipient !== respondent.ally &&
g.players.some(player => player.id === action.recipient), 'Choose another current non-allied ordinary bribe recipient.');
return action;
});
return { kind: 'mixedBribe', target: v.target as string, turn: g.turn, mixed };
}
if (v.kind === 'bribeExpression') {
check(Object.keys(v).sort().join(',') === 'claim,kind,target,turn',
'Supply the current turn, complete bribe expression and respondent.');
@@ -578,6 +594,8 @@ export function truthQuestionText(
return `you currently hold ${f.compare === 'eq' ? 'exactly' : f.compare === 'gte' ? 'at least' : 'at most'} ${f.value} spice`;
return `(${f.terms.map(clause).join(f.kind === 'and' ? ' AND ' : ' OR ')})`;
};
if (q.kind === 'mixedBribe') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => `you ${bribeExpressionText({ kind: 'payment', ...action }, playerName)} from now until this turn ends`)}?`;
if (q.kind === 'mixedMovement') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => `you ${movementClaimText(action)} before your Movement opportunity ends`)}?`;
return `Is it true that ${q.kind === 'mixedShipment'
@@ -612,6 +630,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;
mixedBribeAnswers?: (g: Game, p: Player, claim: MixedBribeClaim) => TruthAnswer[];
bindMixedBribe?: (g: Game, p: Player, claim: MixedBribeClaim, 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[];
@@ -757,6 +777,9 @@ export function resolveTruthAction(
if (w.question.kind === 'movementExpression')
check(effects.movementExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a supported whole ground-movement question.');
if (w.question.kind === 'mixedBribe')
check(effects.mixedBribeAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a mixed current-fact and ordinary-payment question compatible with earlier answers.');
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.');
@@ -845,6 +868,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 === 'mixedBribe') {
check(effects.mixedBribeAnswers && effects.bindMixedBribe && effects.mixedBribeAnswers(g, p, q).includes(a.answer),
'Choose a whole mixed-bribe answer compatible with current facts, funds and earlier commitments.');
if (a.answer !== 'unknown') effects.bindMixedBribe(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.');