+ {game.charity.multiplier === 0
+ ? 'Inflation cancels charity for every faction this turn.'
+ : game.charity.multiplier === 2
+ ? 'Inflation doubles the amount of charity you receive. Ordinary eligibility still requires fewer than two spice; advanced Bene Gesserit receives four regardless of wealth.'
+ : game.advanced && me.faction === 'beneGesserit'
+ ? 'Receive two spice once per turn. If you already hold two or more, opponents may cancel the faction benefit with Karama.'
+ : 'If you have fewer than two spice, claim charity once to reach two.'}
+
+
+ {game.charity.payer
+ ? `The ordinary claim is ${game.charity.ordinary} spice. CHOAM pays as much of the ordinary claim as it can; the Spice Bank supplies any shortfall. This funding rule is provisional.`
+ : 'Charity is paid by the Spice Bank.'}
+ {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' : ''}.`}
+
+
+ >
+ );
+}
diff --git a/components/game-table.tsx b/components/game-table.tsx
index d28d51a7..deba4c8f 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -51,6 +51,7 @@ import { NexusAdvisors } from './nexus-advisors';
import { NexusSardaukar } from './nexus-sardaukar';
import { TerrorBoardMarkers } from './terror-board-markers';
import { AmbassadorBoardMarkers } from './ambassador-board-markers';
+import { CharityClaim } from './charity-claim';
import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options';
import { GuildHomeworldShipment } from './guild-homeworld-shipment';
import { JunctionTransport } from './junction-transport';
@@ -4204,35 +4205,7 @@ export function GameTable({
Ready the table to reveal the next spice blow.
)}
- {g.phase === 2 && (
- <>
-
- {g.charity.multiplier === 0
- ? 'Inflation cancels charity for every faction this turn.'
- : g.charity.multiplier === 2
- ? 'Inflation doubles the amount of charity you receive. Ordinary eligibility still requires fewer than two spice; advanced Bene Gesserit receives four regardless of wealth.'
- : g.advanced && me.faction === 'beneGesserit'
- ? 'Receive two spice once per turn. If you already hold two or more, opponents may cancel the faction benefit with Karama.'
- : 'If you have fewer than two spice, claim charity once to reach two.'}
-
-
- {g.charity.payer
- ? `The ordinary ${g.charity.ordinary} spice is paid from ${g.players.find((p) => p.id === g.charity.payer)?.name}’s spice.`
- : 'Charity is paid by the Spice Bank.'}
- {g.charity.homeworld > 0 &&
- ` Low Homeworld population adds ${g.charity.homeworld} spice directly from the bank${g.charity.multiplier === 2 ? ' after Inflation doubles the bonus' : ''}.`}
-
- {actionButton(
- g.charity.amount > 0
- ? `Claim ${g.charity.amount} charity spice`
- : 'Claim charity',
- { type: 'charity' },
- me.charityClaimed ||
- g.charity.incomePending ||
- g.charity.amount === 0,
- )}
- >
- )}
+ {g.phase === 2 && }
{g.phase === 3 && g.richeseAuction && (
,
+ beneficiary?: string,
) {
return g.choamCharity?.turn === g.turn && !g.choamCharity.canceled
- ? g.players.find((p) => p.faction === 'choam')
+ ? g.players.find((p) => p.faction === 'choam' && p.id !== beneficiary)
: undefined;
}
+
+export class CharityFundingError extends Error {}
+
+/** Preview policy: preserve the claimant's full entitlement without negative
+ * cash. Only the ordinary portion can debit another faction's CHOAM wallet;
+ * the bank covers any shortfall and every Homeworld addition. No loan is made.
+ * Keep this private calculation out of rival projections and public logs. */
+export function quoteCharityFunding(
+ amounts: { ordinary: number; homeworld: number },
+ payerSpice: number | null,
+): { choam: number; bank: number } {
+ const { ordinary, homeworld } = amounts;
+ if (![ordinary, homeworld].every(n => Number.isSafeInteger(n) && n >= 0) ||
+ !Number.isSafeInteger(ordinary + homeworld) ||
+ (payerSpice !== null && (!Number.isSafeInteger(payerSpice) || payerSpice < 0)))
+ throw new CharityFundingError('The charity funding amounts are invalid.');
+ const choam = payerSpice === null ? 0 : Math.min(ordinary, payerSpice);
+ return { choam, bank: ordinary - choam + homeworld };
+}
diff --git a/game/engine.ts b/game/engine.ts
index fe959f8d..1c145759 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -536,6 +536,8 @@ import {
charityAmount,
charityQuote,
charityPayer,
+ quoteCharityFunding,
+ CharityFundingError,
charityMultiplier,
type Inflation,
} from './charity';
@@ -21336,20 +21338,20 @@ function payCharity(g: Game, player: Player, amount: number, homeworld = 0) {
'The Homeworld charity payment is invalid.',
);
const ordinary = amount - homeworld;
- const payer = charityPayer(g);
- // The published charity rule does not specify insolvency after intervening
- // spending. Fail atomically while this edge case remains under audit; never
- // mint a bank subsidy or create a negative spice balance silently.
- requireRule(
- !payer || payer.spice >= ordinary,
- 'CHOAM cannot fund this charity claim.',
- );
- if (payer) payer.spice -= ordinary;
+ const payer = charityPayer(g, player.id);
+ let funding: ReturnType;
+ try {
+ funding = quoteCharityFunding({ ordinary, homeworld }, payer?.spice ?? null);
+ } catch (error) {
+ if (error instanceof CharityFundingError) throw new RuleError(error.message);
+ throw error;
+ }
+ if (payer) payer.spice -= funding.choam;
player.spice += amount;
if (homeworld)
log(
g,
- `${player.name} received ${ordinary} ordinary charity spice ${payer ? `from ${payer.name}` : 'from the Spice Bank'} and ${homeworld} additional bank spice for low Homeworld population.`,
+ `${player.name} received ${ordinary} ordinary charity spice ${payer ? `under ${payer.name}’s provisional funding rule` : 'from the Spice Bank'} and ${homeworld} additional bank spice for low Homeworld population.`,
{ faction: player.faction, name: 'Homeworld charity' },
);
techIncome(g, 'production', player);
@@ -36737,7 +36739,7 @@ export function viewGame(state: Game, id: string) {
...charityQuote(g, me),
multiplier: charityMultiplier(g),
amount: charityAmount(g, me),
- payer: charityPayer(g)?.id ?? null,
+ payer: charityPayer(g, me.id)?.id ?? null,
incomePending: !!byFaction(g, 'choam') && g.choamCharity?.turn !== g.turn,
incomeCanceled:
g.choamCharity?.turn === g.turn && g.choamCharity.canceled,
diff --git a/game/reference.ts b/game/reference.ts
index 7d75ff1d..7e757f01 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4080,10 +4080,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'CHOAM collects before charity claims and holds up to five treachery cards.',
steps: [
'At the start of Charity, resolve CHOAM’s public Karama response. If allowed, CHOAM receives two spice per faction, including itself. In a six-player game this is twelve spice.',
- 'Other factions claim normally, with payments taken from CHOAM’s spice. Advanced Bene Gesserit receives two even when wealthy; its cancelable claim resolves before CHOAM pays.',
- 'Canceling CHOAM’s income sends all charity payments that turn to the bank. CHOAM can then claim ordinary charity if it has zero or one spice. Cancellation does not affect next turn’s income.',
+ 'Other factions claim normally from CHOAM. Under an explicit provisional shortfall policy, CHOAM pays the available ordinary contribution and the Spice Bank supplies any remainder, preserving the full eligible claim. This unresolved funding choice can shift costs to the bank after voluntary transfers. Advanced Bene Gesserit receives two even when wealthy; its cancelable claim resolves before any payment.',
+ 'Canceling CHOAM’s income sends all charity payments that turn to the bank. CHOAM’s own ordinary poverty claim also uses the bank, even after allowed opening income, because its faction payer rule names other factions. Every claimant still has one eligible claim per turn. Cancellation does not affect next turn’s income.',
'CHOAM may hold five treachery cards and can bid for a fifth. A full five-card hand prevents further purchases, including purchases with Karama.',
- 'Inflation now has engine support; see its topic for the two-turn lifecycle. Insolvency after intervening spending still needs a rules ruling; unsupported claims fail without altering balances. CHOAM games remain disabled until the remaining powers and full interaction audit are complete.',
+ 'Inflation retains its two-turn lifecycle and original wealth threshold. Homeworld additions remain bank-funded regardless of the ordinary payer. Controls explain the provisional shortfall rule without exposing private wallets. CHOAM starts remain labelled previews; full faction, combination and official insolvency acceptance remain unfinished.',
],
related: [
'charity',
@@ -4098,6 +4098,60 @@ export const RULE_TOPICS: RuleTopic[] = [
'choam-worthless',
'bg-charity-karama',
],
+ checklist: [
+ {
+ "area": "Implementation",
+ "status": "Partial",
+ "detail": "Beneficiary-aware payer fixes CHOAM self-claims. A private quote caps another CHOAM wallet at the ordinary contribution and supplies the provisional bank residual, retaining full entitlement, BG response, once-turn claims, Inflation and Homeworld bank portions. No debt or new persisted field.",
+ "evidence": [
+ "game/charity.ts",
+ "game/engine.ts"
+ ]
+ },
+ {
+ "area": "Player controls",
+ "status": "Partial",
+ "detail": "Actual persistent claim controls distinguish ordinary bank payment from the constant provisional CHOAM fallback. Busy, pending income, completed/zero claims, Truthtrance and Nexus Traitor interruption remain blocked; no rival balance is read.",
+ "evidence": [
+ "components/charity-claim.tsx",
+ "components/game-table.tsx",
+ "tests/charity-claim-controls.test.tsx"
+ ]
+ },
+ {
+ "area": "AI",
+ "status": "Partial",
+ "detail": "Eight existing-profile Basic/Advanced paths accept16 legal Charity proposals for both low factions through the same settlement. Strategy, full AI and difficulty calibration remain deferred.",
+ "evidence": [
+ "game/bots.ts",
+ "docs/CHOAM_CHARITY_FUNDING.md"
+ ]
+ },
+ {
+ "area": "Documentation",
+ "status": "Partial",
+ "detail": "Separates the sourced self-bank composition from the unconfirmed bank-residual remedy, including the cost-shifting limitation and unfinished full-rule gates.",
+ "evidence": [
+ "docs/CHOAM_CHARITY_FUNDING.md",
+ "docs/RULE_DECISIONS.md"
+ ]
+ },
+ {
+ "area": "Verification",
+ "status": "Partial",
+ "detail": "Nine files88/88, types/lint/build, eight actual control/gameplay/GameTable SSR journeys and eight minimal-policy continuations pass. Genuine24/27-action public setup/income/bribe sources preserve original claims, physical pieces/cards and deferred receipts through Bidding. Browser/deployed, complete modes, broader combinations and comprehensive assurance remain open.",
+ "evidence": [
+ "tests/fixture-choam-charity-funding.ts",
+ "tests/choam-charity-funding-engine.test.ts",
+ "tests/charity-funding-quote.test.ts",
+ "tests/charity-claim-controls.test.tsx",
+ "tests/choam-charity.test.ts",
+ "tests/homeworld-charity-engine.test.ts",
+ "tests/choam-inflation.test.ts",
+ "tests/tech-tokens.test.ts"
+ ]
+ }
+],
},
{
id: 'bg-charity-karama',
diff --git a/tests/charity-claim-controls.test.tsx b/tests/charity-claim-controls.test.tsx
new file mode 100644
index 00000000..a3ee5afb
--- /dev/null
+++ b/tests/charity-claim-controls.test.tsx
@@ -0,0 +1,176 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { Children, createElement, isValidElement, type ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { CharityClaim } from '../components/charity-claim';
+import {
+ createGame,
+ newPlayer,
+ viewGame,
+ type Action,
+ type GameView,
+} from '../game/engine';
+import type { FactionId } from '../game/catalog';
+
+// The client consumes entitlement and funding projections. Source tests cover
+// the actual CHOAM debit and provisional bank shortfall without private shares.
+function projected({
+ advanced = false,
+ faction = 'emperor',
+ spice = 0,
+}: {
+ advanced?: boolean;
+ faction?: FactionId;
+ spice?: number;
+} = {}): GameView {
+ const game = createGame('CHARITYCONTROLS', newPlayer('p', 'Claimant', faction), advanced, ['choam']);
+ game.players.push(newPlayer('other', 'Other', faction === 'choam' ? 'emperor' : 'choam'));
+ Object.assign(game, {
+ status: 'playing', phase: 2, turn: 3, order: ['p', 'other'],
+ choamCharity: { turn: 3, canceled: false },
+ });
+ game.players[0].spice = spice;
+ return viewGame(game, 'p');
+}
+
+function markup(game: GameView, busy = false) {
+ return renderToStaticMarkup(createElement(CharityClaim, { game, busy, act() {} }));
+}
+
+function click(node: ReactNode): boolean {
+ for (const child of Children.toArray(node)) {
+ if (!isValidElement<{
+ children?: ReactNode;
+ onClick?: () => void;
+ 'data-testid'?: string;
+ }>(child)) continue;
+ if (child.props['data-testid'] === 'charity-claim-submit' && child.props.onClick) {
+ child.props.onClick();
+ return true;
+ }
+ if (click(child.props.children)) return true;
+ }
+ return false;
+}
+
+function claim(game: GameView, busy = false): Action[] {
+ const actions: Action[] = [];
+ click(CharityClaim({ game, busy, act(action) { actions.push(action); } }));
+ return actions;
+}
+
+void test('the actual charity control submits one ordinary claim action from its current entitlement', () => {
+ const game = projected({ spice: 1 });
+ assert.match(markup(game), /If you have fewer than two spice, claim charity once to reach two/);
+ assert.match(markup(game), /Claim 1 charity spice/);
+ assert.deepEqual(claim(game), [{ type: 'charity' }]);
+});
+
+void test('busy, pending income, a completed claim and zero entitlement block the actual callback', () => {
+ const game = projected();
+ assert.deepEqual(claim(game, true), []);
+ assert.match(markup(game, true), /