Fund ordinary promises from native CHOAM opening income

This commit is contained in:
Quintine committed 2026-10-27 06:42:56 +11:00
1 parent 38738eabac
commit 3abde581db
13 files changed
+663 -24

No files matched your search

+10 -1
View File
@@ -60,6 +60,11 @@ export function BribePromiseGuideButton(props: BribePromiseProps) {
export function BribePromises(props: BribePromiseProps) {
const own = props.game.bribePromises.filter(promise => promise.player === props.game.me);
if (!own.length) return null;
const live = liveBribePromises(own, props.game.me, props.game.turn).length > 0;
const pendingChoamIncome = live && props.game.status === 'playing' &&
props.game.players.find(player => player.id === props.game.me)?.faction === 'choam' &&
Number.isSafeInteger(props.game.phase) && props.game.phase >= 0 && props.game.phase <= 2 &&
props.game.charity.incomePending && !props.game.charity.incomeCanceled && props.game.charity.multiplier > 0;
const name = (id: string) => props.game.players.find(player => player.id === id)?.name ?? 'the named recipient';
return <section className="notice space-y-3" aria-label="Your Truthtrance bribe promises" id="bribe-promise-guidance">
<h3>Your bribe promises</h3>
@@ -74,6 +79,10 @@ export function BribePromises(props: BribePromiseProps) {
No forbids reaching that minimum during the rest of this turn. Smaller payments to the named recipient add together.
Prior payments, gifts and ally aid do not count.
</p>
{pendingChoamIncome && <p className="fine" data-testid="choam-pending-payment-income">
Automatic CHOAM income resolves at the Charity opening. It is not current spendable cash.
Your ordinary Charity claim is separate. Karama or opposing claims can change the funding.
Current payment guides may still offer legal prepayments.</p>}
<p className="fine">
The sender pays immediately. The recipient collects the payment at the start of Mentat Pause.
Payments made during Mentat Pause wait until the start of next turn&apos;s Mentat Pause.
@@ -82,6 +91,6 @@ export function BribePromises(props: BribePromiseProps) {
The future claim is not current spendable spice; only actual payments add to the recorded total.
{' '}<a href="/rules#truthtrance-bribe">Bribe promise rules</a>.
</p>
{liveBribePromises(own, props.game.me, props.game.turn).length > 0 && <BribePromiseGuideButton {...props} />}
{live && <BribePromiseGuideButton {...props} />}
</section>;
}
+8
View File
@@ -361,6 +361,10 @@ export function TurnActionPromises(props: TurnActionProps) {
const game = props.game, own = game.turnActionPromises.filter(promise => promise.player === game.me);
if (!own.length) return null;
const live = own.some(promise => promise.turn === game.turn && !promise.fulfilled && !promise.released);
const pendingChoamIncome = live && game.status === 'playing' &&
game.players.find(player => player.id === game.me)?.faction === 'choam' &&
Number.isSafeInteger(game.phase) && game.phase >= 0 && game.phase <= 2 &&
game.charity.incomePending && !game.charity.incomeCanceled && game.charity.multiplier > 0;
return <section className="notice space-y-3" aria-label="Your Truthtrance turn-action promises" id="turn-action-promise-guidance">
<h3>Your battle and turn-action promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
@@ -376,6 +380,10 @@ export function TurnActionPromises(props: TurnActionProps) {
Separate No answers each create their own restriction. Legal alternatives remain available; no fixed branch is selected for you.
Current facts freeze at the answer and create no promise to keep cards, spice or forces afterward.
Only the aggregate answer is shared. Individual fact answers and any reduced future branch remain private.</p>
{pendingChoamIncome && <p className="fine" data-testid="choam-pending-payment-income">
Automatic CHOAM income resolves at the Charity opening. It is not current spendable cash.
Your ordinary Charity claim is separate. Karama or opposing claims can change the funding.
Current payment guides may still offer legal prepayments.</p>}
<p className="fine">Count the sum of future real ordinary payments to each named recipient after the answer.
Earlier payments, gifts and ally aid do not count. The sender pays immediately; the recipient collects at the next Mentat opening.
Payments made during Mentat Pause wait until next turn&apos;s Mentat opening; collection never counts twice.
+28
View File
@@ -20,6 +20,34 @@ open mode gates.
Continue remaining Basic functions before Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Truthtrance / CHOAM opening-income installments — 27 October. Prototyped / Partial.**
Whole, bare, grouped and mixed payments can share one native automatic CHOAM
receipt plus one distinct ordinary Charity claim. Current guides spend only
actual cash. Native Karama cancellation and opposing claims retain their rights,
including an Advanced BG claim settling on CHOAM's final response pass. Persistent
owner guidance explains the wait and separate claim. Four legal profiles use
these guides; strategy and calibration remain deferred.
Final frozen **5 files, 108/108**, types/lint/build and seven native journeys
pass. The preceding 24-file run passes 339/340, including early movement; its
one failure expected discard before the answer. That test assertion is repaired
and all 11 new cases pass in the final union. Product source is identical across
these runs, with an explicit private inheritance receipt. Journeys include 57
GameTable SSR renders, 2 isolated answer renders, 7 actual answer callbacks
and 17 exact guide callbacks (14 whole, 3 scalar). Genuine selected-Ix sources
use 18 Basic or 25 four-seat Advanced opening actions. Inventories and further
counts derive from native arrays; CHOAM starts with its actual two spice.
Actual paths cover 2→automatic 6, four-seat 2→8→ordinary 2, canceled funding,
shared desert shipment and the last-pass forced claim. Counter eligibility is
supplied by an original unopened-deal Karama, preserving the native response.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-choam-income-proof-v3/`;
journeys/HTML: adjacent `20261027-truth-choam-income-runtime/run-v3/`.
No HTTP/storage boundary, saved-game/database, restart or deployed operation
changes. This is local native/SSR evidence. Other future revenue, wider deals/
optional combinations, complete modes and final acceptance remain open.
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#choam-opening-income--27-october-2026).
**Truthtrance / Charity-funded installments — 27 October. Prototyped / Partial.**
One remaining owned claim can fund payments after actual prepayments. Whole,
bare, grouped and mixed questions share that timeline, cumulative caps and the
+16
View File
@@ -1,5 +1,21 @@
# Dune implementation status
## 27 October 2026 — CHOAM opening-income payment installments
Prototyped / Partial. Payment witnesses can use the owned automatic native
receipt once, independently of ordinary Charity, while guides spend actual cash.
Native cancellation and opposing claims retain their rights. Last counter passes
attribute forced settlement to the claimant, including legacy financial answers.
Persistent owner guidance and four legal profiles are connected. Final frozen 5 files,
108/108, types/lint/build and seven journeys (57 GameTable renders,
2 isolated answer renders, 7 answers, 17 guides) pass. Existing early movement
passes in the preceding 24-file run (339/340). The sole pre-answer discard
assertion is corrected in the final union; product source is unchanged and
inheritance is recorded. No strategic tuning, deployment or saved-game operation occurs.
Wider future revenue/deals and complete acceptance remain.
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#choam-opening-income--27-october-2026).
Private proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-choam-income-proof-v3/`.
## 27 October 2026 — Charity-funded payment installments
Prototyped / Partial. Bare and combined payments can use one owned remaining
+19 -2
View File
@@ -1,5 +1,22 @@
# Rule decision index
## Truthtrance CHOAM opening-income promises — 27 October 2026
Prototyped / Partial. Compose the native CHOAM opening receipt with ordinary
payment witnesses, using the existing amount and current-turn receipt marker
once. A separate eligible ordinary Charity claim remains available once, and
all installments share the resulting purse with other commitments. The allowed
opening outcome supplies a possible detached witness; it grants no current
cash or right to choose an opponent's counter. Actual cancellation or another
faction's claim can release a now-unfunded answer under the bounded planner.
A payer's final response pass settles the opposing claimant's effect rather
than making its debit voluntary; optional protective Karama use remains a
native choice. This extends the existing whole-answer settlement convention to
legacy financial promises. It is software composition, not a dedicated
publisher worked example or complete future-income solver.
[Native income authority and provisional shortfall policy](CHOAM_CHARITY_FUNDING.md#source-and-selected-prototype-policy--24-october-2026);
[promise contract](TRUTHTRANCE_TURN_ACTIONS.md#choam-opening-income--27-october-2026).
## Truthtrance Charity-funded installments — 27 October 2026
Prototyped / Partial. Extend ordinary payment witnesses with one remaining owned
@@ -10,8 +27,8 @@ future Charity and share the resulting purse with every other commitment.
Own premature claiming or passing cannot evade an answer. Recipient receipts
remain deferred. This is bounded composition of existing Charity/bribery and
Truthtrance authority, without inventing future auction/collection income,
foreign gifts or a selected public route. Unresolved CHOAM own-opening income
and wider optional combinations retain their separate boundaries.
foreign gifts or a selected public route. The later CHOAM opening-income
extension is recorded above; wider optional combinations remain bounded.
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#charity-funded-installments--27-october-2026).
## Truthtrance printed Amal and phase openings — 27 October 2026
+8
View File
@@ -1,5 +1,13 @@
# Truthtrance ordinary bribe-payment promises
**27 October CHOAM follow-up:** the owned automatic opening receipt can fund
later installments once, independently of one remaining ordinary Charity claim.
Current payments use current cash. Native counters and opposing claims retain
their rights, including a claim settling on CHOAM's last response pass. Persistent
owner guidance explains the wait. This supersedes the opening-income exclusion
below, not other future revenue or broader optional-combination boundaries.
[Current contract](TRUTHTRANCE_TURN_ACTIONS.md#choam-opening-income--27-october-2026).
**27 October Charity follow-up:** an owned remaining ordinary claim can follow
actual prepayments and fund a later installment, with current-phase guides for
both actions. Positive answers reopen Charity readiness, and own passing or an
+49 -2
View File
@@ -1,5 +1,52 @@
# Truthtrance combined current-turn actions
## CHOAM opening income — 27 October 2026
**Prototyped / Partial.** Whole, bare, grouped and mixed ordinary payment
witnesses can use CHOAM's own automatic opening receipt. They first spend actual
available cash, then wait for the real Charity opening before using its income.
The detached calendar reuses the native amount, including configured Inflation
and Homeworld additions, and the current-turn receipt prevents repeated credit.
The automatic receipt does not consume the separate ordinary Charity claim.
For three seats, native starting cash 2 plus opening income 6 can fund 8; after
those actual installments, a remaining eligible ordinary claim of 2 can fund 10.
Different recipients and later physical shipment share this one purse.
The allowed receipt is a possible witness rather than a selected foreign
response. Actual Karama cancellation removes that modeled funding; a smaller
cash-plus-ordinary-claim answer can survive. Another faction's actual ordinary
claim debits CHOAM under its existing funding policy and may release a now-
unfunded answer. This also applies when CHOAM takes the final response pass on
Advanced Bene Gesserit's claim: the opposing claim owns the debit. Passing does
not become voluntary spending or require using a held protective Karama. The
same settlement attribution now applies to legacy financial promises.
No future-income action, actual wallet, ordinary claim or chosen route is
installed by a quote. Earlier card windows cannot spend the later receipt.
Human guides send only currently affordable payments or an actually legal
ordinary claim; native response controls resolve the opening. Persistent
owner-only text explains pending income, the separate claim and possible
opposition. Busy, owner, event, modal and current-quote checks remain. Four
profiles use the native guides; strategy and calibration remain deferred.
This is bounded composition of existing payment, Charity and Truthtrance
authority. Unknown future market/card/collection income, gifts and wider
optional compositions are not modeled. Release means impossibility in that
bounded witness, not an exhaustive proof against every possible negotiated
future. Full modes, comprehensive review and final acceptance remain open.
Native source/policy: [CHOAM funding](CHOAM_CHARITY_FUNDING.md).
Final frozen evidence: 5 affected files, 108/108, types/lint/build and seven
native/SSR/control journeys pass (57 GameTable renders, 2 isolated answer renders,
7 answer callbacks, 14 whole and 3 scalar guide callbacks). Sources use
18 Basic or 25 four-seat Advanced native actions with actual opening cash 2,
47 Treachery Cards and original Traitor/Spice inventories. Private proof:
`/home/quintine/.local/share/dune/checkpoints/20261027-truth-choam-income-proof-v3/`;
journeys/HTML: adjacent `20261027-truth-choam-income-runtime/run-v3/`.
The preceding 24-file run passes 339/340, including early movement. Its one
incorrect pre-answer discard assertion is repaired and all 11 new cases pass
in the final union; product source is unchanged, with recorded inheritance.
This is local native/SSR evidence, not browser hydration or deployed acceptance.
## Charity-funded installments — 27 October 2026
**Prototyped / Partial.** Ordinary payment witnesses can now schedule one
@@ -31,8 +78,8 @@ expiry it cannot be reserved again. Cumulative No caps and original history
persist. Four profiles consume legal guides; strategy and calibration remain
deferred. Native phase controls handle waiting before the actual Charity phase.
Other future income, unresolved CHOAM own-opening income, wider card/optional
compositions, complete modes and final assurance remain open. Existing native
The later CHOAM opening-income extension is recorded above. Other future income,
wider card/optional compositions, complete modes and final assurance remain open. Existing native
ordinary entitlement for another faction is quoted without choosing CHOAM's
foreign opening response. This is a bounded extension, not full planning of all
possible deals or later collection.
+32 -16
View File
@@ -15366,17 +15366,35 @@ function bribeQuestionWindow(g: Game): boolean {
return bribePromiseTurn(g) && !bribeTimingBlock({ ...g, truthtrance: null,
inflation: g.inflation?.side === 'double' ? null : g.inflation });
}
/** One owned claim, using the same entitlement as real Charity and existing
* preparation quotes. Future phase projection consumes no hidden card. */
function forecastOrdinaryCharity(source: Game, owner: string): Game | null {
const before = getPlayer(source, owner);
if (source.phase > 2 || before.charityTurn === source.turn ||
source.phase === 2 && source.ready.includes(owner)) return null;
/** Advance only the owned financial forecast, without drawing hidden cards or
* completing foreign choices at the real table. */
function charityForecastContext(source: Game): Game {
const g = structuredClone(source); g.phase = 2; g.ready = [];
g.truthtrance = null; g.response = null; g.decision = null; g.phaseOpening = null;
g.pendingKarama = null; g.pendingTreacheryDiscard = null;
g.spiceWindow = null; g.spiceResolution = null; g.spiceSequence = null; g.nexus = false;
g.stormCardWindowOpen = false; g.thumperWindowOpen = false; g.amalWindowOpen = false;
return g;
}
/** The permitted opening outcome is a possible native receipt. An actual
* opposing cancellation still settles independently and can remove it. */
function forecastChoamOpeningIncome(source: Game, owner: string): Game | null {
if (source.status !== 'playing' || source.phase < 0 || source.phase > 2 ||
getPlayer(source, owner).faction !== 'choam' || source.choamCharity?.turn === source.turn) return null;
const g = charityForecastContext(source), player = getPlayer(g, owner);
const amount = choamOpeningIncome(g, player);
if (amount <= 0) return null;
player.spice += amount;
g.choamCharity = { turn: g.turn, canceled: false };
return g;
}
/** One owned claim, using the same entitlement as real Charity and existing
* preparation quotes. Opening income never consumes this separate claim. */
function forecastOrdinaryCharity(source: Game, owner: string): Game | null {
const before = getPlayer(source, owner);
if (source.phase > 2 || before.charityTurn === source.turn ||
source.phase === 2 && source.ready.includes(owner)) return null;
const g = charityForecastContext(source);
const player = getPlayer(g, owner);
if (charityQuote(g, player).total <= 0) return null;
try {
@@ -15417,6 +15435,9 @@ function* ordinaryPaymentSeeds(source: Game, actor: string, payments: readonly O
beforeFuture(current);
const actions: Action[] = split.before.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount }));
if (!split.after.length) { yield { game: current, actions }; return; }
const income = forecastChoamOpeningIncome(current, actor);
if (income) for (const paid of ordinaryPaymentSeeds(income, actor, split.after))
yield { game: paid.game, actions: [...actions, ...paid.actions] };
const claimed = forecastOrdinaryCharity(current, actor);
if (!claimed) return;
quoteOrdinaryPayments(claimed, actor, split.after);
@@ -28788,15 +28809,8 @@ type ShipmentCompletionSeed = { game: Game; actions: Action[]; early: { phase: n
function earlyRevivalCompletionSeeds(initial: Game, source: Game, owner: string): ShipmentCompletionSeed[] {
let seeds: ShipmentCompletionSeed[] = [{ game: initial, actions: [], early: [] }];
if (source.phase > 4) return seeds;
if (source.phase <= 2 && source.choamCharity?.turn !== source.turn && getPlayer(source,owner).faction === 'choam') {
// The owned opening is automatic, not an executable future claim. Witness
// its allowed fixed income only; the real table retains the counter.
const projected = structuredClone(initial);
const choam = getPlayer(projected,owner);
choam.spice += choamOpeningIncome(projected,choam);
projected.choamCharity = {turn:projected.turn,canceled:false};
seeds.push({game:projected,actions:[],early:[]});
}
const openingIncome = forecastChoamOpeningIncome(initial, owner);
if (openingIncome) seeds.push({ game: openingIncome, actions: [], early: [] });
if (source.phase <= 2 && !(source.phase === 2 && source.ready.includes(owner))) {
const charitySeeds: ShipmentCompletionSeed[] = [];
for (const seed of seeds) {
@@ -37957,7 +37971,9 @@ export function applyAction(state: Game, id: string, action: Action): Game {
})) guardTurnActionAuctionClosure(g, id, liveTurnActionPromises(state.turnActionPromises ?? [], id, state.turn));
const forcedThumperDraw = thumperForcedDraws.delete(g);
const promiseActor = forcedThumperDraw ? undefined : id;
const voluntary = forcedThumperDraw ? undefined : { actor: id, action };
// A final counter pass settles the opposing claimant's effect; it does not
// turn that forced debit into the passer's voluntary payment or expenditure.
const voluntary = forcedThumperDraw || action.type === 'passResponse' ? undefined : { actor: id, action };
reconcileTravelPromises(g, promiseActor, action, state.phase);
reconcileBidPromises(g, voluntary);
reconcileRevivalPromises(g, voluntary);
+4 -3
View File
@@ -3235,6 +3235,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A Thumper condition counts an actual printed play after answering before this turn’s original first Spice reveal. Its cost counts even when the existing first-turn rule ignores the encounter or waiting Sandtrout suppresses it. Own acceptance must retain the whole answer. Known card effects enter the quote; the next hidden Spice Card does not. A forced later draw can release genuine impossibility, while choosing known casualties cannot evade an earlier answer.',
'A printed Amal clause names the original phase opening and counts a future printed play after this answer. The actual available-spice reduction occurs before ordinary payments become possible; known Mentat bribe receipts arrive afterward. Definite answers reopen your own opening pass. Own cost or acceptance must retain a completion, while a foreign Amal can release genuine impossibility before another player takes the last pass. Later openings keep separate intervals.',
'One remaining owned Charity claim may fund cumulative ordinary installments after actual prepayments. Charity itself is not a bribe; it cannot be claimed twice or spent again on shipment or revival. Current card windows precede future Charity, and native guides wait for its actual phase. Own premature claiming or Ready cannot evade a commitment; cumulative No caps remain.',
'CHOAM may also fund later installments with its own automatic opening receipt, once and independently of an eligible ordinary Charity claim. Future income is not current spendable cash. Native counters and opposing claims retain their rights; a last response pass settles the opposing claim rather than making its debit voluntary. Guides wait for actual cash, and other future market, card, collection or gift revenue is not forecast.',
'Shipment clauses describe the same one future original reserve shipment. Each ground-movement minimum requires one actual physical group, never the sum of smaller moves. Existing allowances, cards, pieces and preparation remain authoritative.',
'Yes requires the whole expression and fulfills on qualifying actual events. OR preserves legal alternatives; No negates the whole expression. Current facts freeze at answer. A private native quotation shares funding with earlier commitments without storing or publishing a selected branch.',
'Transport and Hajr close at your actual Movement end. Ghola or a payment alternative may remain after that close, through the full current turn including Mentat. A known Inflation Double opening closes payments earlier without closing Ghola. Complete a required payment before its actual opportunity closes; No payment caps remain binding until then.',
@@ -3246,7 +3247,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed Hajr/Ghola and original power declarations; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-thumper-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Current funding, physical event meaning, private alternatives, separate deadlines and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TURN_ACTIONS.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/window-card-play.test.ts', 'tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/fixture-truth-turn-window-card.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/fixture-truth-turn-karama.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-forecast.test.ts', 'tests/fixture-truth-turn-karama-shipment.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-karama-shipment-funding.test.ts', 'tests/fixture-truth-turn-thumper.ts', 'tests/truth-turn-thumper-runtime.test.ts', 'tests/fixture-truth-turn-amal.ts', 'tests/truth-turn-amal-runtime.test.ts', 'game/ordinary-payment-plan.ts', 'tests/ordinary-payment-plan.test.ts', 'tests/fixture-truth-charity-payments.ts', 'tests/truth-charity-payments-runtime.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/fixture-truth-choam-income.ts', 'tests/truth-choam-income-runtime.test.ts', 'tests/window-card-play.test.ts', 'tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/fixture-truth-turn-window-card.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/fixture-truth-turn-karama.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-forecast.test.ts', 'tests/fixture-truth-turn-karama-shipment.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-karama-shipment-funding.test.ts', 'tests/fixture-truth-turn-thumper.ts', 'tests/truth-turn-thumper-runtime.test.ts', 'tests/fixture-truth-turn-amal.ts', 'tests/truth-turn-amal-runtime.test.ts', 'game/ordinary-payment-plan.ts', 'tests/ordinary-payment-plan.test.ts', 'tests/fixture-truth-charity-payments.ts', 'tests/truth-charity-payments-runtime.test.ts'] },
],
},
{
@@ -3405,7 +3406,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Choose Bind ordinary bribe payments this turn, another respondent, a named non-allied recipient and a positive whole minimum. The current turn is fixed. Registered native Basic and Advanced tables without optional modules support this funded-payment prototype.',
'Yes requires cumulative matching ordinary payer payments after the answer while possible. No keeps later matching payments below the minimum, even across smaller payments. Earlier payments, gifts and allied aid do not count. Several Yes questions for one recipient share the largest deficit; different recipients reserve separate spice.',
'The payer spends spice when the actual native bribe is accepted. The recipient keeps that payment deferred until the next Mentat opening; a late Mentat payment waits for next turn. The question creates no immediate recipient cash, discount or refund. Current auction and sealed-battle liabilities stay reserved.',
'Ordinary payment witnesses use current spendable spice and may schedule one remaining owned Charity claim after prepayments. Later installments share the same final purse with battle, bid, revival, shipment and movement commitments. Actual claiming and phase expiry retire that entitlement. Other future income, pledge withdrawal, gifts and wider card preparation remain bounded. A later question must preserve earlier answers too.',
'Ordinary payment witnesses use current spendable spice, CHOAM’s own automatic opening receipt once, and one remaining owned Charity claim after prepayments. The automatic receipt and ordinary claim have separate markers; guides spend only actual cash. Later installments share the final purse with battle, bid, revival, shipment and movement commitments. Actual counters or opposing claims can change funding. Other future income, pledge withdrawal, gifts and wider card preparation remain bounded. A later question must preserve earlier answers too.',
'Your persistent panel shows named cumulative progress and fulfilled/released status. Its legal ordinary-payment guide works out of turn in a clear native window. Current payment controls also apply the recipient cap and retain funding for other Yes answers. Stale or modified guides reject.',
'Your own voluntary spending, alliance offer to the recipient or finishing Mentat cannot evade an unpaid Yes. A new Yes reopens an already-ready Charity or Mentat respondent; Collection readiness stays. A known automatic Inflation Double closure requires payment before Collection Ready ends that legal window. Completed opposing consequences may release impossible answers in accepted order. No finishes at the original turn boundary.',
'Quiet Mentat remains available after opening collection; Inflation Double still prohibits actual payments. No can be fixed under Double, while Yes requires a currently legal funded payment. This cumulative funded-current-turn protocol is a provisional composition of general Truthtrance authority; optional modules, future preparations, broader deals and complete-mode acceptance remain due.',
@@ -3416,7 +3417,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Typed current-turn named-recipient fields, public preview, target-only answers and persistent guarded out-of-turn payment guide; ordinary recipient limits retain other commitments.', evidence: ['components/bribe-claim-fields.tsx', 'components/bribe-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-bribe-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Existing four minimal profiles answer and execute legal ordinary-payment guides. Strategic initiation/negotiation and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-bribe-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'General Truthtrance/physical bribery sources, provisional cumulative meaning, funded scope, original timing and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_BRIBE_PROMISES.md', 'docs/MENTAT_BRIBES.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused pure, original deal/payment/Mentat/next-turn runtime and controlled/native UI evidence. Native alliance campaigns, optional compositions, future preparations and final browser/deployed acceptance remain open.', evidence: ['tests/fixture-mentat-bribes.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/fixture-truth-bribe.ts', 'tests/truth-bribe-runtime.test.ts', 'tests/truth-bribe-controls.test.tsx'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused pure, original deal/payment/Mentat/next-turn runtime and controlled/native UI evidence. Native alliance campaigns, optional compositions, future preparations and final browser/deployed acceptance remain open.', evidence: ['tests/fixture-truth-choam-income.ts', 'tests/truth-choam-income-runtime.test.ts', 'tests/fixture-mentat-bribes.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/fixture-truth-bribe.ts', 'tests/truth-bribe-runtime.test.ts', 'tests/truth-bribe-controls.test.tsx'] },
],
},
{
+177
View File
@@ -0,0 +1,177 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { choamOpeningIncome } from '../game/charity';
import { treacheryDeck } from '../game/cards';
import { traitorDeck } from '../game/traitors';
import type { TruthQuestion } from '../game/truthtrance';
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
import { actChoamCharityFunding, nextChoamCharityFundingStep } from './fixture-choam-charity-funding';
import { truthCharityPaymentsFact, truthCharityPaymentsGroup, truthCharityPaymentsPayment,
truthCharityPaymentsShip, truthCharityPaymentsPromise, type TruthCharityPaymentsKind } from './fixture-truth-charity-payments';
import { truthTurnThumperInventory, truthTurnThumperPlayer, truthTurnThumperSpice,
truthTurnThumperTraitors, type TruthTurnThumperStep } from './fixture-truth-turn-thumper';
export type TruthChoamIncomeStep = TruthTurnThumperStep;
export type TruthChoamIncomeKind = TruthCharityPaymentsKind;
export const actTruthChoamIncome = actChoamCharityFunding;
export const truthChoamIncomePlayer = truthTurnThumperPlayer;
export const truthChoamIncomeInventory = truthTurnThumperInventory;
export const truthChoamIncomeSpice = truthTurnThumperSpice;
export const truthChoamIncomeTraitors = truthTurnThumperTraitors;
export const truthChoamIncomePromise = truthCharityPaymentsPromise;
export const truthChoamIncomeFact = truthCharityPaymentsFact;
export const truthChoamIncomeGroup = truthCharityPaymentsGroup;
export const truthChoamIncomePayment = truthCharityPaymentsPayment;
export const truthChoamIncomeShip = truthCharityPaymentsShip;
export const truthChoamIncomeClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival && !game.pendingShipment;
/** Native setup and original quiet controls. Market opportunities are passed;
* no sale, ordinary claim or counter decision is manufactured by this driver. */
export function nextTruthChoamIncomeNative(game: Game): TruthChoamIncomeStep | null {
if (game.truthtrance) return null;
const bg = game.players.find(player => player.faction === 'beneGesserit');
if (game.status === 'setup' && game.setupStage === 'prediction' && bg)
return { actor: bg.id, action: { type: 'predict', faction: 'harkonnen', turn: 10 } };
if (game.status === 'setup' && game.setupStage === 'forces' && bg && !bg.advisorSetup)
return { actor: bg.id, action: { type: 'advisorSetup', territory: 'polar_sink', sector: 0 } };
if (!game.phaseOpening && !game.response && game.decision?.kind === 'advisorBattle')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
return nextChoamCharityFundingStep(game);
}
export function nextTruthChoamIncome(game: Game): TruthChoamIncomeStep | null {
if (game.truthtrance) return null;
for (const player of game.players) {
const view = viewGame(game, player.id), guide = view.turnActionGuide ?? view.bribePromiseGuide;
if (guide) return { actor: player.id, action: guide.action };
}
return nextTruthChoamIncomeNative(game);
}
export function advanceTruthChoamIncome(game: Game, until: (state: Game) => boolean,
actions: TruthChoamIncomeStep[] = [], nativeOnly = false): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original CHOAM income source ended before its native boundary');
const step = nativeOnly ? nextTruthChoamIncomeNative(game) : nextTruthChoamIncome(game);
assert.ok(step, 'Supply the actual Truthtrance question or original private Battle Plan');
game = actTruthChoamIncome(game, step.actor, step.action, actions);
}
throw Error('The genuine CHOAM income chronology did not reach its finite boundary');
}
export function settleTruthChoamIncome(game: Game, actions: TruthChoamIncomeStep[] = []): Game {
return advanceTruthChoamIncome(game, truthChoamIncomeClean, actions, true);
}
export function reachTruthChoamIncomeResponse(game: Game, actions: TruthChoamIncomeStep[] = []): Game {
return advanceTruthChoamIncome(game, state => state.phase === 2 && !state.phaseOpening && !state.truthtrance &&
state.response?.kind === 'choamCharity', actions, true);
}
export function allowTruthChoamIncome(game: Game, actions: TruthChoamIncomeStep[] = []): Game {
assert.equal(game.response?.kind, 'choamCharity'); const turn = game.turn;
return advanceTruthChoamIncome(game, state => state.choamCharity?.turn === turn && truthChoamIncomeClean(state), actions, true);
}
/** Public original H/A/CHOAM selected-Ix game. Select H's two unopened Truths
* and A's printed Karama. The optional BG counter source also selects the
* second original Karama in CHOAM's native one-card initial deal slot. */
export function createTruthChoamIncomeFixture(options: {
advanced?: boolean; bg?: boolean; providerKarama?: boolean; stage?: 'beforeIncome' | 'afterIncome';
} = {}) {
const advanced = options.advanced ?? false, withBG = options.bg ?? false, stage = options.stage ?? 'beforeIncome';
const providerKarama = options.providerKarama ?? withBG;
assert.ok(!withBG || advanced, 'The bounded wealthy BG claim source uses its native Advanced rule');
const asker = 'harkonnen', observer = 'atreides', target = 'choam', recipient = asker, bg = withBG ? 'beneGesserit' : null;
let game = createGame('TRUTHCHOAMINCOME', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced, ['ix', 'choam']);
joinGame(game, newPlayer(observer, 'Atreides', 'atreides')); joinGame(game, newPlayer(target, 'CHOAM', 'choam'));
if (bg) joinGame(game, newPlayer(bg, 'Bene Gesserit', 'beneGesserit'));
const initial = structuredClone(game), actions: TruthChoamIncomeStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthChoamIncome(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthChoamIncome(game, until, actions, true); };
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.ok(game.players.every(player => !player.hand.length));
if (bg) act(bg, { type: 'predict', faction: 'harkonnen', turn: 10 });
const originalCards = truthChoamIncomeInventory(game), originalTraitors = truthChoamIncomeTraitors(game), originalSpice = truthChoamIncomeSpice(game);
assert.deepEqual(originalCards, treacheryDeck(['ix', 'choam']).map(card => card.id).sort());
assert.deepEqual(originalTraitors, traitorDeck(game.players, true).sort());
const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama);
const providerCard = providerKarama ? game.deck.find(card => card.effect === 'karama' && card.id !== karama.id) : null;
assert.ok(!providerKarama || providerCard, 'Select only the second physical printed Karama already in the unopened original deck');
// Native dealStartingTreachery deals H two, A one, then CHOAM one: index3.
const dealOrder = [...truths.map(card => card.id), karama.id, ...(providerCard ? [providerCard.id] : [])].map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), startCash = truthChoamIncomePlayer(game, target).spice;
const startingChoamCards = truthChoamIncomePlayer(game, target).hand.map(card => card.id);
assert.equal(startCash, 2); assert.deepEqual(truthChoamIncomePlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id));
assert.ok(truthChoamIncomePlayer(game, observer).hand.some(card => card.id === karama.id));
if (providerCard) assert.deepEqual(startingChoamCards, [providerCard.id]);
reach(beforeIxSpiceDraw);
const beforeIncome = structuredClone(game), beforeIncomeActions = structuredClone(actions);
const spiceOrder = orderIxSpiceCards(game, [{ territory: 'hagga_basin' }, ...(advanced ? [{ territory: 'broken_land' }] : [])]);
const incomeAmount = choamOpeningIncome(game, truthChoamIncomePlayer(game, target));
assert.equal(game.choamCharity?.turn === game.turn, false); assert.notEqual(truthChoamIncomePlayer(game, target).charityTurn, game.turn);
let income: { before: Game; steps: TruthChoamIncomeStep[]; after: Game } | null = null;
if (stage === 'afterIncome') {
game = reachTruthChoamIncomeResponse(game, actions); const before = structuredClone(game), from = actions.length;
game = allowTruthChoamIncome(game, actions); income = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
assert.deepEqual(game.choamCharity, { turn: game.turn, canceled: false });
assert.equal(truthChoamIncomePlayer(game, target).spice, startCash + incomeAmount);
}
assert.equal(truthChoamIncomePlayer(game, target).spice, startCash + (stage === 'afterIncome' ? incomeAmount : 0));
assert.notEqual(truthChoamIncomePlayer(game, target).charityTurn, game.turn);
assert.deepEqual(truthChoamIncomePlayer(game, target).hand.map(card => card.id), startingChoamCards);
assert.deepEqual(truthChoamIncomeInventory(game), originalCards); assert.deepEqual(truthChoamIncomeTraitors(game), originalTraitors);
assert.deepEqual(truthChoamIncomeSpice(game), originalSpice);
const minimum = startCash + incomeAmount, maximum = minimum + 2;
const spiceFact = { kind: 'spice', compare: 'eq', value: truthChoamIncomePlayer(game, target).spice } as const;
const question: TruthQuestion = { kind: 'turnActions', target, turn: game.turn,
mixed: truthChoamIncomeGroup('and', truthChoamIncomeFact(spiceFact), truthChoamIncomePayment(recipient, minimum)) };
return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, beforeIncome, beforeIncomeActions,
asker, target, recipient, observer, bg, advanced, stage, providerKarama, providerCard: providerCard?.id ?? null,
startCash, incomeAmount, minimum, maximum,
startingChoamCards, truths: truths.map(card => card.id), truth: truths[0].id, karama: karama.id, question, spiceFact,
payment: { type: 'bribe', target: recipient, amount: minimum } satisfies Action,
charity: { type: 'charity' } satisfies Action,
cancel: { type: 'card', card: karama.id, mode: 'cancel' } satisfies Action,
dealOrder, spiceOrder, income, originalCards, originalTraitors, originalSpice,
actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { selected: actions.length, beforeIncome: beforeIncomeActions.length, actualIncomeResponse: income?.steps.length ?? 0 },
sourceDisclosure: 'Genuine public Harkonnen/Atreides/CHOAM selected-Ix/CHOAM setup, with optional original Advanced BG prediction and advisor setup. ' +
'Only original H Truths, A printed Karama and optionally the second original printed Karama in CHOAM native deal slot3, safe unopened Spice positions and scalar lotteries are selected; BG retains its normal native card. ' +
'Native first Storm, closing market passes and Amal-opening passes reach original phase1 before the first real reveal. Any allowed opening receipt follows the actual phase2 counter-response passes. ' +
'Forecast assertions are bounded to current cash, the one original automatic CHOAM opening receipt and one unused ordinary Charity claim. Later held-card market sales, collection, gifts and foreign choices are not forecast or claimed globally impossible. No hand, purse, army, phase, pass list, receipt or claim marker is installed. Counts derive from actual action arrays.',
};
}
export type TruthChoamIncomeFixture = ReturnType<typeof createTruthChoamIncomeFixture>;
export function truthChoamIncomeQuestion(f: TruthChoamIncomeFixture, kind: TruthChoamIncomeKind,
minimum = f.minimum, recipient = f.recipient): TruthQuestion {
if (kind === 'bribe') return { kind, target: f.target, turn: f.game.turn, recipient, minimum };
if (kind === 'bribeExpression') return { kind, target: f.target, turn: f.game.turn, claim: { kind: 'payment', recipient, minimum } };
if (kind === 'mixedBribe') return { kind, target: f.target, turn: f.game.turn, mixed: { kind: 'and', terms: [
{ kind: 'fact', fact: f.spiceFact }, { kind: 'action', action: { recipient, minimum } },
] } };
return { kind, target: f.target, turn: f.game.turn,
mixed: truthChoamIncomeGroup('and', truthChoamIncomeFact(f.spiceFact), truthChoamIncomePayment(recipient, minimum)) };
}
export function declareTruthChoamIncome(f: TruthChoamIncomeFixture, source = f.game, copy: 0 | 1 = 0, actions: TruthChoamIncomeStep[] = []) {
let game = actTruthChoamIncome(source, f.asker, { type: 'card', card: f.truths[copy] }, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthChoamIncome(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask'); return { game, actions };
}
export function askTruthChoamIncome(game: Game, asker: string, question: TruthQuestion, actions: TruthChoamIncomeStep[] = []): Game {
return actTruthChoamIncome(game, asker, { type: 'truthAsk', question: structuredClone(question) }, actions);
}
export function answerTruthChoamIncome(game: Game, target: string, answer: 'yes' | 'no' | 'unknown', actions: TruthChoamIncomeStep[] = []): Game {
return actTruthChoamIncome(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthChoamIncome(f: TruthChoamIncomeFixture, answer: 'yes' | 'no' = 'yes', question: TruthQuestion = f.question,
source = f.game, copy: 0 | 1 = 0, actions: TruthChoamIncomeStep[] = []) {
const declared = declareTruthChoamIncome(f, source, copy, actions), asked = askTruthChoamIncome(declared.game, f.asker, question, actions);
return { game: answerTruthChoamIncome(asked, f.target, answer, actions), declared, asked, actions };
}
+34
View File
@@ -122,6 +122,40 @@ void test('own bribe promises persist with named cumulative progress, delayed sp
assert.equal(BribePromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
});
void test('own pending automatic CHOAM income guidance preserves legal current prepayment callbacks', () => {
for (const phase of [0, 2]) {
const game = projected(phase); game.players.find(player => player.id === 'p')!.faction = 'choam'; game.expansions = ['choam'];
game.charity.incomePending = true; game.charity.incomeCanceled = false;
const html = markup(game); assert.match(html, /data-testid="choam-pending-payment-income"/);
assert.match(html, /Automatic CHOAM income resolves at the Charity opening/); assert.match(html, /not current spendable cash/);
assert.match(html, /ordinary Charity claim is separate/); assert.match(html, /Karama or opposing claims can change the funding/);
assert.match(html, /may still offer legal prepayments/);
if (phase === 0) {
const sent: Action[] = [], button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.bribePromiseGuide!.action);
game.bribeOptions.available = 0; press(button); assert.equal(sent.length, 1, 'Pending automatic income does not raise the current payment quote.');
}
}
});
void test('automatic CHOAM payment note is owner-only, live-only and ends when income resolves or is canceled', () => {
for (const change of [
(game: GameView) => { game.players.find(player => player.id === 'p')!.faction = 'emperor'; game.players.find(player => player.id === 'a')!.faction = 'choam'; },
(game: GameView) => { game.me = 'a'; },
(game: GameView) => { game.charity.incomePending = false; },
(game: GameView) => { game.charity.multiplier = 0; },
(game: GameView) => { game.charity.incomePending = false; game.charity.incomeCanceled = true; },
(game: GameView) => { game.phase = 3; },
(game: GameView) => { game.bribePromises[0].fulfilled = true; },
(game: GameView) => { game.bribePromises[0].released = true; },
]) {
const game = projected(1); game.players.find(player => player.id === 'p')!.faction = 'choam'; game.expansions = ['choam'];
game.charity.incomePending = true; game.charity.incomeCanceled = false; game.bribePromiseGuide = null;
assert.match(markup(game), /data-testid="choam-pending-payment-income"/); change(game);
assert.doesNotMatch(markup(game), /data-testid="choam-pending-payment-income"/);
}
});
void test('actual guide callbacks forward exact ordinary bribes out of turn throughout their supported phases', () => {
for (const phase of [0, 3, 4, 7]) {
const game = projected(phase), sent: Action[] = [];
+246
View File
@@ -0,0 +1,246 @@
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 { spiceDeck, treacheryDeck } from '../game/cards';
import { viewGame, type Action, type Game } from '../game/engine';
import { CHEAP_HERO_TRAITOR, traitorDeck } from '../game/traitors';
import { truthFactAnswer, type TruthQuestion } from '../game/truthtrance';
import {
actTruthChoamIncome as act, advanceTruthChoamIncome as advance, allowTruthChoamIncome as allow,
answerTruthChoamIncome as answer, askTruthChoamIncome as ask, bindTruthChoamIncome as bind,
createTruthChoamIncomeFixture as fixture, declareTruthChoamIncome as declare,
nextTruthChoamIncomeNative as nativeStep, reachTruthChoamIncomeResponse as reachIncome,
settleTruthChoamIncome as settle, truthChoamIncomeClean as clean,
truthChoamIncomeGroup as group, truthChoamIncomeInventory as inventory,
truthChoamIncomePayment as payment, truthChoamIncomePlayer as player,
truthChoamIncomePromise as promise, truthChoamIncomeQuestion as question,
truthChoamIncomeShip as ship, truthChoamIncomeSpice as spice, truthChoamIncomeTraitors as traitors,
type TruthChoamIncomeFixture, type TruthChoamIncomeStep,
} from './fixture-truth-choam-income';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function physical(game: Game, f: TruthChoamIncomeFixture, copies: 0 | 1 | 2 = 1) {
for (const truth of f.truths.slice(0, copies)) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors);
assert.deepEqual(spice(game), f.originalSpice);
}
const pay = (game: Game, f: TruthChoamIncomeFixture, amount: number, recipient = f.recipient,
actions: TruthChoamIncomeStep[] = []) => act(game, f.target, { type: 'bribe', target: recipient, amount }, actions);
void test('native CHOAM income sources retain the original Ix deal, automatic receipt and distinct unused ordinary claim', () => {
for (const options of [{}, { stage: 'afterIncome' as const }, { advanced: true }, { advanced: true, bg: true }] ) {
const f = fixture(options); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.deepEqual(f.originalCards, treacheryDeck(['ix', 'choam']).map(card => card.id).sort()); assert.equal(f.originalCards.length, 47);
assert.deepEqual(f.originalTraitors, traitorDeck(f.afterSetup.players, true).sort()); assert.ok(f.originalTraitors.includes(CHEAP_HERO_TRAITOR));
assert.equal(f.originalSpice.length, spiceDeck(true).length); assert.equal(f.originalSpice.length, 22);
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
assert.ok(player(f.game, f.observer).hand.some(card => card.id === f.karama));
assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), f.startingChoamCards);
assert.equal(f.startCash, 2); assert.equal(f.incomeAmount, 2 * f.game.players.length);
assert.equal(player(f.game, f.target).spice, f.startCash + (f.income ? f.incomeAmount : 0));
assert.notEqual(player(f.game, f.target).charityTurn, f.game.turn);
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.sourceCounts.selected, f.actions.length);
assert.equal(f.sourceCounts.beforeIncome, f.beforeIncomeActions.length); assert.equal(f.sourceCounts.actualIncomeResponse, f.income?.steps.length ?? 0);
if (f.income) {
assert.equal(f.income.before.response?.kind, 'choamCharity'); assert.ok(f.income.steps.every(step => step.action.type === 'passResponse'));
assert.equal(player(f.income.before, f.target).spice, 2); assert.equal(player(f.income.after, f.target).spice, 2 + f.incomeAmount);
assert.deepEqual(f.game.choamCharity, { turn: 1, canceled: false });
} else assert.notEqual(f.game.choamCharity?.turn, f.game.turn);
const asked = ask(declare(f).game, f.asker, question(f, 'bribe', f.maximum)), before = structuredClone(asked);
assert.deepEqual(viewGame(asked, f.target).truthBribeAnswers, ['yes', 'no']);
assert.deepEqual(asked, before, 'A quote never credits automatic income or stamps an ordinary claim'); physical(asked, f, 0);
}
});
void test('allowed automatic CHOAM opening income funds whole, bare, grouped and mixed installments with frozen facts', () => {
for (const kind of ['turnActions', 'bribe', 'bribeExpression', 'mixedBribe'] as const) {
const f = fixture(), raw = question(f, kind); const actions: TruthChoamIncomeStep[] = [];
let game = bind(f, 'yes', raw, f.game, 0, actions).game;
const recipientCash = player(game, f.recipient).spice;
game = pay(game, f, f.startCash, f.recipient, actions); assert.equal(player(game, f.target).spice, 0);
assert.equal(player(game, f.recipient).spice, recipientCash); assert.equal(player(game, f.recipient).bribes, f.startCash);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.ok(!promise(game, kind).fulfilled);
assert.notEqual(game.choamCharity?.turn, game.turn); assert.notEqual(player(game, f.target).charityTurn, game.turn);
game = reachIncome(game, actions); assert.equal(game.response?.kind, 'choamCharity');
rejected(game, f.target, f.charity); assert.equal(player(game, f.target).spice, 0);
game = allow(game, actions); assert.equal(player(game, f.target).spice, f.incomeAmount);
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false }); assert.notEqual(player(game, f.target).charityTurn, game.turn);
game = pay(game, f, f.incomeAmount, f.recipient, actions);
assert.equal(promise(game, kind).fulfilled, true); assert.equal(player(game, f.target).spice, 0);
assert.equal(player(game, f.recipient).spice, recipientCash); assert.equal(player(game, f.recipient).bribes, f.minimum);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'bribe').length, 2);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'charity').length, 0); physical(game, f);
}
});
void test('the fixed CHOAM receipt and one ordinary Charity fund the maximum without sharing either marker twice', () => {
const f = fixture(); const actions: TruthChoamIncomeStep[] = [];
let game = bind(f, 'yes', question(f, 'turnActions', f.maximum), f.game, 0, actions).game;
game = pay(game, f, f.startCash, f.recipient, actions); game = allow(reachIncome(game, actions), actions);
game = pay(game, f, f.incomeAmount, f.recipient, actions); assert.equal(player(game, f.target).spice, 0);
assert.equal(charityQuote(game, player(game, f.target)).total, 2); assert.notEqual(player(game, f.target).charityTurn, game.turn);
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'charity');
assert.equal(guide.action.turnActionPromiseEvent, promise(game).event); assert.equal(guide.action.turnActionPromisePhase, 2);
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, guide.action, actions);
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).charityTurn, game.turn);
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false });
assert.equal(game.turnActionPromises![0].payments[0].amount, f.minimum, 'Neither automatic nor claimed income is itself a payment');
game = pay(game, f, 2, f.recipient, actions); assert.equal(player(game, f.target).spice, 0);
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.recipient).bribes, f.maximum);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'charity').length, 1);
rejected(game, f.target, f.charity); physical(game, f);
});
void test('two original Truths cannot reserve the same automatic CHOAM income and ordinary claim for different recipients', () => {
const f = fixture(); let game = bind(f, 'yes', question(f, 'bribe', f.maximum)).game;
const earlier = structuredClone(game.bribePromises![0]), history = structuredClone(game.truthHistory![0]);
const asked = ask(declare(f, game, 1).game, f.asker, question(f, 'bribe', 1, f.observer));
assert.deepEqual(viewGame(asked, f.target).truthBribeAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
game = answer(asked, f.target, 'no'); assert.deepEqual(game.bribePromises![0], earlier); assert.deepEqual(game.truthHistory![0], history);
assert.equal(player(game, f.target).spice, f.startCash); assert.notEqual(game.choamCharity?.turn, game.turn);
assert.notEqual(player(game, f.target).charityTurn, game.turn); physical(game, f, 2);
});
void test('real Karama cancellation releases a large fixed-income commitment while a current-cash plus ordinary-claim alternative survives', () => {
for (const minimum of [10, 4]) {
const f = fixture(); let game = bind(f, 'yes', question(f, 'turnActions', minimum)).game;
game = pay(game, f, f.startCash); game = reachIncome(game);
game = settle(act(game, f.observer, f.cancel));
assert.deepEqual(game.choamCharity, { turn: 1, canceled: true }); assert.equal(player(game, f.target).spice, 0);
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(game.turnActionPromises![0].payments[0].amount, f.startCash);
if (minimum === 10) { assert.equal(promise(game).released, true); assert.ok(!promise(game).fulfilled); }
else {
assert.ok(!promise(game).released); const claim = viewGame(game, f.target).turnActionGuide; assert.ok(claim);
assert.equal(claim.action.type, 'charity'); game = act(game, f.target, claim.action); game = pay(game, f, 2);
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.recipient).bribes, 4);
}
physical(game, f);
}
});
void test('already collected automatic income and claimed or expired ordinary Charity cannot be quoted a second time', () => {
const f = fixture({ stage: 'afterIncome' });
let asked = ask(declare(f).game, f.asker, question(f, 'bribe', f.maximum + 1));
assert.deepEqual(viewGame(asked, f.target).truthBribeAnswers, ['no']);
let game = pay(f.game, f, f.minimum); game = act(game, f.target, f.charity); game = pay(game, f, 2);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).charityTurn, game.turn);
rejected(game, f.target, f.charity); asked = ask(declare(f, game).game, f.asker, question(f, 'bribe', 1));
assert.deepEqual(viewGame(asked, f.target).truthBribeAnswers, ['no']); game = answer(asked, f.target, 'no'); physical(game, f);
const expired = fixture({ stage: 'afterIncome' }); game = pay(expired.game, expired, expired.minimum);
game = advance(game, state => state.phase === 3 && clean(state), [], true);
assert.equal(player(game, expired.target).spice, 0); assert.notEqual(player(game, expired.target).charityTurn, game.turn);
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false }); rejected(game, expired.target, expired.charity);
asked = ask(declare(expired, game).game, expired.asker, question(expired, 'bribe', 1));
assert.deepEqual(viewGame(asked, expired.target).truthBribeAnswers, ['no']); game = answer(asked, expired.target, 'no'); physical(game, expired);
});
void test('payments and a later original shipment share the same single CHOAM receipt and ordinary claim', () => {
const f = fixture();
const unfunded: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn,
mixed: group('and', payment(f.recipient, f.maximum - 1), ship()) };
const asked = ask(declare(f).game, f.asker, unfunded);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const funded: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn,
mixed: group('and', payment(f.recipient, f.minimum), ship()) };
let game = bind(f, 'yes', funded).game; game = pay(game, f, f.startCash); game = allow(reachIncome(game));
game = pay(game, f, f.incomeAmount); game = act(game, f.target, f.charity);
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), [], true);
game = settle(act(game, f.target, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 }));
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.recipient).bribes, f.minimum);
assert.equal(player(game, f.target).charityTurn, 1); assert.deepEqual(game.choamCharity, { turn: 1, canceled: false }); physical(game, f);
});
void test('an actual opposing ordinary claim debits CHOAM and releases a commitment beyond the remaining fixed income without veto', () => {
const f = fixture({ stage: 'afterIncome' }); let game = bind(f, 'yes', question(f, 'bribe', f.maximum)).game;
const opponentCash = player(game, f.observer).spice;
game = act(game, f.observer, { type: 'bribe', target: f.recipient, amount: opponentCash });
assert.equal(player(game, f.observer).spice, 0); assert.equal(player(game, f.target).spice, f.minimum);
game = act(game, f.observer, f.charity);
assert.equal(player(game, f.observer).spice, 2); assert.equal(player(game, f.target).spice, f.minimum - 2);
assert.equal(player(game, f.observer).charityTurn, game.turn); assert.notEqual(player(game, f.target).charityTurn, game.turn);
assert.equal(promise(game, 'bribe').released, true); assert.equal(game.bribePromises![0].progress, 0); physical(game, f);
});
void test('Advanced BG forced settlement after CHOAM passes last releases the fixed-income promise without attributing a voluntary debit', () => {
const f = fixture({ advanced: true, bg: true }); assert.ok(f.bg);
assert.ok(f.providerCard); assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), [f.providerCard]);
for (const kind of ['turnActions', 'bribe'] as const) {
let game = bind(f, 'yes', question(f, kind, f.maximum), structuredClone(f.game)).game;
game = pay(game, f, f.startCash); game = allow(reachIncome(game));
assert.equal(player(game, f.target).spice, f.incomeAmount); assert.equal(player(game, f.bg).spice, 5);
game = act(game, f.bg, f.charity); assert.equal(game.response?.kind, 'bgCharity');
assert.equal(player(game, f.target).spice, f.incomeAmount); assert.ok(!promise(game, kind).released);
while (game.response?.kind === 'bgCharity') {
const response = game.response;
const actor = game.players.find(candidate => candidate.id !== f.target && candidate.id !== response.owner &&
!response.passed.includes(candidate.id) && !!viewGame(game, candidate.id).responseControls?.cancelCards.length);
if (!actor) break;
game = act(game, actor.id, { type: 'passResponse' });
}
assert.equal(game.response?.kind, 'bgCharity'); assert.ok(!game.response.passed.includes(f.target));
assert.ok(viewGame(game, f.target).responseControls?.cancelCards.includes(f.providerCard));
game = act(game, f.target, { type: 'passResponse' });
assert.equal(game.response, null); assert.equal(player(game, f.bg).spice, 7);
assert.equal(player(game, f.target).spice, f.incomeAmount - 2); assert.equal(player(game, f.bg).charityTurn, game.turn);
assert.notEqual(player(game, f.target).charityTurn, game.turn); assert.equal(promise(game, kind).released, true);
assert.equal(kind === 'turnActions' ? game.turnActionPromises![0].payments[0].amount : game.bribePromises![0].progress, f.startCash);
assert.ok(player(game, f.target).hand.some(card => card.id === f.providerCard)); physical(game, f);
}
});
void test('actual Charity guides retain the original CHOAM promise event and owner through JSON without crediting income twice', () => {
for (const kind of ['turnActions', 'bribe'] as const) {
const f = fixture({ stage: 'afterIncome' }); let game = bind(f, 'yes', question(f, kind, f.maximum)).game;
game = pay(game, f, f.minimum);
const view = viewGame(game, f.target), guide = kind === 'turnActions' ? view.turnActionGuide : view.bribePromiseGuide;
assert.ok(guide); assert.equal(guide.action.type, 'charity');
assert.equal(kind === 'turnActions' ? guide.action.turnActionPromiseEvent : guide.action.bribePromiseEvent, promise(game, kind).event);
rejected(game, f.asker, guide.action);
rejected(game, f.target, kind === 'turnActions' ? { ...guide.action, turnActionPromiseEvent: 'stale-choam-income-event' }
: { ...guide.action, bribePromiseEvent: 'stale-choam-income-event' });
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, packet); rejected(game, f.target, packet);
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).charityTurn, game.turn);
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false });
const next = viewGame(game, f.target), final = kind === 'turnActions' ? next.turnActionGuide : next.bribePromiseGuide;
assert.ok(final); assert.equal(final.action.type, 'bribe'); assert.equal(final.action.amount, 2);
game = act(game, f.target, final.action); assert.equal(promise(game, kind).fulfilled, true); physical(game, f);
}
});
void test('all four minimal profiles await the real CHOAM response then use legal installments and one ordinary Charity', () => {
for (const kind of ['turnActions', 'bribe'] as const) {
const f = fixture(), asked = ask(declare(f).game, f.asker, question(f, kind, f.maximum));
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthChoamIncomeStep[] = [];
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, `${kind}/${difficulty}`);
assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
for (let count = 0; count < 160 && !promise(game, kind).fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const guide = kind === 'turnActions' ? view.turnActionGuide : view.bribePromiseGuide;
if (guide) {
const action = botActions(view).find(candidate => kind === 'turnActions'
? candidate.turnActionPromiseEvent === guide.action.turnActionPromiseEvent
: candidate.bribePromiseEvent === guide.action.bribePromiseEvent);
assert.ok(action, `${kind}/${difficulty}`); assert.deepEqual(action, guide.action);
if (action.type === 'charity') assert.equal(game.phase, 2);
game = act(game, f.target, action, actions);
} else {
const step = nativeStep(game); assert.ok(step, `${kind}/${difficulty}`); game = act(game, step.actor, step.action, actions);
}
}
assert.equal(promise(game, kind).fulfilled, true, `${kind}/${difficulty}`);
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false }); assert.equal(player(game, f.target).charityTurn, game.turn);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'charity').length, 1);
assert.ok(actions.filter(step => step.actor === f.target && step.action.type === 'bribe').length >= 3);
assert.equal(player(game, f.recipient).bribes, f.maximum); assert.equal(player(game, f.target).spice, 0); physical(game, f);
}
}
});
+32
View File
@@ -1470,6 +1470,38 @@ void test('whole payment timelines forward an exact positive unused Charity quot
assert.match(markup(game), /future claim is not current spendable spice/);
});
void test('whole CHOAM commitments explain pending automatic income while ordinary Charity still waits for its native quote', () => {
const game = projected(2, { type: 'charity' }); game.players.find(player => player.id === 'p')!.faction = 'choam'; game.expansions = ['choam'];
game.charity.incomePending = true; game.charity.incomeCanceled = false;
const html = markup(game); assert.match(html, /data-testid="choam-pending-payment-income"/);
assert.match(html, /not current spendable cash/); assert.match(html, /ordinary Charity claim is separate/);
assert.match(html, /Karama or opposing claims can change the funding/); assert.match(html, /may still offer legal prepayments/);
const sent: Action[] = [], pending = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(pending.props.disabled, true); press(pending); assert.equal(sent.length, 0);
game.charity.incomePending = false; game.charity.incomeCanceled = true;
press(pending); assert.equal(sent.length, 0, 'The old source quote remains stale after automatic income is canceled.');
assert.doesNotMatch(markup(game), /data-testid="choam-pending-payment-income"/);
const current = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(current.props.disabled, false); press(current); assert.equal(sent[0], game.turnActionGuide!.action);
});
void test('whole automatic-income guidance stays private to live CHOAM owners before the Charity opening resolves', () => {
for (const change of [
(game: GameView) => { game.players.find(player => player.id === 'p')!.faction = 'emperor'; game.players.find(player => player.id === 'a')!.faction = 'choam'; },
(game: GameView) => { game.me = 'a'; },
(game: GameView) => { game.charity.incomePending = false; game.charity.incomeCanceled = true; },
(game: GameView) => { game.charity.multiplier = 0; },
(game: GameView) => { game.phase = 3; },
(game: GameView) => { game.turnActionPromises[0].fulfilled = true; },
(game: GameView) => { game.turnActionPromises[0].released = true; },
]) {
const game = projected(1); game.players.find(player => player.id === 'p')!.faction = 'choam'; game.expansions = ['choam'];
game.charity.incomePending = true; game.charity.incomeCanceled = false; game.turnActionGuide = null;
assert.match(markup(game), /data-testid="choam-pending-payment-income"/); change(game);
assert.doesNotMatch(markup(game), /data-testid="choam-pending-payment-income"/);
}
});
void test('whole Charity guides reject claimed, stale, unready native quotes and extra fields without generic fallback', () => {
for (const change of [
(game: GameView) => { game.players.find(player => player.id === 'p')!.charityClaimed = true; },