Support early Charity Truthtrance commitments

This commit is contained in:
Quintine committed 2026-10-27 19:13:48 +11:00
1 parent 72ff9db5e8
commit 2cd2a60a36
12 files changed
+407 -32

No files matched your search

+21 -8
View File
@@ -16159,15 +16159,22 @@ function charityPromiseRule<T>(run: () => T): T {
}
}
function charityQuestionWindow(g: Game): boolean {
return g.status === 'playing' && !g.advanced && g.phase === 2 && !g.charityOrder &&
return g.status === 'playing' && !g.advanced && g.phase >= 0 && g.phase <= 2 && !g.charityOrder &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard &&
(!byFaction(g, 'choam') || g.choamCharity?.turn === g.turn);
(g.phase < 2 || !byFaction(g, 'choam') || g.choamCharity?.turn === g.turn);
}
function charityPromiseCompletion(g: Game, player: string): Action | null {
if (!charityQuestionWindow(g) || getPlayer(g,player).charityTurn === g.turn) return null;
const trial = structuredClone(g);
// In early phases the action is only a detached witness for the later
// Charity opportunity. Never project it as a currently executable guide.
const trial = g.phase < 2 ? charityForecastContext(g) : structuredClone(g);
trial.truthtrance = null;
trial.ready = trial.ready.filter(id => id !== player);
// Either CHOAM opening outcome pays another faction's ordinary claim; use
// the existing bank-fallback branch only on this detached quote. Its own
// future opening income is uncertain and is handled separately below.
if (g.phase < 2 && byFaction(trial,'choam') && trial.choamCharity?.turn !== trial.turn)
trial.choamCharity = {turn:trial.turn,canceled:true};
// Evaluate physical availability independently of this answer's own guard.
delete trial.charityPromises;
const claim = (source: Game): boolean => {
@@ -16197,24 +16204,30 @@ function charityPromiseCanClaim(g: Game, player: string): boolean {
function charityTruthAnswers(g: Game, p: Player, turn: number): TruthAnswer[] {
if (!charityQuestionWindow(g) || turn !== g.turn || p.charityTurn === turn) return [];
if (richeseBidOtherPromises(g,p)) return ['unknown'];
// CHOAM's own unsettled opening changes its purse and depends on another
// player's cancellation. No remains an owned choice; do not invent Yes.
if (g.phase < 2 && p.faction === 'choam' && g.choamCharity?.turn !== turn)
return ['no','unknown'];
return charityPromiseRule(() => charityPromiseAnswers(g.charityPromises ?? [],p.id,turn,charityPromiseCanClaim(g,p.id)));
}
function bindCharityTruth(g: Game,p: Player,turn: number,answer: boolean,asker: string): void {
requireRule(charityTruthAnswers(g,p,turn).includes(answer ? 'yes' : 'no'),
'Choose an answer compatible with your current Charity opportunity.');
g.charityPromises = charityPromiseRule(() => bindCharityPromise(g.charityPromises ?? [],p.id,asker,turn,answer,charityPromiseCanClaim(g,p.id)));
// A definite new answer reopens a concurrent Basic Ready, not a completed phase.
g.ready = g.ready.filter(id => id !== p.id);
// Only the actual concurrent Charity phase reopens Ready; Storm/Spice
// readiness belongs to the original phase and must remain intact.
if (g.phase === 2) g.ready = g.ready.filter(id => id !== p.id);
}
function reconcileCharityTruth(g: Game,voluntary?: {actor:string;action:Action}): void {
if (!g.charityPromises?.length || g.truthtrance || g.response || g.decision || g.phaseOpening ||
g.pendingKarama || g.pendingTreacheryDiscard) return;
g.pendingKarama || g.pendingTreacheryDiscard ||
(g.phase === 2 && !!byFaction(g,'choam') && g.choamCharity?.turn !== g.turn)) return;
let promises: CharityPromise[] = g.charityPromises;
const people = new Set(promises.filter(row => !row.fulfilled && !row.released).map(row => row.player));
for (const player of people) {
const rows: CharityPromise[] = promises.filter(row => row.player === player && !row.fulfilled && !row.released);
for (const turn of new Set(rows.map(row => row.turn))) {
const open = g.status === 'playing' && !g.advanced && g.phase === 2 && g.turn === turn;
const open = g.status === 'playing' && !g.advanced && g.phase >= 0 && g.phase <= 2 && g.turn === turn;
const before: CharityPromise[] = promises;
promises = charityPromiseRule(() => reconcileCharityPromises(before,player,turn,
{phaseOpen:open,canClaim:open && charityPromiseCanClaim(g,player),
@@ -16226,7 +16239,7 @@ function reconcileCharityTruth(g: Game,voluntary?: {actor:string;action:Action})
g.charityPromises = promises;
}
function projectedCharityTruthGuide(g: Game,player: string): {action:Action} | null {
if (g.truthtrance || !charityQuestionWindow(g) || g.ready.includes(player)) return null;
if (g.phase !== 2 || g.truthtrance || !charityQuestionWindow(g) || g.ready.includes(player)) return null;
const promise = liveCharityPromises(g.charityPromises ?? [],player,g.turn).find(row => row.answer);
const action = promise && charityPromiseCompletion(g,player);
return action && promise ? {action:{...action,charityPromiseEvent:promise.event,charityPromiseTurn:promise.turn}} : null;
+6 -6
View File
@@ -3905,20 +3905,20 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Ask whether another player will claim Charity after answering during the current Basic Charity phase.',
summary: 'Ask whether another player will claim Charity after answering during this turn’s upcoming or current Basic Charity phase.',
steps: [
'Yes requires an actual native Charity claim while possible; No forbids it until the original Charity phase ends. An answer reopens that player’s concurrent Ready, but does not reopen a completed phase or count an earlier claim.',
'A private guide can suggest the exact claim or an ordinary bribe that makes the claim eligible. The bribe is paid now and received at Mentat; the claim uses the normal eligibility, payer and inflation rules.',
'Yes requires an actual native Charity claim while possible; No forbids it until the original Charity phase ends. An answer in Charity reopens that player’s concurrent Ready; earlier Storm or Spice readiness stays intact, and an earlier claim does not count.',
'During Charity a private guide can suggest the exact claim or an ordinary bribe that makes the claim eligible. Before Charity, the forecast offers no executable future action. The bribe is paid now and received at Mentat; the claim uses the normal eligibility, payer and inflation rules.',
'A Yes remains binding while a supported claim or payment route exists. A voluntary action cannot abandon it; an external change that removes the supported route releases it. Independent future commitments may require an unknown answer until their joint planning is supported.',
'The live Basic timing and interaction with other obligations are provisional. Earlier phases, Advanced ordered Charity, other preparations and broader combinations remain unfinished.',
'The supported quiet Basic Storm, Spice and Charity timing is provisional. Advanced ordered Charity, other preparations, pending windows and broader combinations remain unfinished.',
],
related: ['card-truthtrance', 'charity', 'truthtrance-bribe'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Native Basic claim, one-payment eligibility route, same-phase Yes/No and event-bound guide are connected; wider forecasts and combined obligations remain open.', evidence: ['game/charity-promises.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Native Basic upcoming/current claim, one-payment route, turn-bound Yes/No and event-bound live guide are connected; wider forecasts and combined obligations remain open.', evidence: ['game/charity-promises.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Question, recipient answers, persistent private guidance and claim/Ready reasons are connected; browser acceptance remains open.', evidence: ['components/truthtrance.tsx', 'components/truth-charity-promise.tsx', 'components/charity-claim.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Profiles use the private answer and native guided action; strategy and calibration are deferred.', evidence: ['game/bots.ts', 'tests/truth-charity-commitment-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'The FAQ source, provisional timing and bounded preparation route are recorded.', evidence: ['docs/TRUTHTRANCE_CHARITY.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused native and rendered controls cover the Basic prototype; wider combinations and deployed acceptance remain open.', evidence: ['tests/charity-promises.test.ts', 'tests/truth-charity-commitment-runtime.test.ts', 'tests/truth-charity-commitment-controls.test.tsx'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused native and rendered controls cover the Basic prototype; wider combinations and deployed acceptance remain open.', evidence: ['tests/charity-promises.test.ts', 'tests/truth-charity-commitment-runtime.test.ts', 'tests/truth-charity-commitment-controls.test.tsx', 'tests/truth-charity-early-runtime.test.ts', 'tests/truth-charity-early-controls.test.tsx'] },
],
},
{
+3 -3
View File
@@ -268,9 +268,9 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
throw new TruthError('Finish this player’s current Charity answer before binding another action question.');
if (v.kind === 'charity') {
check(Object.keys(v).sort().join(',') === 'kind,target,turn' && v.turn === g.turn &&
g.status === 'playing' && !g.advanced && g.phase === 2 && !g.charityOrder &&
g.status === 'playing' && !g.advanced && g.phase >= 0 && g.phase <= 2 && !g.charityOrder &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard,
'Choose the current quiet Basic Charity phase and its original turn.');
'Choose this turn’s upcoming or current Basic Charity in a quiet window.');
return { kind: 'charity', target: v.target as string, turn: g.turn };
}
if (v.kind === 'mixedRevival') {
@@ -752,7 +752,7 @@ export function truthQuestionText(
playerName: (id: string) => string = id => id,
): string {
if (q.kind === 'freeform') return q.text;
if (q.kind === 'charity') return 'Will you claim Charity after this answer during this Charity phase?';
if (q.kind === 'charity') return 'Will you claim Charity after this answer during this turn’s Charity phase?';
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 === 'allianceExpression') return `Is it true that you ${allianceExpressionText(q.claim, playerName)}?`;