From 72ff9db5e8b5c9cf6b79729ee97355f808b56db9 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:07:38 +1100 Subject: [PATCH] Support Truthtrance charity commitments in Basic play --- components/charity-claim.tsx | 3 +- components/game-table.tsx | 5 +- components/truth-charity-promise.tsx | 49 ++++++ components/truthtrance.tsx | 20 ++- docs/CURRENT_STATUS.md | 14 ++ docs/IMPLEMENTATION_STATUS.md | 20 +++ docs/RULE_DECISIONS.md | 14 ++ docs/TRUTHTRANCE_CHARITY.md | 56 +++++++ game/bots.ts | 6 + game/charity-promises.ts | 116 ++++++++++++++ game/engine.ts | 106 +++++++++++++ game/reference.ts | 25 ++- game/truthtrance.ts | 25 +++ tests/charity-promises.test.ts | 87 ++++++++++ tests/choam-charity.test.ts | 1 + tests/fixture-truth-charity-commitment.ts | 45 ++++++ ...truth-charity-commitment-controls.test.tsx | 80 ++++++++++ .../truth-charity-commitment-runtime.test.ts | 148 ++++++++++++++++++ 18 files changed, 815 insertions(+), 5 deletions(-) create mode 100644 components/truth-charity-promise.tsx create mode 100644 docs/TRUTHTRANCE_CHARITY.md create mode 100644 game/charity-promises.ts create mode 100644 tests/charity-promises.test.ts create mode 100644 tests/fixture-truth-charity-commitment.ts create mode 100644 tests/truth-charity-commitment-controls.test.tsx create mode 100644 tests/truth-charity-commitment-runtime.test.ts diff --git a/components/charity-claim.tsx b/components/charity-claim.tsx index 93035f10..09d97d61 100644 --- a/components/charity-claim.tsx +++ b/components/charity-claim.tsx @@ -21,7 +21,7 @@ export function CharityClaim(props: Props) { const blocked = busy || !!game.truthtrance || !!game.nexusTraitors?.pending || game.charity.incomePending || !!game.response || !!game.decision || !!game.phaseOpening || !!game.automaticContinuationPending || !ownTurn || !!game.charity.orderBlocked; - const canClaim = !blocked && !me.charityClaimed && game.charity.amount > 0; + const canClaim = !blocked && !game.charity.truthBlocked && !me.charityClaimed && game.charity.amount > 0; const canFinish = !!order && !blocked; return ( <> @@ -51,6 +51,7 @@ export function CharityClaim(props: Props) { {game.charity.homeworld > 0 && ` Low Homeworld population adds ${game.charity.homeworld} spice directly from the bank${game.charity.multiplier === 2 ? ' after Inflation doubles the bonus' : ''}.`}

+ {game.charity.truthBlocked &&

{game.charity.truthBlocked}

} } + ; +} diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index ad53c2e5..c623ed30 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -99,7 +99,7 @@ export function Truthtrance({ g.players.find((p) => p.id !== me.id)!.id, ); const [kind, setKind] = useState< - 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | 'richeseBid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions' + 'fact' | 'freeform' | 'charity' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | 'richeseBid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions' >('fact'); const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader => leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? ''); @@ -245,6 +245,7 @@ export function Truthtrance({ : kind === 'bid' ? { kind, target, ...bidClaim } : kind === 'phaseBid' ? { kind, target, ...phaseBidClaim } : kind === 'richeseBid' ? { kind, target, turn:g.turn, event:g.richeseAuction?.event ?? '', minimum:bidMinimum } + : kind === 'charity' ? { kind, target, turn:g.turn } : kind === 'shipment' ? { kind, @@ -431,6 +432,13 @@ export function Truthtrance({ {button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })} )} + ) : w.question!.kind === 'charity' ? ( + <> +

This asks whether you will claim Charity after your answer in this Charity phase. Yes requires a legal claim while it remains possible; No means you cannot claim during this phase. Native eligibility and payment rules still apply.

+ {(g.truthCharityAnswers ?? []).map(answer =>
+ {button(answer === 'unknown' ? 'I don’t know' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })} +
)} + ) : w.question!.kind === 'richeseBid' ? ( <>

Yes requires a qualifying bid in your one remaining opportunity while possible. No forbids a qualifying bid. Silent amounts remain sealed until all submit; winning still requires payment.

@@ -600,6 +608,7 @@ export function Truthtrance({ + @@ -745,6 +754,12 @@ export function Truthtrance({ {!g.truthRevivalTargets.includes(target) &&

Choose a respondent whose native Revival opportunity remains this turn and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported first.

}

{truthQuestionText(question, leaderName, locationName, name)}

+ ) : kind === 'charity' ? ( + <> +

Ask whether the selected player will claim Charity after answering during this phase. Earlier claims do not count; native eligibility and payment still apply.

+ {!g.truthCharityTargets.includes(target) &&

Choose a player with a supported remaining Charity opportunity.

} +

{truthQuestionText(question, leaderName, locationName, name)}

+ ) : kind === 'richeseBid' ? ( <> @@ -897,6 +912,7 @@ export function Truthtrance({ (kind === 'mixedBid' && (!!mixedBidError || !g.truthMixedBidTargets.includes(target))) || (kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) || (kind === 'richeseBid' && (!!richeseBidMinimumError(bidMinimum) || !g.richeseAuction || !g.truthRicheseBidTargets.includes(target))) || + (kind === 'charity' && !g.truthCharityTargets.includes(target)) || (kind === 'phaseBid' && (!!phaseBidClaimInputError(phaseBidClaim) || !g.truthPhaseBidTargets.includes(target))) || (kind === 'turnActions' && (!!turnActionsError || !g.truthTurnActionTargets.includes(target))) || (kind === 'travel' && (!!travelError || !g.truthTravelTargets.includes(target))) || @@ -921,7 +937,7 @@ export function TruthHistory({ game: g }: { game: GameView }) {
Truthtrance answers · Turn {g.turn}

- Supported battle-plan, Nexus-alliance, bid, force/leader and grouped revival, bribe, movement and reserve-shipment + Supported Charity, battle-plan, Nexus-alliance, bid, force/leader and grouped revival, bribe, movement and reserve-shipment answers are enforced. Current-turn freeform promises remain binding when possible, but their automatic enforcement is unfinished.

diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index be959288..aa2da0f4 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,20 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Truthtrance Charity commitment — 27 October. Prototyped / Partial.** +A current concurrent Basic Charity question can bind whether the target claims +after answering. A Yes requires an attainable native claim; No prevents a claim +until the phase closes. Private legal choices and guidance, the ordinary claim +control and minimal legal AI use the same native entitlement. An event-bound +guide can quote a real ordinary payment to reach eligibility before claiming, +including after another player changes the target's funds. Voluntary and +external loss of opportunity differ. Other future routes and unknown answers +requiring joint planning remain partial; earlier-phase questions, Advanced, +wider combinations and deployed acceptance remain open. The general +FAQ supports current-turn questions; this particular binding procedure remains +provisional. Fourteen related files pass 187/187; type/lint and build pass. +[Scope and source](TRUTHTRANCE_CHARITY.md#live-basic-charity--27-october-2026). + **Sapho Truthtrance order in Richese cache auctions — 27 October. Prototyped / Partial.** The public question-order opportunity also opens during a clean Once Around or Silent cache lot. The same lot, bidder order, funding and aid continue after the diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 95dc8afc..e0439bdf 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,25 @@ # Dune implementation status +## 27 October 2026 — Truthtrance Charity commitment + +Connect an explicit current Basic Charity claim question to target-only answer +quotes, a saved binding, real native claim/Ready enforcement, player guidance +and minimal legal AI. Yes uses the actual claim; No persists until phase close. +Voluntary loss of an attainable Yes rejects and external impossibility releases +it. A detached quote can use a native ordinary payment to reach eligibility; +the event- and turn-bound guide sends the exact bribe or claim without paying +on the quote. An external funds change retains Yes when this route remains. +Other preparation, unknown answers and independent action combinations needing +a joint planner remain partial. +The source fixture uses original cards, genuine Amal and an actual four-spice +bribe, with no reassigned wallets. This is provisional, limited to the current +concurrent Basic phase; Advanced, earlier-phase and wider combinations plus +browser/deployed acceptance remain open. Fourteen related files pass 187/187; +type/lint and build pass. The external-income supplement uses a labeled, +conserved controlled transfer. Private proof is +`20261027-truth-charity-claim-promise-proof-v2/`. +[Contract](TRUTHTRANCE_CHARITY.md#live-basic-charity--27-october-2026). + ## 27 October 2026 — Sapho Truthtrance order in Richese cache auctions Extend the public simultaneous-question ordering opportunity to clean live diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index d817c842..aae93480 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,19 @@ # Rule decision index +## Truthtrance Charity commitment — 27 October 2026 + +Provisionally bind a question about claiming after the answer in the current +concurrent Basic Charity phase. Yes may use a quoted native ordinary payment +to reach the actual claim entitlement; No prohibits claiming until phase close. +The event- and turn-bound guide retains the exact bribe or claim. Preserve a +feasible Yes against voluntary abandonment, including after an external funds +change if the payment route survives, and release it when an external change +makes every supported route impossible. The general FAQ describes action +commitments but does not specify this Charity implementation. Other eligibility +routes, unknown answers needing joint planning, earlier-phase questions and +Advanced ordering remain separate. +[Source and boundaries](TRUTHTRANCE_CHARITY.md#live-basic-charity--27-october-2026). + ## Sapho Truthtrance order during Richese cache auctions — 27 October 2026 Provisionally apply the simultaneous-question ordering response to a clean diff --git a/docs/TRUTHTRANCE_CHARITY.md b/docs/TRUTHTRANCE_CHARITY.md new file mode 100644 index 00000000..7eb31dcb --- /dev/null +++ b/docs/TRUTHTRANCE_CHARITY.md @@ -0,0 +1,56 @@ +# Truthtrance: Charity claim commitment + +## Live Basic Charity — 27 October 2026 + +**Prototyped / Partial.** A Truthtrance holder may ask another player whether +that player will claim Charity **after the answer** in the currently open, +concurrent Basic Charity phase. The [GF9 November 2020 FAQ, page 8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8) +supports current-turn questions about a player's actions while they remain +possible; it does not provide a Charity-specific worked example. This precise +online binding and release procedure is a provisional interpretation. + +The target's answer quote uses the native current claim entitlement and payer, +including the original faction, turn, claim status, funds and any resolved CHOAM +opening. Yes is offered when the native claim is possible now or the player +can make a quoted native ordinary payment to reach eligibility, provided no +earlier live No conflicts. No is available when no earlier live Yes conflicts. +The quote does not invent future funding or unverified preparations beyond the +supported payment route. A player cannot be asked again about a claim already +completed that turn. Unknown answers and conflicting independent action +families requiring a joint future-action planner remain partial. + +A definite answer reopens that player's concurrent Basic Ready, if already +marked, without reopening a completed phase. The actual native Charity action +fulfills Yes and pays through the existing rules; Ready cannot voluntarily close +an attainable Yes. No forbids the native claim for the rest of the original +Charity phase, including if a later response reopens Ready. A voluntary action +cannot abandon an attainable Yes; after another player changes the target's +funds, an existing Yes remains binding if the supported ordinary-payment route +still makes the claim possible. A genuinely external change that removes every +supported opportunity releases it, while a No completes at phase close. Pending +responses are not treated as proof of impossibility. Ordinary eligibility, +inflation, payment and the original once-per-turn claim remain authoritative. + +The respondent sees their available answers and persistent guidance; a live No +explains why the ordinary claim control is disabled, while an attainable Yes +provides an event- and turn-bound guide to the exact native claim or ordinary +bribe. The payer spends the bribe immediately and the recipient receives it at +Mentat Pause. AI uses the same target-only quote and legal action. Questions and +answers are public as in the existing +Truthtrance history; private receipts and target-only guide are not projected +to other seats. This is a usable first version, not full enforcement of all +Charity questions or a complete implementation of Truthtrance. + +The native fixture `tests/fixture-truth-charity-commitment.ts` starts original +Basic classic factions with the selected Ix deck and original Truth cards, +uses genuine Amal and Charity chronology, and makes a real four-spice bribe to +leave the target at one spice. It does not install new wallets, cards, phase or +readiness. A separate external-income test uses a labeled, conserved controlled +transfer before real Ready, bribe and claim actions. Fourteen related files pass +187/187; type/lint and build pass. Server-rendered controls are exercised, but +that is not browser or deployed acceptance. + +Questions earlier than the live phase, other preparation or later eligibility +forecasts, Advanced ordered Charity, broader overlapping promises, +optional-module and pending-response combinations, full games, browser and +deployed acceptance remain open. No independent joint planner is introduced. diff --git a/game/bots.ts b/game/bots.ts index 462f1310..16f51c45 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1314,6 +1314,7 @@ function policyActions(g: GameView): Action[] { ? [{ type: 'decision', event: search.event, card: ordered[0].id }] : []; } + if (g.truthCharityGuide) return [g.truthCharityGuide.action]; if (g.bribePromiseGuide) return [g.bribePromiseGuide.action]; if (g.alliancePromiseGuide) return [g.alliancePromiseGuide.action]; if (g.revivalExpressionPromiseGuide) return [g.revivalExpressionPromiseGuide.action]; @@ -1380,6 +1381,10 @@ function policyActions(g: GameView): Action[] { const answer = g.truthBidAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } + if (w.question.kind === 'charity') { + const answer = g.truthCharityAnswers?.[0]; + return answer ? [{ type: 'truthAnswer', answer }] : []; + } if (w.question.kind === 'richeseBid') { const answer = g.truthRicheseBidAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; @@ -3232,6 +3237,7 @@ function policyActions(g: GameView): Action[] { g.phase === 2 && !me.charityClaimed && !g.charity.incomePending && + !g.charity.truthBlocked && g.charity.amount > 0 ) return [{ type: 'charity' }]; diff --git a/game/charity-promises.ts b/game/charity-promises.ts new file mode 100644 index 00000000..f91dee47 --- /dev/null +++ b/game/charity-promises.ts @@ -0,0 +1,116 @@ +/** A definite answer about claiming charity after the answer in the current + * Charity phase. Native eligibility and the cause of an opportunity changing + * remain authoritative in the engine. */ +export type CharityPromise = { + event: string; + turn: number; + player: string; + asker: string; + answer: boolean; + fulfilled: boolean; + released: boolean; +}; + +export class CharityPromiseError extends Error {} +export type CharityPromiseAnswer = 'yes' | 'no'; +export type CharityPromiseChange = 'voluntary' | 'external'; + +function checkContext(player: string, turn: number): void { + if (typeof player !== 'string' || !player.trim() || player !== player.trim() || + !Number.isSafeInteger(turn) || turn < 1) + throw new CharityPromiseError('Retain the original player and turn for the Charity answer.'); +} + +/** Each physical answer owns a distinct stable receipt across continuations. */ +export function validateCharityPromises(promises: readonly CharityPromise[]): void { + if (!Array.isArray(promises)) throw new CharityPromiseError('The Charity answer receipts are invalid.'); + const events = new Set(); + for (const row of promises) { + if (!row || typeof row.event !== 'string' || !row.event.trim() || row.event !== row.event.trim() || + events.has(row.event) || typeof row.player !== 'string' || !row.player.trim() || + row.player !== row.player.trim() || typeof row.asker !== 'string' || !row.asker.trim() || + row.asker !== row.asker.trim() || row.asker === row.player || + !Number.isSafeInteger(row.turn) || row.turn < 1 || typeof row.answer !== 'boolean' || + typeof row.fulfilled !== 'boolean' || typeof row.released !== 'boolean' || row.fulfilled && row.released) + throw new CharityPromiseError('A Charity answer lost its unique event, participants or outcome.'); + events.add(row.event); + } +} + +/** A No remains live through phase close, even if concurrent Basic Ready is + * later reopened. A Yes completes only when the actual claim is accepted. */ +export function liveCharityPromises( + promises: readonly T[], player: string, turn: number, +): T[] { + checkContext(player, turn); + validateCharityPromises(promises); + return promises.filter(row => row.player === player && row.turn === turn && !row.fulfilled && !row.released); +} + +/** canClaim is the native, current opportunity, including funding and prior + * claim rules. It must not be a temporary inability while a response is open. */ +export function charityPromiseAnswers( + promises: readonly CharityPromise[], player: string, turn: number, canClaim: boolean, +): CharityPromiseAnswer[] { + const live = liveCharityPromises(promises, player, turn); + return [ + ...(canClaim && !live.some(row => !row.answer) ? ['yes' as const] : []), + ...(!live.some(row => row.answer) ? ['no' as const] : []), + ]; +} + +/** Called only for a definite accepted answer, before the Truth card's actual + * discard. The engine reopens Basic readiness separately. */ +export function bindCharityPromise( + promises: readonly CharityPromise[], player: string, asker: string, turn: number, + answer: boolean, canClaim: boolean, event: string = crypto.randomUUID(), +): CharityPromise[] { + checkContext(asker, turn); + if (asker === player || typeof answer !== 'boolean' || + !charityPromiseAnswers(promises, player, turn, canClaim).includes(answer ? 'yes' : 'no')) + throw new CharityPromiseError('Choose a Charity answer consistent with the current opportunity and previous answers.'); + const next = [...promises, { event, turn, player, asker, answer, fulfilled: false, released: false }]; + validateCharityPromises(next); + return next; +} + +/** Invoke after native claim validation, before the actual claim settles. */ +export function guardCharityClaim( + promises: readonly T[], player: string, turn: number, +): T[] { + const live = liveCharityPromises(promises, player, turn); + if (live.some(row => !row.answer)) + throw new CharityPromiseError('Your Truthtrance No prevents claiming Charity this phase.'); + return promises.map(row => live.includes(row) && row.answer ? { ...row, fulfilled: true } : row); +} + +/** A reachable affirmative claim must happen before this player confirms Ready. */ +export function guardCharityReady( + promises: readonly CharityPromise[], player: string, turn: number, canClaim: boolean, +): void { + if (canClaim && liveCharityPromises(promises, player, turn).some(row => row.answer)) + throw new CharityPromiseError('Claim Charity before finishing your Truthtrance Yes.'); +} + +/** Reconcile only at a stable native boundary. A voluntary change may not + * sacrifice an attainable Yes; an externally impossible Yes is released. No + * resolves only when the original phase closes, never merely on Ready. */ +export function reconcileCharityPromises( + promises: readonly T[], player: string, turn: number, + opportunity: { phaseOpen: boolean; canClaim: boolean; change: CharityPromiseChange }, +): T[] { + const live = liveCharityPromises(promises, player, turn); + if (!opportunity || typeof opportunity.phaseOpen !== 'boolean' || + typeof opportunity.canClaim !== 'boolean' || + (opportunity.change !== 'voluntary' && opportunity.change !== 'external')) + throw new CharityPromiseError('Retain the original Charity opportunity and the cause of its change.'); + if ((!opportunity.phaseOpen || !opportunity.canClaim) && live.some(row => row.answer) && + opportunity.change === 'voluntary') + throw new CharityPromiseError('This action would abandon your Truthtrance Charity Yes.'); + return promises.map(row => { + if (!live.includes(row)) return row; + if (row.answer && (!opportunity.phaseOpen || !opportunity.canClaim)) return { ...row, released: true }; + if (!row.answer && !opportunity.phaseOpen) return { ...row, fulfilled: true }; + return row; + }); +} diff --git a/game/engine.ts b/game/engine.ts index 9f1b6bce..47a2a321 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,4 @@ +import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises'; import { SaphoTruthOrderError, quoteSaphoTruthOrder, reorderSaphoTruthOrder, validateSaphoTruthOrder } from './sapho-truth-order'; import { RicheseBidPromiseError, richeseBidCompletionRange, richeseBidRequired, recordRicheseBid, liveRicheseBidPromises, reconcileRicheseBidPromises, finishRicheseBidLot, validateRicheseBidPromises, @@ -1395,6 +1396,7 @@ type RicheseAllyOffer = { payer: string; }; export type Game = { + charityPromises?: CharityPromise[]; revivalOrder?: RevivalOrder; charityOrder?: CharityOrder; pendingAutomaticAdvisorFlips?: AutomaticAdvisorFlipSource[]; @@ -7096,6 +7098,7 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) { `truth:${next.historyIndex}`, ), history: g.truthHistory, + ...(g.charityPromises ? { charityPromises: g.charityPromises } : {}), battle: g.battle, shipmentPromises: g.shipmentPromises, movementPromises: g.movementPromises, @@ -16146,6 +16149,88 @@ function phaseBidWitnesses(g: Game, p: Player): PhaseBidOpportunity[] { } return result; } +/** The first structured Charity question covers the live concurrent Basic phase. + * A detached native action quotes the real entitlement and payer; it never + * commits a payment or opens a response at the table. */ +function charityPromiseRule(run: () => T): T { + try { return run(); } catch (error) { + if (error instanceof CharityPromiseError) throw new RuleError(error.message); + throw error; + } +} +function charityQuestionWindow(g: Game): boolean { + return g.status === 'playing' && !g.advanced && g.phase === 2 && !g.charityOrder && + !g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard && + (!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); + trial.truthtrance = null; + trial.ready = trial.ready.filter(id => id !== player); + // Evaluate physical availability independently of this answer's own guard. + delete trial.charityPromises; + const claim = (source: Game): boolean => { + try { + const result = applyActionInner(source, player, {type:'charity'}, 'shipmentPreparation'); + return getPlayer(result,player).charityTurn === result.turn; + } catch (error) { if (error instanceof RuleError) return false; throw error; } + }; + if (claim(trial)) return {type:'charity'}; + // A real ordinary payment may make an otherwise wealthy player eligible. + // Quote only the minimum known one-payment route and never invent consent. + const actor = getPlayer(trial,player), amount = actor.spice - 1; + if (!Number.isSafeInteger(amount) || amount <= 0 || amount > uncommittedSpice(trial,actor)) return null; + for (const recipient of trial.players) { + if (recipient.id === player || recipient.id === actor.ally) continue; + const action: Action = {type:'bribe',target:recipient.id,amount}; + try { + const paid = applyActionInner(trial,player,action,'shipmentPreparation'); + if (claim(paid)) return action; + } catch (error) { if (!(error instanceof RuleError)) throw error; } + } + return null; +} +function charityPromiseCanClaim(g: Game, player: string): boolean { + return !!charityPromiseCompletion(g,player); +} +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']; + 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); +} +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; + 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 before: CharityPromise[] = promises; + promises = charityPromiseRule(() => reconcileCharityPromises(before,player,turn, + {phaseOpen:open,canClaim:open && charityPromiseCanClaim(g,player), + change:voluntary?.actor === player ? 'voluntary' : 'external'})); + if (promises.some((row,index) => row.player === player && row.released && !before[index]?.released)) + log(g, `${getPlayer(g,player).name} can no longer fulfill the Truthtrance Charity answer; it is no longer binding.`); + } + } + g.charityPromises = promises; +} +function projectedCharityTruthGuide(g: Game,player: string): {action:Action} | null { + if (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; +} function richeseBidRule(run: () => T): T { try { return run(); } catch (error) { if (error instanceof RicheseBidPromiseError) throw new RuleError(error.message); throw error; } } @@ -39366,6 +39451,12 @@ export function applyAction(state: Game, id: string, action: Action): Game { Object.keys(guide.action).every(key => action[key] === guide.action[key]), 'This phase bid guide is stale or belongs to another auction lot.'); } + if (action.charityPromiseEvent !== undefined || action.charityPromiseTurn !== undefined) { + const guide = projectedCharityTruthGuide(state,id); + requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') && + Object.keys(guide.action).every(key => action[key] === guide.action[key]), + 'This Charity promise guide is stale or belongs to another opportunity.'); + } if (action.richeseBidPromiseId !== undefined) { const guide = projectedRicheseBidGuide(state,id); requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') && @@ -39387,6 +39478,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { reconcileBidPromises(g, voluntary); reconcilePhaseBids(g, voluntary); reconcileRicheseBids(g, voluntary); + reconcileCharityTruth(g, voluntary); reconcileRevivalPromises(g, voluntary); reconcileBribePromises(g, voluntary); reconcileGroundMovementPromises(g, promiseActor, action, state.phase); @@ -39513,6 +39605,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { reconcileBidPromises(g, { actor: id, action }); reconcilePhaseBids(g, { actor: id, action }); reconcileRicheseBids(g, { actor: id, action }); + reconcileCharityTruth(g, { actor: id, action }); reconcileRevivalPromises(g, { actor: id, action }); reconcileBribePromises(g, { actor: id, action }); reconcileAlliancePromises(g, { actor: id, action }); @@ -39732,6 +39825,7 @@ function settleAutomaticContinuations(g: Game) { reconcileBidPromises(g); reconcilePhaseBids(g); reconcileRicheseBids(g); + reconcileCharityTruth(g); reconcileRevivalPromises(g); reconcileBribePromises(g); reconcileGroundMovementPromises(g); @@ -39841,6 +39935,7 @@ export function normalizeAutomaticGame(state: Game): Game { reconcileBidPromises(g); reconcilePhaseBids(g); reconcileRicheseBids(g); + reconcileCharityTruth(g); reconcileRevivalPromises(g); reconcileBribePromises(g); reconcileGroundMovementPromises(g); @@ -40123,6 +40218,8 @@ function applyActionInner( bindMixedBid: bindMixedBidTruth, bidAnswers: bidTruthAnswers, bindBid: bindBidTruth, + charityAnswers: charityTruthAnswers, + bindCharity: bindCharityTruth, richeseBidAnswers: richeseBidTruthAnswers, bindRicheseBid: bindRicheseBidTruth, phaseBidAnswers: phaseBidTruthAnswers, @@ -42544,6 +42641,7 @@ function applyActionInner( p.charityTurn !== g.turn, 'Charity may be claimed only once per turn.', ); + if (g.charityPromises) g.charityPromises = charityPromiseRule(() => guardCharityClaim(g.charityPromises!,id,g.turn)); if (g.charityOrder) g.charityOrder = charityOrderRule(() => startCharityOrder(charityOrderContext(g),g.charityOrder!,id)); p.charityTurn = g.turn; @@ -43852,6 +43950,7 @@ function applyActionInner( } if (t === 'ready') { if (g.phase === 2) { + if (g.charityPromises) charityPromiseRule(() => guardCharityReady(g.charityPromises!,id,g.turn,charityPromiseCanClaim(g,id))); const orderBlock = nativeCharityOrderBlock(g, id); requireRule(!orderBlock, orderBlock ?? 'Wait for your Charity turn.'); } @@ -44129,6 +44228,11 @@ export function viewGame(state: Game, id: string, now?: number) { }).map(player => player.id) : [], truthBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'bid' ? bidTruthAnswers(g, me, g.truthtrance.question) : null, + truthCharityTargets: charityQuestionWindow(g) ? g.players.filter(player => player.charityTurn !== g.turn).map(player => player.id) : [], + truthCharityAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && + g.truthtrance.question.kind === 'charity' ? charityTruthAnswers(g,me,g.truthtrance.question.turn) : null, + truthCharityPromises: (g.charityPromises ?? []).filter(row => row.player === id).map(row => ({...row})), + truthCharityGuide: projectedCharityTruthGuide(g,id), truthRicheseBidTargets: richeseBidWindow(g) ? g.players.filter(player => { const lot = g.richeseAuction!; return lot.eligible.includes(player.id) && !lot.acted.includes(player.id); @@ -44832,6 +44936,8 @@ export function viewGame(state: Game, id: string, now?: number) { g.choamCharity?.turn === g.turn && g.choamCharity.canceled, incomeHomeworld: choamPlayer ? homeworldLowBonus(g, choamPlayer.id) : 0, orderBlocked: nativeCharityOrderBlock(g, id), + truthBlocked: liveCharityPromises(g.charityPromises ?? [],id,g.turn).some(row => !row.answer) + ? 'Your Truthtrance No prevents claiming Charity this phase.' : null, }, charityOrder: g.charityOrder ? structuredClone(g.charityOrder) : null, moritaniAssassinate: projectedMoritaniAssassinate(g,id), diff --git a/game/reference.ts b/game/reference.ts index 7f83c9bf..d6ffbd53 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3899,13 +3899,35 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Verification', status: 'Partial', detail: 'Pure parser/budget, original Basic battle/payment/Prescience and controlled native assistance cases; wider Advanced/faction/card preparation and final deployed acceptance remain open.', evidence: ['tests/mixed-battle-question.test.ts', 'tests/mixed-action-question.test.ts', 'tests/fixture-truth-mixed-battle.ts', 'tests/truth-mixed-battle-runtime.test.ts', 'tests/truth-mixed-battle-funding.test.ts', 'tests/truth-mixed-battle-controls.test.tsx'] }, ], }, + { + id: 'truthtrance-charity', + title: 'Truthtrance: Charity commitment', + category: 'Cards', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Ask whether another player will claim Charity after answering during the 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.', + '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.', + ], + 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: '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'] }, + ], + }, { id: 'card-truthtrance', title: 'Truthtrance', category: 'Cards', coverage: 'Partial', summary: - 'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, named/grouped native revival, ordinary bribe-payment, current-Nexus alliance, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.', + 'Ask another player a public yes/no question. Verified facts, supported Charity, ordinary-bid, ordinary force-revival, named/grouped native revival, ordinary bribe-payment, current-Nexus alliance, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.', steps: [ 'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.', 'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.', @@ -3947,6 +3969,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'truthtrance-movement-expression', 'truthtrance-mixed-movement', 'truthtrance-bid', + 'truthtrance-charity', 'truthtrance-revival', 'truthtrance-leader-revival', 'truthtrance-revival-expression', diff --git a/game/truthtrance.ts b/game/truthtrance.ts index fbb2c4eb..9a8aeb9b 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1,3 +1,4 @@ +import { liveCharityPromises } from './charity-promises'; import { createSaphoTruthOrder, passSaphoTruthOrder, SaphoTruthOrderError, type SaphoTruthOrderFrame } from './sapho-truth-order'; import { parseTravelActionClaim, type TravelClaim } from './travel-expression-promises'; import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES, @@ -87,6 +88,7 @@ export type TruthFact = | { kind: 'and' | 'or'; terms: TruthFact[] }; export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression }; export type TruthQuestion = + | { kind: 'charity'; target: string; turn: number } | ({ kind: 'turnActions'; target: string } & TurnActionClaim) | ({ kind: 'travel'; target: string } & TravelClaim) | ({ kind: 'mixedTraitorCall'; target: string } & MixedTraitorClaim) @@ -259,6 +261,18 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { g.players.some((p) => p.id === v.target), 'Ask one other player.', ); + // A joint planner for Charity and other future-action questions is not yet + // available. Do not bind incompatible action obligations in the interim. + if (v.kind !== 'charity' && v.kind !== 'fact' && v.kind !== 'freeform' && + g.charityPromises?.length && liveCharityPromises(g.charityPromises,v.target as string,g.turn).length) + throw new TruthError('Finish this player’s current Charity answer before binding another action question.'); + 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.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard, + 'Choose the current quiet Basic Charity phase and its original turn.'); + return { kind: 'charity', target: v.target as string, turn: g.turn }; + } 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 && @@ -738,6 +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 === '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)}?`; @@ -878,6 +893,8 @@ export function resolveTruthAction( bindMixedBid?: (g: Game, p: Player, claim: MixedBidClaim, answer: boolean, asker: string) => void; bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[]; bindBid?: (g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) => void; + charityAnswers?: (g: Game, p: Player, turn: number) => TruthAnswer[]; + bindCharity?: (g: Game, p: Player, turn: number, answer: boolean, asker: string) => void; richeseBidAnswers?: (g: Game, p: Player, claim: RicheseBidClaim) => TruthAnswer[]; bindRicheseBid?: (g: Game, p: Player, claim: RicheseBidClaim, answer: boolean, asker: string) => void; phaseBidAnswers?: (g: Game, p: Player, claim: PhaseBidClaim) => TruthAnswer[]; @@ -1066,6 +1083,9 @@ export function resolveTruthAction( if (w.question.kind === 'bid') check(effects.bidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, 'Choose a bidder with a remaining ordinary opportunity in this lot.'); + if (w.question.kind === 'charity') + check(effects.charityAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question.turn).length, + 'Choose a Charity question compatible with the current native opportunity.'); if (w.question.kind === 'richeseBid') check(effects.richeseBidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, 'Choose a bidder with a remaining Richese auction opportunity.'); @@ -1202,6 +1222,11 @@ export function resolveTruthAction( 'Choose an answer consistent with this lot, your legal bids and earlier answers.'); if (a.answer !== 'unknown') effects.bindBid(g, p, q, a.answer === 'yes', current.player); } + if (q.kind === 'charity') { + check(effects.charityAnswers && effects.bindCharity && effects.charityAnswers(g, p, q.turn).includes(a.answer), + 'Choose an answer consistent with this Charity opportunity and earlier answers.'); + if (a.answer !== 'unknown') effects.bindCharity(g, p, q.turn, a.answer === 'yes', current.player); + } if (q.kind === 'richeseBid') { check(effects.richeseBidAnswers && effects.bindRicheseBid && effects.richeseBidAnswers(g, p, q).includes(a.answer), 'Choose an answer consistent with this Richese lot and your legal bid.'); diff --git a/tests/charity-promises.test.ts b/tests/charity-promises.test.ts new file mode 100644 index 00000000..a56b2fbf --- /dev/null +++ b/tests/charity-promises.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + bindCharityPromise, charityPromiseAnswers, CharityPromiseError, + guardCharityClaim, guardCharityReady, liveCharityPromises, reconcileCharityPromises, validateCharityPromises, + type CharityPromise, +} from '../game/charity-promises'; + +const ctx = { player: 'emperor', asker: 'atreides', turn: 3 }; +const empty: CharityPromise[] = []; +const bind = (rows: readonly CharityPromise[], answer: boolean, canClaim = true) => + bindCharityPromise(rows, ctx.player, ctx.asker, ctx.turn, answer, canClaim); + +void test('Charity answer availability follows actual eligibility and incompatible earlier answers', () => { + assert.deepEqual(charityPromiseAnswers(empty, ctx.player, ctx.turn, true), ['yes', 'no']); + assert.deepEqual(charityPromiseAnswers(empty, ctx.player, ctx.turn, false), ['no']); + assert.deepEqual(charityPromiseAnswers(bind(empty, true), ctx.player, ctx.turn, true), ['yes']); + assert.deepEqual(charityPromiseAnswers(bind(empty, false), ctx.player, ctx.turn, true), ['no']); + assert.throws(() => bind(bind(empty, true), false), CharityPromiseError); + assert.throws(() => bind(bind(empty, false), true), CharityPromiseError); + assert.throws(() => bind(empty, true, false), CharityPromiseError); + assert.throws(() => bindCharityPromise(empty, ctx.player, ctx.player, ctx.turn, false, true), CharityPromiseError); + assert.deepEqual(empty, []); +}); + +void test('a Yes blocks ready until the actual claim and a No blocks the actual claim', () => { + const yes = bind(bind(empty, true), true); + assert.throws(() => guardCharityReady(yes, ctx.player, ctx.turn, true), CharityPromiseError); + assert.doesNotThrow(() => guardCharityReady(yes, ctx.player, ctx.turn, false)); + const claimed = guardCharityClaim(yes, ctx.player, ctx.turn); + assert.equal(claimed.every(row => row.fulfilled && !row.released), true); + assert.equal(yes.every(row => !row.fulfilled), true, 'the original input is unchanged'); + assert.doesNotThrow(() => guardCharityReady(claimed, ctx.player, ctx.turn, true)); + assert.throws(() => guardCharityClaim(bind(empty, false), ctx.player, ctx.turn), CharityPromiseError); +}); + +void test('an external loss releases Yes, while a voluntary loss or phase exit rejects unchanged', () => { + const yes = bind(empty, true); + for (const phaseOpen of [true, false]) { + assert.throws(() => reconcileCharityPromises(yes, ctx.player, ctx.turn, + { phaseOpen, canClaim: false, change: 'voluntary' }), CharityPromiseError); + assert.equal(yes[0].released, false); + const released = reconcileCharityPromises(yes, ctx.player, ctx.turn, + { phaseOpen, canClaim: false, change: 'external' }); + assert.equal(released[0].released, true); + assert.equal(released[0].fulfilled, false); + } +}); + +void test('No survives Basic ready while the phase is open and finishes at phase close', () => { + const no = bind(empty, false); + const ready = reconcileCharityPromises(no, ctx.player, ctx.turn, + { phaseOpen: true, canClaim: true, change: 'voluntary' }); + assert.deepEqual(liveCharityPromises(ready, ctx.player, ctx.turn), no); + assert.throws(() => guardCharityClaim(ready, ctx.player, ctx.turn), CharityPromiseError); + const closed = reconcileCharityPromises(ready, ctx.player, ctx.turn, + { phaseOpen: false, canClaim: false, change: 'voluntary' }); + assert.equal(closed[0].fulfilled, true); + assert.equal(closed[0].released, false); + assert.deepEqual(liveCharityPromises(closed, ctx.player, ctx.turn), []); +}); + +void test('promises for other players, turns and completed answers are unaffected', () => { + const yes = bind(empty, true); + const other = bindCharityPromise(yes, 'guild', 'atreides', 3, true, true); + const later = bindCharityPromise(other, 'emperor', 'atreides', 4, false, true); + const settled = guardCharityClaim(later, ctx.player, ctx.turn); + assert.equal(settled[0].fulfilled, true); + assert.deepEqual(settled.slice(1), later.slice(1)); + assert.deepEqual(charityPromiseAnswers(settled, ctx.player, ctx.turn, true), ['yes', 'no']); + assert.throws(() => liveCharityPromises(settled, ctx.player, 0), CharityPromiseError); +}); + +void test('every answer has a stable unique receipt event through claim, release and closure', () => { + const yes = bindCharityPromise(empty, ctx.player, ctx.asker, ctx.turn, true, true, 'event-yes'); + assert.equal(yes[0].event, 'event-yes'); + assert.equal(guardCharityClaim(yes, ctx.player, ctx.turn)[0].event, 'event-yes'); + assert.equal(reconcileCharityPromises(yes, ctx.player, ctx.turn, + { phaseOpen: true, canClaim: false, change: 'external' })[0].event, 'event-yes'); + const no = bindCharityPromise(yes, 'guild', ctx.asker, ctx.turn, false, true, 'event-no'); + assert.equal(reconcileCharityPromises(no, 'guild', ctx.turn, + { phaseOpen: false, canClaim: false, change: 'voluntary' })[1].event, 'event-no'); + assert.throws(() => bindCharityPromise(yes, 'guild', ctx.asker, ctx.turn, true, true, 'event-yes'), CharityPromiseError); + assert.throws(() => validateCharityPromises([{ ...yes[0], event: '' }]), CharityPromiseError); + assert.throws(() => validateCharityPromises([{ ...yes[0], fulfilled: true, released: true }]), CharityPromiseError); + assert.ok(bind(empty, false)[0].event, 'normal binding assigns an event'); +}); diff --git a/tests/choam-charity.test.ts b/tests/choam-charity.test.ts index 8cc3ce2f..6b387311 100644 --- a/tests/choam-charity.test.ts +++ b/tests/choam-charity.test.ts @@ -355,6 +355,7 @@ void test('charity projections expose the payer and own entitlement without leak incomeCanceled: false, incomeHomeworld: 0, orderBlocked: null, + truthBlocked: null, }); assert.equal(v.players[0].spice, undefined); assert.equal(v.players[0].handLimit, 5); diff --git a/tests/fixture-truth-charity-commitment.ts b/tests/fixture-truth-charity-commitment.ts new file mode 100644 index 00000000..3d0be2ef --- /dev/null +++ b/tests/fixture-truth-charity-commitment.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { charityQuote } from '../game/charity'; +import { type Action, type Game, viewGame } from '../game/engine'; +import { type TruthQuestion } from '../game/truthtrance'; +import { + actTruthCharityPayments as act, + createTruthCharityPaymentsFixture, + declareTruthCharityPayments, + truthCharityPaymentsInventory as inventory, + truthCharityPaymentsPlayer as player, +} from './fixture-truth-charity-payments'; + +export { act, inventory, player }; + +/** Original Basic classic factions with the selected Ix deck and original + * Truth cards, genuine Amal and Charity chronology. The only additional + * preparation is a real four-spice bribe, leaving the target at one spice; + * no wallet, cards, phase or readiness are installed by the fixture. */ +export function createTruthCharityCommitmentFixture() { + const base = createTruthCharityPaymentsFixture({ source: 'phase2', advanced: false }); + assert.equal(base.game.phase, 2); + assert.equal(player(base.game, base.target).spice, 5); + const game = act(base.game, base.target, { type: 'bribe', target: base.asker, amount: 4 }); + assert.equal(player(game, base.target).spice, 1); + assert.equal(charityQuote(game, player(game, base.target)).total, 1); + assert.deepEqual(inventory(game), base.originalCards); + return { ...base, game, unprepared: structuredClone(base.game), source: structuredClone(game), + charityQuestion: { kind: 'charity', target: base.target, turn: game.turn } satisfies TruthQuestion }; +} +export type TruthCharityCommitmentFixture = ReturnType; + +export function askTruthCharityCommitment(f: TruthCharityCommitmentFixture, source = f.game, copy: 0 | 1 = 0) { + const declared = declareTruthCharityPayments(f, source, copy).game; + return act(declared, f.asker, { type: 'truthAsk', question: f.charityQuestion }); +} +export function bindTruthCharityCommitment(f: TruthCharityCommitmentFixture, answer: 'yes' | 'no', source = f.game, copy: 0 | 1 = 0) { + const asked = askTruthCharityCommitment(f, source, copy); + assert.ok(viewGame(asked, f.target).truthCharityAnswers?.includes(answer)); + return { asked, game: act(asked, f.target, { type: 'truthAnswer', answer }) }; +} +export function rejectTruthCharityCommitment(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => act(game, actor, action)); + assert.deepEqual(game, before, 'Rejected actions preserve the input'); +} diff --git a/tests/truth-charity-commitment-controls.test.tsx b/tests/truth-charity-commitment-controls.test.tsx new file mode 100644 index 00000000..e8d44585 --- /dev/null +++ b/tests/truth-charity-commitment-controls.test.tsx @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { Children, createElement, isValidElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { viewGame, type Action, type GameView } from '../game/engine'; +import { act, askTruthCharityCommitment as ask, bindTruthCharityCommitment as bind, + createTruthCharityCommitmentFixture as fixture, inventory, player, rejectTruthCharityCommitment as reject } from './fixture-truth-charity-commitment'; +import { declareTruthCharityPayments } from './fixture-truth-charity-payments'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); + return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true }; + }, +}); +const { GameTable } = await import('../components/game-table'); +const { Truthtrance } = await import('../components/truthtrance'); +const { TruthCharityPromise } = await import('../components/truth-charity-promise'); +aliases.deregister(); +const table = (game: GameView) => renderToStaticMarkup(createElement(GameTable, { game, busy: false, send: async () => {}, onExit() {} })); +const truth = (game: GameView) => renderToStaticMarkup(createElement(Truthtrance, { game, busy: false, act() {} })); + +void test('Basic native Charity question, private answer controls and guided claim appear in the real table', () => { + const f = fixture(); const declared = declareTruthCharityPayments(f).game; + const asker = viewGame(declared, f.asker); + assert.ok(asker.truthCharityTargets.includes(f.target)); + assert.match(truth(asker), /