Support Truthtrance charity commitments in Basic play

This commit is contained in:
Quintine committed 2026-10-27 19:07:38 +11:00
1 parent 6a31315c95
commit 72ff9db5e8
18 files changed
+815 -5

No files matched your search

+2 -1
View File
@@ -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' : ''}.`}
</p>
{game.charity.truthBlocked && <p className="notice" data-testid="charity-truth-blocked">{game.charity.truthBlocked}</p>}
<Button
data-testid="charity-claim-submit"
className="game-action"
+4 -1
View File
@@ -181,6 +181,7 @@ import { ChoamCashIn } from './choam-cash-in';
import { ChoamMarket } from './choam-market';
import { BattlePromises } from './battle-promises';
import { Truthtrance, TruthHistory } from './truthtrance';
import { TruthCharityPromise } from './truth-charity-promise';
import {
forceRevivalPrice,
eliteRevivalRemaining,
@@ -2077,6 +2078,7 @@ export function GameTable({
{g.status === 'playing' && g.phase < 5 && (
<ShipmentPromises game={g} act={act} busy={busy} />
)}
{g.status === 'playing' && <TruthCharityPromise game={g} send={act} busy={busy} />}
{g.status === 'playing' && <BribePromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <BribeExpressionPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <MixedBribePromises game={g} send={act} busy={busy} />}
@@ -5839,6 +5841,7 @@ export function GameTable({
)}
</>
)}
{g.phase === 2 && g.truthCharityGuide && <p className="notice">Claim Charity before finishing your Truthtrance Yes.</p>}
{[1, 2, 4, 7, 8].includes(g.phase) && !(g.phase === 4 && g.revivalOrder) && !(g.phase === 2 && g.charityOrder) &&
actionButton(
g.ready.includes(me.id)
@@ -5847,7 +5850,7 @@ export function GameTable({
? 'Accept spice blow'
: 'Ready for next phase',
{ type: 'ready' },
g.ready.includes(me.id),
g.ready.includes(me.id) || (g.phase === 2 && !!g.truthCharityGuide),
)}
</>
)}
+49
View File
@@ -0,0 +1,49 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { bribeAction } from '@/game/bribe-options';
import { Button } from './ui/button';
type Props = { game: GameView; busy: boolean; send: (action: Action) => void };
/** The server supplies an exact native claim or payment, never a simulated transfer. */
export function TruthCharityPromise({ game, busy, send }: Props) {
const own = game.truthCharityPromises.filter(promise => promise.turn === game.turn && promise.player === game.me);
if (!own.length) return null;
const guide = game.truthCharityGuide, actor = game.me, turn = game.turn, action = guide?.action;
const signature = JSON.stringify(action), event = action?.charityPromiseEvent;
const canAct = () => {
const me = game.players.find(player => player.id === actor);
const recipient = typeof action?.target === 'string' ? game.players.find(player => player.id === action.target) : undefined;
const amount = action?.amount;
const payment = action?.type === 'bribe' && !!me && !!recipient && recipient.id !== actor && recipient.id !== me.ally &&
typeof amount === 'number' && !!bribeAction(game.bribeOptions, recipient.id, amount);
const claim = action?.type === 'charity' && !!me && !me.charityClaimed &&
!game.ready.includes(actor) && !game.charity.truthBlocked && !game.charity.incomePending &&
!game.charity.orderBlocked && game.charity.amount > 0;
return !busy && !!guide && !!action && game.truthCharityGuide === guide && guide.action === action &&
JSON.stringify(action) === signature && typeof event === 'string' && action.charityPromiseTurn === turn &&
game.truthCharityPromises.some(promise => promise.player === actor && promise.turn === turn &&
promise.event === event && promise.answer && !promise.fulfilled && !promise.released) &&
game.status === 'playing' && game.phase === 2 && game.me === actor && game.turn === turn &&
!!me && !me.autopilot && (payment || claim) && !game.truthtrance && !game.response &&
!game.decision && !game.phaseOpening && !game.automaticContinuationPending &&
!game.roomControl?.paused && !game.roomControl?.closed;
};
const recipient = game.players.find(player => player.id === action?.target)?.name ?? 'the quoted recipient';
return <section className="notice space-y-3" aria-label="Your Truthtrance Charity promises" id="charity-promise-guidance">
<h3>Your Charity promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
You answered {promise.answer ? 'Yes' : 'No'} to claiming Charity in this phase.
{' '}{promise.released ? 'Released: this commitment is no longer possible.'
: promise.fulfilled ? 'Fulfilled: this commitment is complete.' : 'This answer remains binding while possible.'}
</li>)}</ul>
<p>Yes requires an actual eligible claim after the answer; No forbids claiming during the rest of this Charity phase. Earlier claims do not count.</p>
{guide && action?.type === 'bribe' && <p className="fine">The quoted payment can make you eligible for Charity. You pay immediately; the other player receives the bribe at the start of Mentat Pause.</p>}
{guide && action && <Button className="game-action min-h-11 whitespace-normal" data-testid="charity-promise-guide"
disabled={!canAct()} onClick={() => { if (canAct()) send(action); }}>
{action.type === 'bribe' ? `Pay ${typeof action.amount === 'number' ? action.amount : 'the quoted'} spice to ${recipient} to prepare for Charity`
: `Claim ${game.charity.amount} Charity spice to honor your answer`}
</Button>}
</section>;
}
+18 -2
View File
@@ -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 })}
</div>)}
</>
) : w.question!.kind === 'charity' ? (
<>
<p className="notice">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.</p>
{(g.truthCharityAnswers ?? []).map(answer => <div key={answer}>
{button(answer === 'unknown' ? 'I don’t know' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
</div>)}
</>
) : w.question!.kind === 'richeseBid' ? (
<>
<p className="notice">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.</p>
@@ -600,6 +608,7 @@ export function Truthtrance({
<option value="leaderRevival">Bind a named leader revival this turn</option>
<option value="revival">Bind ordinary force revivals this turn</option>
<option value="bid">Bind a bid in this ordinary lot</option>
<option value="charity">Bind a Charity claim this phase</option>
<option value="phaseBid">Bind a bid this Bidding phase</option>
<option value="richeseBid">Bind a bid in this Richese lot</option>
<option value="mixedBid">Combine current facts and ordinary bids</option>
@@ -745,6 +754,12 @@ export function Truthtrance({
{!g.truthRevivalTargets.includes(target) && <p className="notice">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.</p>}
<p className="notice" aria-label="Public revival question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'charity' ? (
<>
<p className="fine">Ask whether the selected player will claim Charity after answering during this phase. Earlier claims do not count; native eligibility and payment still apply.</p>
{!g.truthCharityTargets.includes(target) && <p className="notice">Choose a player with a supported remaining Charity opportunity.</p>}
<p className="notice" aria-label="Public Charity question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'richeseBid' ? (
<>
<RicheseBidPromiseFields minimum={bidMinimum} onMinimum={setBidMinimum} disabled={busy || !g.richeseAuction} method={g.richeseAuction?.method === 'silent' ? 'silent' : 'onceAround'} />
@@ -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 }) {
<details className="notice truthtrance-history">
<summary>Truthtrance answers · Turn {g.turn}</summary>
<p className="fine">
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.
</p>
+14
View File
@@ -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
+20
View File
@@ -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
+14
View File
@@ -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
+56
View File
@@ -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.
+6
View File
@@ -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' }];
+116
View File
@@ -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<string>();
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<T extends CharityPromise>(
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<T extends CharityPromise>(
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<T extends CharityPromise>(
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;
});
}
+106
View File
@@ -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<T>(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<T>(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),
+24 -1
View File
@@ -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',
+25
View File
@@ -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<MovementClaim> };
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.');
+87
View File
@@ -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');
});
+1
View File
@@ -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);
+45
View File
@@ -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<typeof createTruthCharityCommitmentFixture>;
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');
}
@@ -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), /<option value="charity">[^<]*[Cc]harity/);
assert.match(table(asker), /[Cc]harity/);
// SSR confirms the selection is present; engine actions supply the question.
const asked = ask(f), answerView = viewGame(asked, f.target);
assert.equal(answerView.truthtrance?.question?.kind, 'charity');
assert.ok(answerView.truthCharityAnswers?.includes('yes'));
assert.ok(answerView.truthCharityAnswers?.includes('no'));
assert.equal(viewGame(asked, f.asker).truthCharityAnswers, null);
assert.match(truth(answerView), /Answer Yes/);
assert.match(truth(answerView), /Answer No/);
assert.match(table(answerView), /Answer Yes/);
const bound = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const own = viewGame(bound, f.target), guide = own.truthCharityGuide;
assert.ok(guide); assert.equal(guide.action.type, 'charity');
assert.match(table(own), /[Cc]harity/);
const claimed = act(bound, f.target, guide.action);
assert.equal(player(claimed, f.target).charityTurn, claimed.turn);
assert.deepEqual(inventory(claimed), f.originalCards);
});
void test('Basic No answer keeps its own readable Charity restriction and blocks the physical claim', () => {
const f = fixture(), bound = bind(f, 'no').game;
const own = viewGame(bound, f.target);
assert.match(table(own), /[Cc]harity/);
assert.throws(() => act(bound, f.target, { type: 'charity' }));
assert.deepEqual(inventory(bound), f.originalCards);
});
void test('A live Charity bribe guide sends the native packet and cannot replay in a No continuation', () => {
const f = fixture(), asked = ask(f, f.unprepared);
const yes = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const own = viewGame(yes, f.target), sent: Action[] = [];
assert.equal(own.truthCharityGuide?.action.type, 'bribe');
const panel = TruthCharityPromise({ game: own, busy: false, send(action) { sent.push(action); } });
assert.ok(panel);
type GuideProps = { 'data-testid'?: string; disabled?: boolean; onClick?: () => void };
const button = Children.toArray(panel.props.children).find(node =>
isValidElement<GuideProps>(node) && node.props['data-testid'] === 'charity-promise-guide');
assert.ok(isValidElement<GuideProps>(button));
assert.equal(button.props.disabled, false);
assert.ok(button.props.onClick); button.props.onClick();
assert.deepEqual(sent, [own.truthCharityGuide!.action]);
const paid = act(yes, f.target, sent[0]);
assert.ok(player(paid, f.target).spice < 2);
const no = act(asked, f.target, { type: 'truthAnswer', answer: 'no' });
reject(no, f.target, sent[0]);
});
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { charityQuote } from '../game/charity';
import { declareTruthCharityPayments } from './fixture-truth-charity-payments';
import { viewGame } from '../game/engine';
import { act, askTruthCharityCommitment as ask, bindTruthCharityCommitment as bind,
createTruthCharityCommitmentFixture as fixture, inventory, player, rejectTruthCharityCommitment as reject } from './fixture-truth-charity-commitment';
void test('Basic eligible Charity Yes reopens ready and requires one actual native claim', () => {
const f = fixture();
const source = act(f.game, f.target, { type: 'ready' });
assert.ok(source.ready.includes(f.target));
const { asked, game: bound } = bind(f, 'yes', source);
assert.equal(viewGame(asked, f.asker).truthCharityAnswers, null);
assert.ok(!bound.ready.includes(f.target));
assert.equal(bound.discard.filter(card => card.id === f.truth).length, 1);
assert.equal(player(bound, f.target).charityTurn, undefined);
reject(bound, f.target, { type: 'ready' });
const guide = viewGame(bound, f.target).truthCharityGuide; assert.ok(guide);
assert.equal(guide.action.type, 'charity');
const claimed = act(bound, f.target, guide.action);
assert.equal(player(claimed, f.target).charityTurn, claimed.turn);
assert.equal(player(claimed, f.target).spice, 2);
assert.equal(charityQuote(claimed, player(claimed, f.target)).total, 0);
reject(claimed, f.target, { type: 'charity' });
const ready = act(claimed, f.target, { type: 'ready' });
assert.ok(ready.ready.includes(f.target));
assert.deepEqual(inventory(ready), f.originalCards);
});
void test('Basic Charity No prohibits the claim through ready and normal phase close', () => {
const f = fixture(); const source = act(f.game, f.target, { type: 'ready' });
const { game: bound } = bind(f, 'no', source);
assert.ok(!bound.ready.includes(f.target));
reject(bound, f.target, { type: 'charity' });
let game = act(bound, f.target, { type: 'ready' });
assert.ok(game.ready.includes(f.target));
reject(game, f.target, { type: 'charity' });
// A second actual Truthtrance answer reopens concurrent Basic readiness.
// The first No must still forbid claiming through the same Charity phase.
game = bind(f, 'no', game, 1).game;
assert.ok(!game.ready.includes(f.target));
reject(game, f.target, { type: 'charity' });
game = act(game, f.target, { type: 'ready' });
for (const p of game.players) if (!game.ready.includes(p.id) && game.phase === 2)
game = act(game, p.id, { type: 'ready' });
assert.notEqual(game.phase, 2);
assert.equal(player(game, f.target).charityTurn, undefined);
assert.equal(player(game, f.target).spice, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('Basic stale Charity questions and wrong answerers cannot change the native phase', () => {
const f = fixture();
const declared = declareTruthCharityPayments(f).game;
reject(declared, f.asker, { type: 'truthAsk', question: { ...f.charityQuestion, turn: f.game.turn + 1 } });
const asked = ask(f);
reject(asked, f.asker, { type: 'truthAnswer', answer: 'yes' });
assert.deepEqual(inventory(asked), f.originalCards);
});
void test('Basic all four minimal bot profiles answer and use an allowed Charity continuation', () => {
const f = fixture(); const asked = ask(f);
for (const difficulty of DIFFICULTIES) {
const answerView = viewGame(asked, f.target);
answerView.players.find(row => row.id === f.target)!.bot = difficulty;
const answer = botActions(answerView)[0]; assert.ok(answer, difficulty);
assert.equal(answer.type, 'truthAnswer');
assert.ok(answerView.truthCharityAnswers?.includes(answer.answer as 'yes' | 'no'));
const bound = act(structuredClone(asked), f.target, answer);
if (answer.answer === 'yes') {
const own = viewGame(bound, f.target); own.players.find(row => row.id === f.target)!.bot = difficulty;
assert.ok(own.truthCharityGuide);
const choice = botActions(own)[0]; assert.deepEqual(choice, own.truthCharityGuide.action);
const after = act(bound, f.target, choice);
assert.equal(player(after, f.target).charityTurn, after.turn);
assert.deepEqual(inventory(after), f.originalCards);
} else {
reject(bound, f.target, { type: 'charity' });
const own = viewGame(bound, f.target); own.players.find(row => row.id === f.target)!.bot = difficulty;
const choice = botActions(own)[0]; assert.ok(choice, difficulty);
const after = act(bound, f.target, choice);
assert.deepEqual(inventory(after), f.originalCards);
}
}
});
void test('Basic Charity Yes at five spice guides an actual bribe and claim; its guide cannot replay against No', () => {
const f = fixture();
// This is the actual pre-bribe native source, with five spendable spice.
assert.equal(player(f.unprepared, f.target).spice, 5);
const asked = ask(f, f.unprepared);
assert.ok(viewGame(asked, f.target).truthCharityAnswers?.includes('yes'));
const yes = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
reject(yes, f.target, { type: 'ready' });
const bribe = viewGame(yes, f.target).truthCharityGuide; assert.ok(bribe);
assert.equal(bribe.action.type, 'bribe');
assert.ok(typeof bribe.action.amount === 'number' && bribe.action.amount >= 4);
const paid = act(yes, f.target, bribe.action);
assert.ok(player(paid, f.target).spice < 2);
assert.ok(viewGame(paid, f.target).truthCharityPromises.some(row => row.answer && !row.fulfilled && !row.released));
reject(paid, f.target, bribe.action);
const claim = viewGame(paid, f.target).truthCharityGuide; assert.ok(claim);
assert.equal(claim.action.type, 'charity');
const claimed = act(paid, f.target, claim.action);
assert.equal(player(claimed, f.target).charityTurn, claimed.turn);
assert.ok(viewGame(claimed, f.target).truthCharityPromises.some(row => row.answer && row.fulfilled));
assert.deepEqual(inventory(claimed), f.originalCards);
// The same actual question has a separate valid No continuation; a normal
// bribe remains legal here, so rejecting the old Yes packet requires its
// original guide identity, not simply a no-bribes rule.
const no = act(asked, f.target, { type: 'truthAnswer', answer: 'no' });
reject(no, f.target, bribe.action);
const ordinaryBribe = act(no, f.target, { type: 'bribe', target: f.asker, amount: 1 });
assert.equal(player(ordinaryBribe, f.target).spice, 4);
assert.deepEqual(inventory(ordinaryBribe), f.originalCards);
});
void test('A controlled external spice gain retains Charity Yes while an actual bribe route remains', () => {
const f = fixture(); const bound = bind(f, 'yes').game;
assert.equal(player(bound, f.target).spice, 1);
// Controlled supplement: transfer three actual spice between two seated
// wallets, representing an external gain. The native fixture makes Emperor
// the respondent, so it cannot receive its own allied Emperor gift. No card,
// phase, readiness or promise is manufactured; the sum stays unchanged.
const changed = structuredClone(bound);
const donor = changed.players.find(p => p.id !== f.target && p.spice >= 3); assert.ok(donor);
const total = changed.players.reduce((sum, p) => sum + p.spice, 0);
donor.spice -= 3; player(changed, f.target).spice += 3;
assert.equal(changed.players.reduce((sum, p) => sum + p.spice, 0), total);
const observer = changed.players.find(p => p.id !== f.target && !changed.ready.includes(p.id)); assert.ok(observer);
const reconciled = act(changed, observer.id, { type: 'ready' });
assert.equal(player(reconciled, f.target).spice, 4);
assert.ok(viewGame(reconciled, f.target).truthCharityPromises.some(row => row.answer && !row.fulfilled && !row.released));
reject(reconciled, f.target, { type: 'ready' });
const bribe = viewGame(reconciled, f.target).truthCharityGuide; assert.ok(bribe);
assert.equal(bribe.action.type, 'bribe');
const paid = act(reconciled, f.target, bribe.action);
assert.ok(player(paid, f.target).spice < 2);
const claim = viewGame(paid, f.target).truthCharityGuide; assert.ok(claim);
assert.equal(claim.action.type, 'charity');
const completed = act(paid, f.target, claim.action);
assert.equal(player(completed, f.target).charityTurn, completed.turn);
assert.deepEqual(inventory(completed), f.originalCards);
});