Combine current facts with native Revival

This commit is contained in:
Quintine committed 2026-10-26 16:53:27 +11:00
1 parent 6b2dfb84a2
commit 957d5a172f
19 files changed
+1386 -5

No files matched your search

+4
View File
@@ -1320,6 +1320,10 @@ function policyActions(g: GameView): Action[] {
return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }];
if (w.stage === 'answer') {
if (w.question?.target !== me.id) return [];
if (w.question.kind === 'mixedRevival') {
const answer = g.truthMixedRevivalAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'revivalExpression') {
const answer = g.truthRevivalExpressionAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
+69 -1
View File
@@ -1,3 +1,4 @@
import type { RevivalActionClaim, MixedRevivalClaim } from './mixed-revival-question';
import type { BribePaymentClaim, MixedBribeClaim } from './mixed-bribe-question';
import { compileMixedActionExpression, MixedActionQuestionError, type CompiledMixedAction } from './mixed-action-question';
import { MovementExpressionPromiseError, liveMovementExpressionPromises, recordMovementExpressionEvent,
@@ -2159,6 +2160,10 @@ export type Game = {
revivalPromises?: RevivalPromise[];
leaderRevivalPromises?: LeaderRevivalPromise[];
revivalExpressionPromises?: RevivalExpressionPromise[];
/** Original mixed questions link to the existing ordinary-return ledger. */
mixedRevivalQuestions?: (MixedRevivalClaim & {
event: string; player: string; asker: string; answer: boolean; binding: boolean;
})[];
/** Shared accepted order for typed force and named leader answers. */
revivalPromiseSequence?: string[];
bribePromises?: BribePromise[];
@@ -6913,6 +6918,9 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
battle: g.battle,
shipmentPromises: g.shipmentPromises,
movementPromises: g.movementPromises,
...(next.record.question.kind === 'mixedRevival' ? {
mixedRevivalQuestions: g.mixedRevivalQuestions, revivalExpressionPromises: g.revivalExpressionPromises,
} : {}),
...(next.record.question.kind === 'mixedBribe' ? {
mixedBribeQuestions: g.mixedBribeQuestions, bribeExpressionPromises: g.bribeExpressionPromises,
} : {}),
@@ -30746,6 +30754,60 @@ function bindRevivalExpressionTruth(g: Game, p: Player, claim: RevivalExpression
(g.revivalExpressionPromises ??= []).push(promise);
(g.revivalPromiseSequence ??= []).push(promise.event);
}
function mixedRevivalIdentities(g: Game, p: Player, mixed: MixedRevivalClaim['mixed']): boolean {
if (mixed.kind === 'fact') return true;
if (mixed.kind === 'action') return revivalExpressionIdentity(g, p, mixed.action);
return mixed.terms.every(term => mixedRevivalIdentities(g, p, term));
}
function compileMixedRevival(g: Game, p: Player, claim: MixedRevivalClaim, answer: boolean) {
const compiled = compileMixedActionExpression(claim.mixed,
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
if (compiled.kind === 'constant') return { constant: compiled.value };
const returns = (node: CompiledMixedAction<RevivalActionClaim>): RevivalExpression => {
if (node.kind === 'action') return { ...node.action };
requireRule(node.kind !== 'constant', 'Retain the complete privately compiled mixed-Revival obligation.');
return { kind: node.kind, terms: node.terms.map(returns) };
};
return { residual: { turn: claim.turn, claim: returns(compiled) } };
}
function mixedRevivalTruthAnswers(g: Game, p: Player, claim: MixedRevivalClaim): TruthAnswer[] {
if (claim.turn !== g.turn || !revivalQuestionWindow(g) || !revivalOpportunity(g, p.id) ||
!mixedRevivalIdentities(g, p, claim.mixed)) return [];
const answers: TruthAnswer[] = [];
for (const answer of [true, false]) {
const compiled = compileMixedRevival(g, p, claim, answer);
if (compiled.constant !== undefined ? compiled.constant === answer :
revivalExpressionTruthAnswers(g, p, compiled.residual!).includes(answer ? 'yes' : 'no')) answers.push(answer ? 'yes' : 'no');
}
return answers.length ? answers : ['unknown'];
}
function bindMixedRevivalTruth(g: Game, p: Player, claim: MixedRevivalClaim, answer: boolean, asker: string) {
requireRule(mixedRevivalTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'Choose a mixed Revival answer compatible with the whole current-fact and ordinary-return question.');
const compiled = compileMixedRevival(g, p, claim, answer);
let event: string;
if (compiled.residual) {
bindRevivalExpressionTruth(g, p, compiled.residual, answer, asker);
event = g.revivalExpressionPromises!.at(-1)!.event;
} else {
requireRule(compiled.constant === answer, 'Retain the truthful whole mixed-Revival answer.');
event = crypto.randomUUID();
}
(g.mixedRevivalQuestions ??= []).push({ turn: claim.turn, mixed: structuredClone(claim.mixed),
event, player: p.id, asker, answer, binding: !!compiled.residual });
}
function isMixedRevivalEvent(g: Game, event: string): boolean {
return !!g.mixedRevivalQuestions?.some(question => question.event === event);
}
function projectedMixedRevivalPromises(g: Game, player: string) {
return (g.mixedRevivalQuestions ?? []).filter(question => question.player === player).map(question => {
const bound = g.revivalExpressionPromises?.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 ?? { total: 0, normal: 0, elite: 0, leaders: [] }),
fulfilled: !question.binding || bound?.fulfilled === true, released: bound?.released === true };
});
}
function guardRevivalExpressionReady(g: Game, p: Player) {
requireRule(liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn)
.every(promise => evaluateRevivalExpression(promise.claim, promise.progress) === promise.answer),
@@ -36515,6 +36577,8 @@ function applyActionInner(
bindMixedBribe: bindMixedBribeTruth,
revivalExpressionAnswers: revivalExpressionTruthAnswers,
bindRevivalExpression: bindRevivalExpressionTruth,
mixedRevivalAnswers: mixedRevivalTruthAnswers,
bindMixedRevival: bindMixedRevivalTruth,
leaderRevivalAnswers: leaderRevivalTruthAnswers,
bindLeaderRevival: bindLeaderRevivalTruth,
revivalAnswers: revivalTruthAnswers,
@@ -40395,7 +40459,11 @@ export function viewGame(state: Game, id: string) {
truthRevivalExpressionTargets: revivalQuestionWindow(g) ? g.players.filter(player => revivalOpportunity(g, player.id)).map(player => player.id) : [],
truthRevivalExpressionAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'revivalExpression' ? revivalExpressionTruthAnswers(g, me, g.truthtrance.question) : null,
revivalExpressionPromises: (g.revivalExpressionPromises ?? []).filter(promise => promise.player === id).map(promise => structuredClone(promise)),
revivalExpressionPromises: (g.revivalExpressionPromises ?? []).filter(promise => promise.player === id && !isMixedRevivalEvent(g, promise.event)).map(promise => structuredClone(promise)),
truthMixedRevivalTargets: revivalQuestionWindow(g) ? g.players.filter(p => revivalOpportunity(g, p.id)).map(p => p.id) : [],
truthMixedRevivalAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'mixedRevival' ? mixedRevivalTruthAnswers(g, me, g.truthtrance.question) : null,
mixedRevivalPromises: projectedMixedRevivalPromises(g, id),
revivalExpressionPromiseGuide: projectedRevivalExpressionPromiseGuide(g, id),
bidPromises: projectedBidPromises(g, id),
bidPromiseGuide: projectedBidPromiseGuide(g, id),
+13
View File
@@ -0,0 +1,13 @@
import { parseRevivalExpression, RevivalExpressionPromiseError, type RevivalExpressionLeaf } from './revival-expression-promises';
import type { MixedActionExpression } from './mixed-action-question';
export type RevivalActionClaim = RevivalExpressionLeaf;
export type MixedRevivalClaim = { turn: number; mixed: MixedActionExpression<RevivalActionClaim> };
/** Only one exact native force or personal-leader leaf belongs in an action.
* Mixed groups use the outer shared grammar and budget instead. */
export function parseRevivalActionClaim(value: unknown): RevivalActionClaim {
const action = parseRevivalExpression(value);
if (action.kind === 'forces' || action.kind === 'leader') return action;
throw new RevivalExpressionPromiseError('Supply one ordinary force or named-leader return condition.');
}
+24
View File
@@ -3258,6 +3258,30 @@ 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-revival',
title: 'Truthtrance: current facts and ordinary Revival',
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Combine answer-time facts with actual future ordinary force and personal named-leader returns using the existing Revival rules.',
steps: [
'Choose Combine current facts and ordinary Revival. Build full AND/OR groups with at least one current fact and one force minimum or named native leader condition. Nested current facts share the16-leaf/depth4 software limit.',
'Current facts freeze at the actual answer. A fact that you hold at least4 spice stays that answer-time fact after actual force income and leader payment change your spice. This creates no promise to retain the same cards, spice or forces afterward.',
'Yes makes the complete expression true; No keeps the whole expression false. A definite Yes cannot rely on an unknown fact being true, and No cannot rely on it being false. If neither guaranteed answer is currently available, the holder may save the unanswered card or change the question.',
'A fact may already settle the aggregate, requiring no later return. Otherwise actual ordinary physical force returns add cumulatively by total, normal and elite counters; personal named-leader returns count after the answer. Prior returns, requests alone, Ghola, foreign leaders and special returns do not fulfill the claim.',
'The original Revival system retains eligibility, costs, cycles, slots, turn order, owned preparations and earlier accepted funding. The answer grants no extra opportunity. A current accepted leader price can be paid; future foreign consent is not assumed. OR alternatives stay available.',
'Raw preview/Ask, private answer choices, own aggregate progress and one exact shared native guide connect to four minimal policies. Basic settles at actual phase close; Advanced settles when your ordered opportunity ends. Quiet native phases0–4 retain their supported scope; other action families, correlated unknowns and broader compositions remain unfinished.',
],
related: ['card-truthtrance', 'truthtrance-revival-expression', 'truthtrance-leader-revival', 'truthtrance-mixed-bribe', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Shared answer-time compilation binds only the required native Revival expression; own raw aggregate projection hides reduced clauses and retains native eligibility/funding/deadlines.', evidence: ['game/mixed-action-question.ts', 'game/mixed-revival-question.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Full shared mixed editor, public faction leader choices, repairable drafts, raw preview/Ask, private answers and one exact live event guide are connected.', evidence: ['components/mixed-revival-fields.tsx', 'components/mixed-revival-promises.tsx', 'components/revival-expression-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-mixed-revival-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and use native ordinary-return guides. Strategic refinement/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-mixed-revival-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Snapshot/unknown meaning, native return boundaries and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_MIXED_REVIVAL.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Original native return and funding journeys plus controlled shared editing/guides; broader combinations and final deployed acceptance remain open.', evidence: ['tests/fixture-truth-mixed-revival.ts', 'tests/truth-mixed-revival-runtime.test.ts', 'tests/truth-mixed-revival-funding.test.ts', 'tests/truth-mixed-revival-controls.test.tsx'] },
],
},
{
id: 'truthtrance-mixed-bribe',
title: 'Truthtrance: current facts and ordinary payments',
+23
View File
@@ -1,3 +1,4 @@
import { parseRevivalActionClaim, type MixedRevivalClaim } from './mixed-revival-question';
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';
@@ -71,6 +72,7 @@ export type TruthFact =
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression<MovementClaim> };
export type TruthQuestion =
| ({ kind: 'mixedRevival'; target: string } & MixedRevivalClaim)
| ({ kind: 'mixedBribe'; target: string } & MixedBribeClaim)
| ({ kind: 'mixedMovement'; target: string } & MixedMovementClaim)
| ({ kind: 'movementExpression'; target: string } & MovementExpressionClaim)
@@ -232,6 +234,15 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
g.players.some((p) => p.id === v.target),
'Ask one other player.',
);
if (v.kind === 'mixedRevival') {
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && v.turn === g.turn &&
revivalExpressionPromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 4 &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && !g.pendingTreacheryDiscard,
'Supply the current turn, complete mixed Revival question and a quiet supported native window.');
const respondent = g.players.find(player => player.id === v.target)!;
return { kind: 'mixedRevival', target: v.target as string, turn: g.turn,
mixed: parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), parseRevivalActionClaim) };
}
if (v.kind === 'revivalExpression') {
check(Object.keys(v).sort().join(',') === 'claim,kind,target,turn', 'Supply the current turn, full revival expression and respondent.');
const claim = parseRevivalExpressionClaim({ turn: v.turn, claim: v.claim });
@@ -594,6 +605,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 === 'mixedRevival') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => `you ${revivalExpressionText(action, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))} before your Revival opportunity ends`)}?`;
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,
@@ -636,6 +649,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;
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[];
bindRevivalExpression?: (g: Game, p: Player, claim: RevivalExpressionClaim, answer: boolean, asker: string) => void;
leaderRevivalAnswers?: (g: Game, p: Player, claim: LeaderRevivalClaim) => TruthAnswer[];
@@ -762,6 +777,9 @@ export function resolveTruthAction(
'Waiting for the Truthtrance holder to ask a question.',
);
w.question = parseQuestion(g, p.id, a.question);
if (w.question.kind === 'mixedRevival')
check(effects.mixedRevivalAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a mixed current-fact and ordinary-return question compatible with the native opportunity.');
if (w.question.kind === 'revivalExpression')
check(effects.revivalExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a whole revival expression with a compatible native completion.');
@@ -853,6 +871,11 @@ export function resolveTruthAction(
'Choose an answer consistent with legal ground movement and earlier promises.');
if (a.answer !== 'unknown') effects.bindMovement(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'mixedRevival') {
check(effects.mixedRevivalAnswers && effects.bindMixedRevival && effects.mixedRevivalAnswers(g, p, q).includes(a.answer),
'Choose a whole mixed-Revival answer compatible with current facts and owned native returns.');
if (a.answer !== 'unknown') effects.bindMixedRevival(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'revivalExpression') {
check(effects.revivalExpressionAnswers && effects.bindRevivalExpression && effects.revivalExpressionAnswers(g, p, q).includes(a.answer),
'Choose a whole revival answer compatible with native returns and earlier promises.');