Settle CHOAM charity claims after depleted funding
Keep CHOAM's own poverty claim bank-funded. Prototype full other-faction claims using available CHOAM cash and an explicitly provisional bank residual; retain original eligibility, cancellation, Homeworld portions and claim limits. Connect persistent controls and genuine public setup/bribe evidence. Complete rules and deployed acceptance remain open.
This commit is contained in:
1 parent
52832dc0fd
commit
d63efccb55
15 files changed
+798
-58
No files matched your search
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
|
||||
export function CharityClaim({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const me = game.players.find((player) => player.id === game.me);
|
||||
if (game.status !== 'playing' || game.phase !== 2 || !me) return null;
|
||||
const disabled = busy || !!game.truthtrance || !!game.nexusTraitors?.pending ||
|
||||
!!me.charityClaimed || game.charity.incomePending || game.charity.amount === 0;
|
||||
return (
|
||||
<>
|
||||
<p className="muted">
|
||||
{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.'}
|
||||
</p>
|
||||
<p className="fine">
|
||||
{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' : ''}.`}
|
||||
</p>
|
||||
<Button
|
||||
data-testid="charity-claim-submit"
|
||||
className="game-action"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled) act({ type: 'charity' });
|
||||
}}
|
||||
>
|
||||
{game.charity.amount > 0
|
||||
? `Claim ${game.charity.amount} charity spice`
|
||||
: 'Claim charity'}
|
||||
<ArrowRight size={15} />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
</p>
|
||||
)}
|
||||
{g.phase === 2 && (
|
||||
<>
|
||||
<p className="muted">
|
||||
{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.'}
|
||||
</p>
|
||||
<p className="fine">
|
||||
{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' : ''}.`}
|
||||
</p>
|
||||
{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 && <CharityClaim game={g} act={act} busy={busy} />}
|
||||
{g.phase === 3 && g.richeseAuction && (
|
||||
<RicheseAuctionLot
|
||||
key={`${g.richeseAuction.event}-${g.me}`}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# CHOAM Charity funding
|
||||
|
||||
## Source and selected prototype policy — 24 October 2026
|
||||
|
||||
Publisher E2 physical p7 says CHOAM collects two spice per faction before
|
||||
Charity claims, then pays **other** factions' claims from its spice.
|
||||
The base physical p8 poverty rule still applies to any faction with zero/one
|
||||
spice. Composing those permissions, CHOAM's own eligible ordinary claim uses
|
||||
the Spice Bank; debiting its own wallet would either make the claim ineffective
|
||||
or reject it at zero. Eligibility and the once-per-turn stamp are unchanged.
|
||||
[Official E2](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf),
|
||||
[official base rules](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf).
|
||||
|
||||
E2 p12 explicitly makes other claims bank-funded when CHOAM's opening advantage
|
||||
is canceled. Targeted publisher/designer research found no corresponding
|
||||
insolvency remedy after CHOAM legally spends or transfers its opening cash.
|
||||
Indexed primary passages remained accessible; direct E2 and designer downloads
|
||||
returned403. No community implementation or tournament rules settle this choice.
|
||||
The self-claim composition above is separate from the following unresolved policy.
|
||||
|
||||
Under the user's 14 October choose-recommended instruction, the preview now
|
||||
pays each full eligible claim, debits another faction's CHOAM wallet up to its
|
||||
available cash, and supplies the ordinary shortfall from the Spice Bank.
|
||||
This is an explicit **provisional deviation**, not a publisher-confirmed
|
||||
insolvency rule. It avoids lost entitlements, negative wallets and a blocked
|
||||
claim without retroactively prohibiting legal transfers or inventing a future
|
||||
debt/repayment mechanism. It also lets voluntary transfers shift Charity costs
|
||||
to the bank; that economic consequence remains a limitation pending a stronger
|
||||
ruling. This prototype cannot certify full CHOAM or complete rules compliance.
|
||||
|
||||
Homeworld additions remain entirely bank-funded. Inflation changes the
|
||||
entitlement first; it never changes the wealth threshold. A wealthy Advanced
|
||||
Bene Gesserit claim retains its original Karama response before any payment;
|
||||
cancellation spends the claim but pays nothing. Current canceled CHOAM income
|
||||
and absent/older income receipts retain their existing bank behavior. No loan,
|
||||
new persistent field, incoming-bribe acceleration, claim repetition or phase
|
||||
advance is introduced. Production income remains tied to the original completed
|
||||
qualifying claim, not to the CHOAM/bank split or opening income.
|
||||
|
||||
## Implementation and controls
|
||||
|
||||
`game/charity.ts` supplies the original entitlement, beneficiary-aware payer
|
||||
and private funding quote. `payCharity` in `game/engine.ts` validates before
|
||||
settlement, debits only the available ordinary contribution and awards the
|
||||
original full amount exactly once. The bank remainder is implicit in the
|
||||
existing unlimited-bank accounting; no foreign wallet becomes negative.
|
||||
The public projection supplies only the existing nominal payer and entitlement,
|
||||
never a wallet-dependent split. Homeworld logs use constant funding-policy
|
||||
wording rather than exposing a private shortfall.
|
||||
|
||||
`components/charity-claim.tsx` is the actual table control. CHOAM sees normal
|
||||
bank guidance for its own claim. Other beneficiaries see the same persistent
|
||||
provisional funding explanation regardless of CHOAM's private balance. Busy,
|
||||
pending income, completed claims, absent entitlement, Truthtrance and Nexus
|
||||
Traitor interruption retain their button blocks. The existing minimal legal
|
||||
bot Charity action uses this same server settlement; no strategy is refined.
|
||||
|
||||
## Verification and remaining work
|
||||
|
||||
Owner-frozen proof passes **9 affected offline files88/88**, types/lint/build, eight actual claim-control/gameplay/GameTable SSR journeys and eight existing-profile continuations (16 accepted proposals).
|
||||
The public three-seat CHOAM/Atreides/Guild source uses real lobby readiness,
|
||||
Basic or explicit Advanced-preview start, native Traitor setup and the first
|
||||
native Storm/Spice/Charity chronology. Only original scalar shuffle order, three
|
||||
unopened Treachery positions and two unopened ordinary Spice positions are
|
||||
selected. Atreides keeps its original starting Karama, so passing the first
|
||||
CHOAM income response is a genuine response rather than an installed receipt.
|
||||
|
||||
CHOAM starts with two and earns six from that opening. Actual Guild and CHOAM
|
||||
bribes then leave zero/one spendable cash while their recipient's native deferred
|
||||
receipt remains unspendable. The fixture stops before either claim: 24/27
|
||||
source actions. No played phase, wallet, army, alliance, claim stamp or payment
|
||||
receipt is assigned. The actual new controls submit self or other claims with
|
||||
zero/partial funding, refuse while busy/already claimed and preserve original
|
||||
pieces/cards/bribes. Each source continues through actual readiness and market
|
||||
controls into Bidding. Eight existing-profile paths accept two legal Charity
|
||||
proposals each without strategy/calibration.
|
||||
|
||||
The nine-file union also covers existing BG cancellation, Inflation, Homeworld
|
||||
portions, Production and Semuta return around Charity. First feedback passes
|
||||
87/88: an old Homeworld test still expects zero-funding rejection. Its updated
|
||||
case preserves ordinary eligibility rejection and checks full entitlement,
|
||||
CHOAM debit at cash0/1/4, unchanged source and no duplicate claim. Focused
|
||||
feedback passes; the next union passes88/88 and type checking, with one const
|
||||
lint repair. The final frozen batch passes every step. Bounded source and
|
||||
integration review found no concrete blocker; this is not comprehensive review.
|
||||
|
||||
Private artifacts are in
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261024-choam-charity-funding/`,
|
||||
including `frozen-integrated-3/report.json`, `runtime-smoke.json`, HTML and
|
||||
`feedback.json`. Verified base `52832dc0fd8b07e5e25c88245d427a500c4523ba`, tree
|
||||
`73141dec573c215ec5e05aff208107783d65f16e99bedb480c1f10d28ef5bef7`
|
||||
(2692 nonignored files, identical before/after checks).
|
||||
Only checkpoint prose, reference strings and evidence paths follow proof; delivery
|
||||
records that reviewed overlay and pushed revision. The user PDF stays untracked.
|
||||
|
||||
Framework aliases/CSS stubs mean actual callback/gameplay/SSR evidence, not
|
||||
browser visual or deployed acceptance. Full modes, all rule combinations,
|
||||
comprehensive rules/recovery/privacy/custody assurance and final deployed
|
||||
acceptance remain unfinished. No server, saved-room/D1/reset/restart or deployment
|
||||
operation occurs here. The provisional residual is still an unresolved tabletop
|
||||
interpretation, even though its selected preview behavior is usable.
|
||||
@@ -16,6 +16,25 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**CHOAM Charity funding — 24 October. Prototyped / Partial.**
|
||||
CHOAM's own eligible poverty claim now uses the Spice Bank; E2 names other
|
||||
factions as its payees. After legal intervening spending, the selected preview
|
||||
policy pays other eligible claims in full from available CHOAM cash and a bank
|
||||
residual. This is an explicit provisional deviation: no publisher insolvency
|
||||
remedy was found, and voluntary transfers can shift costs to the bank.
|
||||
Persistent controls explain the policy without exposing private payment shares.
|
||||
Original eligibility, once-turn claims, BG cancellation, Inflation, bank-only
|
||||
Homeworld additions, Production and deferred bribes remain.
|
||||
[Source, policy and bounded evidence](CHOAM_CHARITY_FUNDING.md).
|
||||
|
||||
Disjoint source/fixture/controls lanes and bounded integration review pass
|
||||
**9 affected offline files88/88**, types/lint/build, eight actual claim-control/gameplay/GameTable SSR journeys and eight existing-profile continuations (16 accepted proposals).
|
||||
Actual public three-seat setup, first opening income and legal bribes supply
|
||||
24/27 source actions. Feedback updates an obsolete Homeworld rejection and
|
||||
one now-constant test binding. Full modes, wider combinations, comprehensive/
|
||||
browser/deployed acceptance and AI refinement remain unfinished. No saved-room/
|
||||
D1/reset/restart/deployment operation.
|
||||
|
||||
**Ecaz Ambassador on the placed HMS — 24 October. Prototyped / Partial.**
|
||||
The shared placement quote now includes the genuinely placed Hidden Mobile
|
||||
Stronghold in Basic and Advanced, including its intrinsic storm shelter.
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 24 October 2026 — CHOAM Charity self-claims and shortfall funding
|
||||
|
||||
Make the ordinary poverty claim effective for CHOAM itself using the bank,
|
||||
consistent with E2's other-faction payer language. For other eligible claims,
|
||||
prototype full entitlement from available CHOAM cash plus a bank residual after
|
||||
intervening legal spending. This second policy is provisional and permits
|
||||
transfer-driven cost shifting; no official insolvency remedy is claimed.
|
||||
Keep original claim limits, cancellation, Inflation, bank-only Homeworld bonus,
|
||||
Production and deferred incoming bribes. Actual persistent controls explain the
|
||||
policy independently of private wallet values; no new serialized field or debt.
|
||||
|
||||
Disjoint source/fixture/control lanes and bounded review pass **9 affected offline files88/88**, types/lint/build, eight actual claim-control/gameplay/GameTable SSR journeys and eight existing-profile continuations (16 accepted proposals).
|
||||
Original public CHOAM/Atreides/Guild setup, held starting Karama, actual first
|
||||
opening response and legal bribes supply24/27 source actions without an
|
||||
installed played phase, wallet, army or payment receipt. Actual callbacks check
|
||||
busy/once-only behavior and reach native Bidding; existing legal bots claim for
|
||||
both low factions. The first union finds an older Homeworld shortfall rejection;
|
||||
its replacement keeps ordinary eligibility, full amount, exact capped debit,
|
||||
immutable rejected/starting state and no second claim. A const lint fix follows.
|
||||
|
||||
[Source, selected policy, proof and limits](CHOAM_CHARITY_FUNDING.md)
|
||||
retain full-rule/combination and comprehensive/browser/deployed gates.
|
||||
Private final proof:20261024-choam-charity-funding/frozen-integrated-3.
|
||||
No strategy/calibration or saved-room/D1/reset/restart/deployment operation.
|
||||
|
||||
## 24 October 2026 — Ecaz Ambassador on the placed HMS
|
||||
|
||||
Share dynamic placement legality between the engine, projected choices,
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Rule decision index
|
||||
|
||||
## CHOAM self-claim and provisional shortfall funding — 24 October 2026
|
||||
|
||||
E2 p7 names other factions as CHOAM's payees; the ordinary poverty rule leaves
|
||||
CHOAM's own eligible claim bank-funded. After legal intervening spending,
|
||||
other eligible claims now receive their full amount from available CHOAM cash
|
||||
and a bank residual. This second rule is an explicit provisional deviation:
|
||||
no publisher insolvency remedy was found; transfers can shift costs to the
|
||||
bank. The 14 October choose-recommended authority applies. No debt, negative
|
||||
wallet, outgoing escrow or new start gate is introduced. Original claim limits,
|
||||
BG cancellation, Inflation and bank-only Homeworld additions remain.
|
||||
[Source, policy and limits](CHOAM_CHARITY_FUNDING.md#source-and-selected-prototype-policy--24-october-2026).
|
||||
|
||||
## Ecaz Ambassador on the placed HMS — 24 October 2026
|
||||
|
||||
Compose E3 any-stronghold placement (p7; authorized revised p30,A) with E1
|
||||
|
||||
+21
-1
@@ -38,8 +38,28 @@ export function charityAmount(
|
||||
|
||||
export function charityPayer(
|
||||
g: Pick<Game, 'players' | 'turn' | 'choamCharity'>,
|
||||
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 };
|
||||
}
|
||||
+13
-11
@@ -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<typeof quoteCharityFunding>;
|
||||
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,
|
||||
|
||||
+57
-3
@@ -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',
|
||||
|
||||
@@ -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), /<button\b[^>]*\sdisabled=""/);
|
||||
for (const field of ['incomePending', 'charityClaimed', 'amount'] as const) {
|
||||
const changed = structuredClone(game);
|
||||
if (field === 'incomePending') changed.charity.incomePending = true;
|
||||
if (field === 'charityClaimed') changed.players[0].charityClaimed = true;
|
||||
if (field === 'amount') changed.charity.amount = 0;
|
||||
assert.deepEqual(claim(changed), [], field);
|
||||
assert.match(markup(changed), /<button\b[^>]*\sdisabled=""/, field);
|
||||
}
|
||||
const noClaim = projected({ spice: 2 });
|
||||
assert.equal(noClaim.charity.amount, 0);
|
||||
assert.deepEqual(claim(noClaim), []);
|
||||
assert.match(markup(noClaim), />Claim charity<svg/);
|
||||
});
|
||||
|
||||
void test('charity controls preserve the existing Truthtrance and Nexus pending blocks and phase boundary', () => {
|
||||
const game = projected();
|
||||
const truthtrance = structuredClone(game);
|
||||
truthtrance.truthtrance = {} as NonNullable<GameView['truthtrance']>;
|
||||
assert.deepEqual(claim(truthtrance), []);
|
||||
const nexus = structuredClone(game);
|
||||
nexus.nexusTraitors = {
|
||||
offer: null,
|
||||
pending: {} as NonNullable<NonNullable<GameView['nexusTraitors']>['pending']>,
|
||||
};
|
||||
assert.deepEqual(claim(nexus), []);
|
||||
for (const field of ['phase', 'status', 'viewer'] as const) {
|
||||
const changed = structuredClone(game);
|
||||
if (field === 'phase') changed.phase = 3;
|
||||
if (field === 'status') changed.status = 'lobby';
|
||||
if (field === 'viewer') changed.me = 'missing';
|
||||
assert.equal(markup(changed), '', field);
|
||||
assert.deepEqual(claim(changed), [], field);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic and Advanced Bene Gesserit retain their separate eligibility and Karama guidance', () => {
|
||||
const basicPoor = projected({ faction: 'beneGesserit', spice: 1 });
|
||||
assert.equal(basicPoor.charity.amount, 1);
|
||||
assert.match(markup(basicPoor), /fewer than two spice/);
|
||||
assert.doesNotMatch(markup(basicPoor), /cancel the faction benefit with Karama/);
|
||||
assert.deepEqual(claim(basicPoor), [{ type: 'charity' }]);
|
||||
const basicRich = projected({ faction: 'beneGesserit', spice: 7 });
|
||||
assert.equal(basicRich.charity.amount, 0);
|
||||
assert.deepEqual(claim(basicRich), []);
|
||||
const advanced = projected({ advanced: true, faction: 'beneGesserit', spice: 7 });
|
||||
assert.equal(advanced.charity.amount, 2);
|
||||
assert.match(markup(advanced), /Receive two spice once per turn/);
|
||||
assert.match(markup(advanced), /opponents may cancel the faction benefit with Karama/);
|
||||
assert.match(markup(advanced), /Claim 2 charity spice/);
|
||||
assert.deepEqual(claim(advanced), [{ type: 'charity' }]);
|
||||
});
|
||||
|
||||
void test('CHOAM poverty itself projects a normal bank claim without a self-funded payment explanation', () => {
|
||||
const game = projected({ faction: 'choam', spice: 0 });
|
||||
assert.equal(game.charity.payer, null);
|
||||
assert.equal(game.charity.amount, 2);
|
||||
const html = markup(game);
|
||||
assert.match(html, /Charity is paid by the Spice Bank/);
|
||||
assert.doesNotMatch(html, /CHOAM pays as much|This funding rule is provisional/);
|
||||
assert.deepEqual(claim(game), [{ type: 'charity' }]);
|
||||
});
|
||||
|
||||
void test('Homeworld bank bonuses and Inflation guidance persist with their projected doubled amounts', () => {
|
||||
const game = projected();
|
||||
game.charity.homeworld = 1;
|
||||
game.charity.amount = 3;
|
||||
assert.match(markup(game), /Low Homeworld population adds 1 spice directly from the bank/);
|
||||
assert.match(markup(game), /Claim 3 charity spice/);
|
||||
game.charity.multiplier = 2;
|
||||
game.charity.ordinary = 4;
|
||||
game.charity.homeworld = 2;
|
||||
game.charity.amount = 6;
|
||||
const doubled = markup(game);
|
||||
assert.match(doubled, /Inflation doubles the amount of charity you receive/);
|
||||
assert.match(doubled, /Ordinary eligibility still requires fewer than two spice/);
|
||||
assert.match(doubled, /advanced Bene Gesserit receives four regardless of wealth/);
|
||||
assert.match(doubled, /Low Homeworld population adds 2 spice directly from the bank after Inflation doubles the bonus/);
|
||||
assert.match(doubled, /Claim 6 charity spice/);
|
||||
game.charity.multiplier = 0;
|
||||
game.charity.ordinary = 0;
|
||||
game.charity.homeworld = 0;
|
||||
game.charity.amount = 0;
|
||||
assert.match(markup(game), /Inflation cancels charity for every faction this turn/);
|
||||
assert.deepEqual(claim(game), []);
|
||||
});
|
||||
|
||||
void test('active CHOAM payer guidance always states the provisional bank fallback without reading rival wallets', () => {
|
||||
const game = projected();
|
||||
const rival = game.players.find((player) => player.id === game.charity.payer)!;
|
||||
Object.defineProperty(rival, 'spice', {
|
||||
get() { throw new Error('Charity guidance read rival spice'); },
|
||||
});
|
||||
const guidance = 'CHOAM pays as much of the ordinary claim as it can; the Spice Bank supplies any shortfall. This funding rule is provisional.';
|
||||
for (let render = 0; render < 3; render++) {
|
||||
const html = markup(game);
|
||||
assert.ok(html.includes(guidance));
|
||||
assert.match(html, /The ordinary claim is 2 spice/);
|
||||
assert.doesNotMatch(html, /remaining spice|current balance|bank supplies [0-9]/i);
|
||||
}
|
||||
assert.deepEqual(claim(game), [{ type: 'charity' }]);
|
||||
game.charity.payer = null;
|
||||
assert.match(markup(game), /Charity is paid by the Spice Bank/);
|
||||
assert.ok(!markup(game).includes(guidance));
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CharityFundingError, quoteCharityFunding, charityPayer } from '../game/charity';
|
||||
import { createGame, newPlayer } from '../game/engine';
|
||||
|
||||
void test('the full ordinary claim uses available CHOAM first, with a provisional bank remainder', () => {
|
||||
for (const [cash, choam, bank] of [[0, 0, 2], [1, 1, 1], [2, 2, 0], [8, 2, 0]]) {
|
||||
const amounts = { ordinary: 2, homeworld: 0 }, before = structuredClone(amounts);
|
||||
assert.deepEqual(quoteCharityFunding(amounts, cash), { choam, bank });
|
||||
assert.deepEqual(amounts, before);
|
||||
}
|
||||
});
|
||||
void test('Homeworld additions stay bank-funded with doubled, partial and absent CHOAM funding', () => {
|
||||
assert.deepEqual(quoteCharityFunding({ ordinary: 4, homeworld: 2 }, 3), { choam: 3, bank: 3 });
|
||||
assert.deepEqual(quoteCharityFunding({ ordinary: 2, homeworld: 1 }, 8), { choam: 2, bank: 1 });
|
||||
assert.deepEqual(quoteCharityFunding({ ordinary: 1, homeworld: 1 }, null), { choam: 0, bank: 2 });
|
||||
assert.deepEqual(quoteCharityFunding({ ordinary: 0, homeworld: 0 }, 0), { choam: 0, bank: 0 });
|
||||
});
|
||||
void test('invalid balances or claim amounts reject before any private funding calculation', () => {
|
||||
for (const invalid of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
assert.throws(() => quoteCharityFunding({ ordinary: invalid, homeworld: 0 }, 2), CharityFundingError);
|
||||
assert.throws(() => quoteCharityFunding({ ordinary: 2, homeworld: invalid }, 2), CharityFundingError);
|
||||
assert.throws(() => quoteCharityFunding({ ordinary: 2, homeworld: 0 }, invalid), CharityFundingError);
|
||||
}
|
||||
assert.throws(() => quoteCharityFunding({ ordinary: Number.MAX_SAFE_INTEGER, homeworld: 1 }, null), CharityFundingError);
|
||||
});
|
||||
void test('CHOAM pays only other factions after current allowed opening; its own poverty remains bank-funded', () => {
|
||||
const g = createGame('CHARITYFUND', newPlayer('c', 'CHOAM', 'choam'), false, ['choam']);
|
||||
g.players.push(newPlayer('a', 'Atreides', 'atreides'));
|
||||
assert.equal(charityPayer(g, 'a'), undefined);
|
||||
g.choamCharity = { turn: g.turn, canceled: false };
|
||||
const before = structuredClone(g);
|
||||
assert.equal(charityPayer(g, 'a')?.id, 'c');
|
||||
assert.equal(charityPayer(g, 'c'), undefined);
|
||||
assert.deepEqual(g, before);
|
||||
g.choamCharity.canceled = true;
|
||||
assert.equal(charityPayer(g, 'a'), undefined);
|
||||
g.choamCharity = { turn: g.turn - 1, canceled: false };
|
||||
assert.equal(charityPayer(g, 'a'), undefined);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame } from '../game/engine';
|
||||
import {
|
||||
createChoamCharityFundingFixture as fixture,
|
||||
actChoamCharityFunding as act,
|
||||
choamCharityFundingPlayer as player,
|
||||
choamCharityFundingInventory as inventory,
|
||||
} from './fixture-choam-charity-funding';
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} native CHOAM poverty claim draws from Bank after actual earned-income bribes`, () => {
|
||||
for (const choamSpice of [0, 1] as const) {
|
||||
const f = fixture({ advanced, choamSpice }), before = structuredClone(f.game);
|
||||
const g = act(f.game, f.choam, f.selfClaim);
|
||||
assert.equal(player(g, f.choam).spice, 2);
|
||||
assert.equal(player(g, f.guild).spice, 0);
|
||||
assert.equal(player(g, f.recipient).bribes, 13 - choamSpice);
|
||||
assert.equal(viewGame(g, f.choam).charity.payer, null);
|
||||
assert.equal(viewGame(g, f.choam).players.find(p => p.id === f.choam)!.charityClaimed, true);
|
||||
assert.deepEqual(inventory(g), f.originalInventory);
|
||||
assert.deepEqual(f.game, before);
|
||||
assert.throws(() => act(g, f.choam, f.selfClaim), /once per turn|fewer than/);
|
||||
}
|
||||
});
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} native other-faction full claim uses provisional shortfall funding`, () => {
|
||||
for (const choamSpice of [0, 1] as const) {
|
||||
const f = fixture({ advanced, choamSpice }), before = structuredClone(f.game);
|
||||
const g = act(f.game, f.guild, f.otherClaim);
|
||||
assert.equal(player(g, f.choam).spice, 0);
|
||||
assert.equal(player(g, f.guild).spice, 2);
|
||||
assert.equal(player(g, f.recipient).bribes, 13 - choamSpice);
|
||||
assert.deepEqual(g.choamCharity, f.game.choamCharity);
|
||||
assert.deepEqual(g.players.map(p => p.forces), f.game.players.map(p => p.forces));
|
||||
assert.deepEqual(inventory(g), f.originalInventory);
|
||||
assert.deepEqual(f.game, before);
|
||||
assert.throws(() => act(g, f.guild, f.otherClaim), /once per turn|fewer than/);
|
||||
}
|
||||
});
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} each low faction keeps exactly one independent claim in either order`, () => {
|
||||
const f = fixture({ advanced, choamSpice: 1, guildSpice: 1 });
|
||||
for (const order of [[f.choam, f.guild], [f.guild, f.choam]]) {
|
||||
let g = structuredClone(f.game);
|
||||
for (const actor of order) g = act(g, actor, { type: 'charity' });
|
||||
assert.equal(player(g, f.guild).spice, 2);
|
||||
assert.equal(player(g, f.choam).spice, order[0] === f.choam ? 1 : 2);
|
||||
assert.equal(player(g, f.recipient).bribes, 11);
|
||||
for (const actor of order) {
|
||||
assert.equal(viewGame(g, actor).players.find(p => p.id === actor)!.charityClaimed, true);
|
||||
assert.throws(() => act(g, actor, { type: 'charity' }), /once per turn|fewer than/);
|
||||
}
|
||||
assert.deepEqual(inventory(g), f.originalInventory);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -239,6 +239,23 @@ void test('wealthy Bene Gesserit payment waits for its response; cancellation ne
|
||||
assert.equal(viewGame(g, 'b').players[2].charityClaimed, true);
|
||||
}
|
||||
});
|
||||
void test('wealthy BG response preserves cancellation before provisional shortfall funding', () => {
|
||||
for (const canceled of [false, true]) {
|
||||
let g = allow(ready(fixture(true)));
|
||||
contest(g);
|
||||
g = applyAction(g, 'c', { type: 'bribe', target: 'e', amount: 6 });
|
||||
g.players[2].spice = 12;
|
||||
g = applyAction(g, 'b', { type: 'charity' });
|
||||
assert.equal(g.response?.kind, 'bgCharity');
|
||||
assert.equal(g.players[0].spice, 0);
|
||||
assert.equal(g.players[2].spice, 12);
|
||||
g = canceled ? cancel(g) : allow(g);
|
||||
assert.equal(g.players[0].spice, 0);
|
||||
assert.equal(g.players[2].spice, canceled ? 12 : 14);
|
||||
assert.equal(viewGame(g, 'b').players[2].charityClaimed, true);
|
||||
assert.equal(g.players[1].bribes, 6);
|
||||
}
|
||||
});
|
||||
void test('Karama cancels CHOAM income, restores bank payments and permits CHOAM ordinary charity', () => {
|
||||
let g = cancel(ready(contest(fixture(true))));
|
||||
assert.equal(g.players[0].spice, 0);
|
||||
@@ -337,12 +354,31 @@ void test('ordinary funded claims still accrue and settle Spice Production once'
|
||||
assert.equal(g.players[1].spice, 3);
|
||||
assert.equal(g.techTokens!.production.spice, 0);
|
||||
});
|
||||
void test('unresolved insolvency fails atomically without minted spice or negative balances', () => {
|
||||
let g = allow(ready(fixture()));
|
||||
g = applyAction(g, 'c', { type: 'bribe', target: 'e', amount: 6 });
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'b', { type: 'charity' }), /cannot fund/);
|
||||
assert.deepEqual(g, before);
|
||||
void test('provisional shortfall funding completes the full claim after actual CHOAM bribes', () => {
|
||||
for (const remaining of [0, 1, 2]) {
|
||||
let g = allow(ready(fixture()));
|
||||
g = applyAction(g, 'c', { type: 'bribe', target: 'e', amount: 6 - remaining });
|
||||
const before = structuredClone(g);
|
||||
const claimed = applyAction(g, 'b', { type: 'charity' });
|
||||
assert.equal(claimed.players[0].spice, 0);
|
||||
assert.equal(claimed.players[2].spice, 2);
|
||||
assert.equal(claimed.players[1].bribes, 6 - remaining);
|
||||
assert.equal(viewGame(claimed, 'b').players[2].charityClaimed, true);
|
||||
assert.throws(() => applyAction(claimed, 'b', { type: 'charity' }), /once per turn|fewer than/);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
});
|
||||
void test('CHOAM self-claim uses the bank after its original income is legally given away', () => {
|
||||
for (const remaining of [0, 1]) {
|
||||
let g = allow(ready(fixture()));
|
||||
g = applyAction(g, 'c', { type: 'bribe', target: 'e', amount: 6 - remaining });
|
||||
assert.equal(viewGame(g, 'c').charity.payer, null);
|
||||
const claimed = applyAction(g, 'c', { type: 'charity' });
|
||||
assert.equal(claimed.players[0].spice, 2);
|
||||
assert.equal(claimed.players[1].bribes, 6 - remaining);
|
||||
assert.equal(viewGame(claimed, 'c').players[0].charityClaimed, true);
|
||||
assert.equal(viewGame(claimed, 'b').charity.payer, 'c');
|
||||
}
|
||||
});
|
||||
void test('CHOAM can buy its fifth card, but a full five-card hand cannot take an auction with Karama', () => {
|
||||
let g = allow(ready(fixture()));
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
export type ChoamCharityFundingStep = { actor: string; action: Action };
|
||||
export type ChoamCharityFundingTransition = {
|
||||
before: Game; steps: ChoamCharityFundingStep[]; after: Game;
|
||||
};
|
||||
export const choamCharityFundingPlayer = (game: Game, id: string) => {
|
||||
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
|
||||
};
|
||||
export const choamCharityFundingClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function cloneChoamCharityFunding(game: Game): Game {
|
||||
assert.ok(choamCharityFundingClean(game), 'Clone an actual clean Charity choice boundary');
|
||||
return structuredClone(game);
|
||||
}
|
||||
export const choamCharityFundingInventory = (game: Game) => {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.auction?.cards.slice(game.auction.index) ?? []),
|
||||
...game.players.flatMap(player => player.hand)];
|
||||
const ids = cards.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Each original physical Treachery Card has one current slot');
|
||||
return ids;
|
||||
};
|
||||
|
||||
/** Choose only original scalar shuffle order. UUIDs and all other native byte
|
||||
* entropy remain untouched; no played card or resource is replaced. */
|
||||
function originalScalarOrder<T>(run: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function actChoamCharityFunding(game: Game, actor: string, action: Action,
|
||||
actions: ChoamCharityFundingStep[] = []): Game {
|
||||
const next = originalScalarOrder(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
/** Native setup, market, response and phase controls only. This helper never
|
||||
* claims Charity, initiates a bribe or makes a ruling about unpaid obligations. */
|
||||
export function nextChoamCharityFundingStep(game: Game): ChoamCharityFundingStep | null {
|
||||
if (!game.phaseOpening && !game.response && game.decision?.kind === 'choamMarket')
|
||||
return { actor: game.decision.player, action: { type: 'decision', done: true } };
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceChoamCharityFunding(game: Game, until: (state: Game) => boolean,
|
||||
actions: ChoamCharityFundingStep[] = []): Game {
|
||||
for (let count = 0; count < 1000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The genuine CHOAM chronology ended before its requested boundary');
|
||||
const next = nextChoamCharityFundingStep(game);
|
||||
assert.ok(next, `A caller must choose the original control at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actChoamCharityFunding(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original CHOAM Charity chronology did not reach its finite boundary');
|
||||
}
|
||||
export function passChoamCharityFundingResponses(game: Game,
|
||||
actions: ChoamCharityFundingStep[] = []): Game {
|
||||
for (let count = 0; game.response && count < 60; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actChoamCharityFunding(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.ok(!game.response); return game;
|
||||
}
|
||||
|
||||
/** Fresh public three-seat E2 source through its FIRST allowed Charity income.
|
||||
* Ordinary bribes then remove spendable spice from CHOAM and Guild. Incoming
|
||||
* bribes remain native deferred receipts, so they do not fund the pending claim.
|
||||
* Stop BEFORE either CHOAM or Guild claims. This reproduces the legal funding boundary without
|
||||
* choosing a remedy or asserting how the claim ought to be paid. */
|
||||
export function createChoamCharityFundingFixture(options: {
|
||||
advanced?: boolean; choamSpice?: 0 | 1; guildSpice?: 0 | 1;
|
||||
} = {}) {
|
||||
const advanced = options.advanced ?? false;
|
||||
const choamSpice = options.choamSpice ?? 0, claimantSpice = options.guildSpice ?? 0;
|
||||
const choam = 'choam', claimant = 'guild', recipient = 'atreides';
|
||||
let game = createGame('CHOAMCHARITYFUNDING', newPlayer(choam, 'CHOAM', 'choam'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer(recipient, 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer(claimant, 'Guild', 'guild'));
|
||||
const initial = structuredClone(game), actions: ChoamCharityFundingStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actChoamCharityFunding(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceChoamCharityFunding(game, until, actions); };
|
||||
const transition = (run: () => void): ChoamCharityFundingTransition => {
|
||||
const before = structuredClone(game), from = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced);
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const originalInventory = choamCharityFundingInventory(game);
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2);
|
||||
const karama = game.deck.find(card => card.effect === 'karama');
|
||||
assert.equal(ordinary.length, 2); assert.ok(karama);
|
||||
const startingCards = [ordinary[0], karama, ordinary[1]];
|
||||
const dealOrder = startingCards.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), startingCards.map(card => card.id));
|
||||
assert.equal(choamCharityFundingPlayer(game, choam).spice, 2);
|
||||
assert.equal(choamCharityFundingPlayer(game, claimant).spice, 5);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
reach(state => state.phase === 2 && state.response?.kind === 'choamCharity');
|
||||
const originalIncomeResponse = structuredClone(game);
|
||||
const income = transition(() => {
|
||||
game = passChoamCharityFundingResponses(game, actions);
|
||||
reach(state => state.phase === 2 && choamCharityFundingClean(state));
|
||||
});
|
||||
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false });
|
||||
assert.equal(choamCharityFundingPlayer(game, choam).spice, 8);
|
||||
const beforeBribes = structuredClone(game);
|
||||
const claimantBribe = transition(() => act(claimant, {
|
||||
type: 'bribe', target: recipient, amount: choamCharityFundingPlayer(game, claimant).spice - claimantSpice,
|
||||
}));
|
||||
const beforeFundingBribe = structuredClone(game);
|
||||
const fundingBribe = transition(() => act(choam, {
|
||||
type: 'bribe', target: recipient, amount: choamCharityFundingPlayer(game, choam).spice - choamSpice,
|
||||
}));
|
||||
assert.equal(game.turn, 1); assert.equal(game.phase, 2); assert.ok(choamCharityFundingClean(game));
|
||||
assert.equal(choamCharityFundingPlayer(game, choam).spice, choamSpice);
|
||||
assert.equal(choamCharityFundingPlayer(game, claimant).spice, claimantSpice);
|
||||
assert.equal(choamCharityFundingPlayer(game, recipient).spice, choamCharityFundingPlayer(beforeBribes, recipient).spice);
|
||||
assert.equal(choamCharityFundingPlayer(game, recipient).bribes, 13 - claimantSpice - choamSpice);
|
||||
assert.notEqual(choamCharityFundingPlayer(game, choam).charityTurn, game.turn);
|
||||
assert.notEqual(choamCharityFundingPlayer(game, claimant).charityTurn, game.turn);
|
||||
assert.deepEqual(choamCharityFundingInventory(game), originalInventory);
|
||||
const claim: Action = { type: 'charity' };
|
||||
const claimStep: ChoamCharityFundingStep = { actor: choam, action: claim };
|
||||
const otherClaimStep: ChoamCharityFundingStep = { actor: claimant, action: claim };
|
||||
return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, originalIncomeResponse,
|
||||
beforeBribes, beforeFundingBribe, income, claimantBribe, fundingBribe,
|
||||
choam, owner: choam, payer: choam, claimant: choam, guild: claimant, otherClaimant: claimant,
|
||||
recipient, holder: recipient, karama: karama.id,
|
||||
advanced, choamSpice, guildSpice: claimantSpice, amount: 2 - choamSpice, otherAmount: 2 - claimantSpice,
|
||||
claim, claimStep, selfClaim: claim, selfClaimStep: claimStep, otherClaim: claim, otherClaimStep,
|
||||
actions, sourceActionCount: actions.length, originalInventory, originalSpice, dealOrder, spiceOrder,
|
||||
staging: [
|
||||
'Actual public three-seat CHOAM/Atreides/Guild ready/start, native private Traitor choices, initial armies and starting wallets. Advanced uses the original explicit preview start; no played Basic game is converted.',
|
||||
'Select only original scalar shuffle order, three original unopened Treachery positions and two original unopened ordinary Spice positions. Atreides receives its original starting Karama, keeping the first CHOAM income response genuinely open.',
|
||||
'Actually complete first Storm, native closing CHOAM market and Spice controls, then pass the original first Charity income response. CHOAM earns six bank spice on top of its original two.',
|
||||
`During clean first Charity, Guild actually pays a ${5 - claimantSpice}-spice bribe and CHOAM actually pays an ${8 - choamSpice}-spice bribe to unallied Atreides. The original ${13 - claimantSpice - choamSpice} deferred incoming spice stays unspendable until Mentat. No phase, wallet, force, alliance, claim stamp or payment receipt is assigned.`,
|
||||
'Stop before either original Charity claim. The primary claimStep belongs to CHOAM itself; otherClaimStep belongs to Guild. Expected funding/remedies are deliberately not asserted by this source constructor.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type ChoamCharityFundingFixture = ReturnType<typeof createChoamCharityFundingFixture>;
|
||||
@@ -226,18 +226,23 @@ void test('actual cancellation of CHOAM phase-opening income makes both ordinary
|
||||
});
|
||||
|
||||
void test('the bank bonus never creates ordinary eligibility or makes CHOAM fund more than its exact ordinary portion', () => {
|
||||
let g = choam(setup(false));
|
||||
const g = choam(setup(false));
|
||||
seat(g, 'a').spice = 2;
|
||||
seat(g, 'b').spice = 9;
|
||||
rejects(g, 'a', { type: 'charity' });
|
||||
rejects(g, 'b', { type: 'charity' });
|
||||
seat(g, 'a').spice = 1;
|
||||
seat(g, 'c').spice = 0;
|
||||
rejects(g, 'a', { type: 'charity' });
|
||||
seat(g, 'c').spice = 1;
|
||||
g = applyAction(g, 'a', { type: 'charity' });
|
||||
assert.equal(seat(g, 'a').spice, 3);
|
||||
assert.equal(seat(g, 'c').spice, 0);
|
||||
for (const cash of [0, 1, 4]) {
|
||||
const source = structuredClone(g);
|
||||
seat(source, 'a').spice = 1;
|
||||
seat(source, 'c').spice = cash;
|
||||
const before = structuredClone(source);
|
||||
const claimed = applyAction(source, 'a', { type: 'charity' });
|
||||
assert.equal(seat(claimed, 'a').spice, 3);
|
||||
assert.equal(seat(claimed, 'c').spice, Math.max(0, cash - 1));
|
||||
assert.equal(viewGame(claimed, 'a').players.find(p => p.id === 'a')!.charityClaimed, true);
|
||||
assert.deepEqual(source, before);
|
||||
rejects(claimed, 'a', { type: 'charity' });
|
||||
}
|
||||
});
|
||||
|
||||
void test('a forged saved BG bank split or total is rejected unchanged before allowance and Karama card spend', () => {
|
||||
|
||||
Reference in new issue
Block a user