diff --git a/components/bribe-promises.tsx b/components/bribe-promises.tsx index 006cc4bd..d0b30c73 100644 --- a/components/bribe-promises.tsx +++ b/components/bribe-promises.tsx @@ -15,31 +15,45 @@ export function BribePromiseGuideButton(props: BribePromiseProps) { if (!guide) return null; const actor = game.me, turn = game.turn, phase = game.phase, action = guide.action; const event = action.bribePromiseEvent, signature = JSON.stringify(action); + const send = props.send, charity = game.charity; + const charityOwner = action.type === 'charity' ? game.players.find(player => player.id === actor) : undefined; + const charitySource = (current: GameView) => { + const owner = current.players.find(player => player.id === actor); + return JSON.stringify({ quote: current.charity, claimed: owner?.charityClaimed, spice: owner?.spice }); + }; + const charitySignature = action.type === 'charity' ? charitySource(game) : null; const canAct = () => { const current = props.game, me = current.players.find(player => player.id === current.me), options = current.bribeOptions; const target = typeof action.target === 'string' ? current.players.find(player => player.id === action.target) : undefined; const limit = options.targets.find(recipient => recipient.id === action.target); - return !props.busy && current === game && bribePromiseModeSupported(current) && current.status === 'playing' && + const payment = action.type === 'bribe' && !!me && !!target && target.id !== actor && target.id !== me.ally && + options.blocked === null && Number.isSafeInteger(options.available) && + typeof action.amount === 'number' && action.amount <= options.available && + !!limit && Number.isSafeInteger(limit.maximum) && action.amount <= limit.maximum && + !!bribeAction(options, target.id, action.amount); + const claim = action.type === 'charity' && phase === 2 && !!me && me === charityOwner && + Object.keys(action).every(key => ['type', 'bribePromiseEvent'].includes(key)) && + !current.ready.includes(actor) && me.charityClaimed === false && current.charity === charity && + charitySource(current) === charitySignature && current.charity.incomePending === false && + Number.isSafeInteger(current.charity.amount) && current.charity.amount > 0; + return !props.busy && props.send === send && current === game && bribePromiseModeSupported(current) && current.status === 'playing' && Number.isSafeInteger(current.phase) && current.phase >= 0 && current.phase <= 8 && current.phase === phase && current.turn === turn && current.me === actor && current.bribePromiseGuide === guide && current.bribePromiseGuide.action === action && JSON.stringify(action) === signature && - action.type === 'bribe' && typeof event === 'string' && + typeof event === 'string' && (liveBribePromises(current.bribePromises, actor, turn).some(promise => promise.event === event) || liveBribeExpressionPromises(current.bribeExpressionPromises, actor, turn).some(promise => promise.event === event) || current.mixedBribePromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn && !promise.fulfilled && !promise.released)) && - !!me && !me.autopilot && !!target && target.id !== actor && target.id !== me.ally && - options.blocked === null && Number.isSafeInteger(options.available) && - typeof action.amount === 'number' && action.amount <= options.available && - !!limit && Number.isSafeInteger(limit.maximum) && action.amount <= limit.maximum && - !!bribeAction(options, target.id, action.amount) && + !!me && !me.autopilot && (payment || claim) && !current.truthtrance && !current.response && !current.decision && !current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed; }; const recipient = game.players.find(player => player.id === action.target)?.name ?? 'the quoted recipient'; return ; } @@ -64,6 +78,8 @@ export function BribePromises(props: BribePromiseProps) { 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's Mentat Pause. Ordinary bribes can be paid out of turn when the current bribe controls permit them; original timing and payment rules still apply. + A legal timeline may use payments now, one remaining Charity claim and later installments. + The future claim is not current spendable spice; only actual payments add to the recorded total. {' '}Bribe promise rules.

{liveBribePromises(own, props.game.me, props.game.turn).length > 0 && } diff --git a/components/turn-action-promises.tsx b/components/turn-action-promises.tsx index c6bc3e65..4d4f9563 100644 --- a/components/turn-action-promises.tsx +++ b/components/turn-action-promises.tsx @@ -40,6 +40,13 @@ export function TurnActionGuideButton(props: TurnActionProps) { if (!guide) return null; const actor = game.me, turn = game.turn, phase = game.phase, event = guide.event, action = guide.action; const act = props.act, fillPlan = props.fillPlan, battle = game.battle; + const charity = game.charity; + const charityOwner = action.type === 'charity' ? game.players.find(player => player.id === actor) : undefined; + const charitySource = (current: GameView) => { + const owner = current.players.find(player => player.id === actor); + return JSON.stringify({ quote: current.charity, claimed: owner?.charityClaimed, spice: owner?.spice }); + }; + const charitySignature = action.type === 'charity' ? charitySource(game) : null; const battleSignature = JSON.stringify(publicBattleContext(game)); const power: NativeBattlePower | null = action.type === 'voice' ? 'voice' : action.type === 'prescience' ? 'prescience' : action.type === 'declineBattlePower' && (battle?.preparation?.kind === 'voice' || battle?.preparation?.kind === 'prescience') @@ -263,8 +270,13 @@ export function TurnActionGuideButton(props: TurnActionProps) { const battlePreparation = genericCard && planWindow; const declaration = action.type === 'traitorCall' && typeof action.call === 'boolean' && !!battle && currentBattle && battle.revealed && battle.traitorVoters.includes(actor) && !battle.traitorSubmitted.includes(actor); + const charityClaim = action.type === 'charity' && phase === 2 && !!me && me === charityOwner && + Object.keys(action).every(key => ['type', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key)) && + !current.ready.includes(actor) && me.charityClaimed === false && current.charity === charity && + charitySource(current) === charitySignature && current.charity.incomePending === false && + Number.isSafeInteger(current.charity.amount) && current.charity.amount > 0; const opportunity = openingReady || earlyWindowReady || earlyWindowBox || printedWindow || ownedPower || payment || paidBox || printedHajr || printedGhola || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2 - ? !current.ready.includes(actor) && (['charity', 'ready'].includes(action.type) || genericCard) + ? charityClaim || !current.ready.includes(actor) && (action.type === 'ready' || genericCard) : phase === 3 ? invoice || nativePass || requiredBid || action.type === 'passBid' && !current.richeseAuction && currentAuction && current.auction?.active === actor : phase === 4 @@ -309,7 +321,7 @@ export function TurnActionGuideButton(props: TurnActionProps) { ? game.players.find(player => player.id === actor)?.ally ? 'Break your current alliance while preserving your whole answer' : 'Withdraw your alliance offer while preserving your whole answer' : `Accept ${recipient}’s alliance invitation while preserving your whole answer` - : action.type === 'charity' ? 'Claim Charity while preserving your whole answer' + : action.type === 'charity' ? `Claim ${game.charity.amount} Charity spice while preserving your whole answer` : action.type === 'ready' ? amalReadyAction ? 'Pass this original phase opening while preserving your whole answer' : phase === 8 ? 'Ready to end this turn while honoring your whole answer' : earlyWindowReadyAction && (phase === 0 || !!game.spiceWindow) ? `Accept this ${phase === 0 ? 'Storm' : 'fresh spice-blow'} window while preserving your whole answer` @@ -367,6 +379,8 @@ export function TurnActionPromises(props: TurnActionProps) {

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's Mentat opening; collection never counts twice. + A legal timeline may use payments now, one remaining Charity claim and later installments. + The future claim is not current spendable spice; only actual payments add to the recorded total. Bid conditions count the highest accepted future declaration in the named current ordinary lot, never the sum of smaller bids. A qualifying bid counts even when another player wins; winning still requires the original payment. Force Revival conditions count cumulative future ordinary returns; normal and elite physical counters each count once. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 8b87230a..90610ee7 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,34 @@ open mode gates. Continue remaining Basic functions before Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**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 +final purse. Current card windows retain their order before future Charity. +Actual phase-2 guides claim once; positive acceptance retains own readiness. +A premature claim cannot evade a promised payment. + +Frozen **24 files, 349/349**, types/lint/build and seven native journeys +pass, including existing early-movement regressions. Journeys include 55 +GameTable SSR renders, 7 actual answer callbacks and 26 exact guide callbacks +(23 whole, 3 scalar). Genuine selected-Ix sources use 14 original opening +actions, 18 after real Amal/closure, or 17 before printed Thumper; inventories and +all source/further-action counts derive from native arrays. + +Actual paths prove Amal cash 5 then pay 5/Charity 2/pay 1, Thumper before its future +claim, separate recipient 3+3 installments, one claim shared with shipment, and +manual pay 4→cash 1: claiming early would strand owed 3 and rejects; pay 1/claim 2/pay 2 +fulfills 7. Claimed/expired income and two-Truth overbooking reject; No caps and +history remain. A narrow fixture question annotation and unused import are +corrected during feedback. No strategic AI or comprehensive assurance runs. + +Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-charity-proof-v3/`; +journeys/HTML: adjacent `20261027-truth-charity-runtime/run-v3/`. +No HTTP/storage boundary, saved-game/database, restart or deployed operation +changes. Unresolved CHOAM own-opening income, other future income, broader deals/ +optional combinations, full modes and final acceptance remain open. +[Contract](TRUTHTRANCE_TURN_ACTIONS.md#charity-funded-installments--27-october-2026). + **Truthtrance / phase-opening Amal — 27 October. Prototyped / Partial.** Supported original openings now admit whole questions and actual printed Amal conditions. Native halving precedes ordinary payments. Definite answers reopen diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 53e9669a..61e37d95 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,17 @@ # Dune implementation status +## 27 October 2026 — Charity-funded payment installments + +Prototyped / Partial. Bare and combined payments can use one owned remaining +Charity claim after actual prepayments, preserving current card windows and the +same final purse for movement/Revival. Current-phase guides, readiness, cumulative +caps and bad-order rejection are connected. Frozen 24 files, 349/349, types/ +lint/build and seven journeys (55 SSR renders, 7 answers, 26 guides) pass, +including existing early-movement cases. No strategic tuning, deployment or saved- +game operation occurs. Wider income/deals and complete acceptance remain. +[Contract](TRUTHTRANCE_TURN_ACTIONS.md#charity-funded-installments--27-october-2026). +Private proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-charity-proof-v3/`. + ## 27 October 2026 — Phase-opening Amal commitments Prototyped / Partial. Original opening questions, actual printed Amal costs, diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 45498a23..1ef07f51 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,19 @@ # Rule decision index +## Truthtrance Charity-funded installments — 27 October 2026 + +Prototyped / Partial. Extend ordinary payment witnesses with one remaining owned +Charity claim under its native wealth, phase, token and once-per-turn rules. +Real prepayments may reduce wealth before the claim; the later installment +still counts only when actually paid. Keep current card/Nexus windows before +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. +[Contract](TRUTHTRANCE_TURN_ACTIONS.md#charity-funded-installments--27-october-2026). + ## Truthtrance printed Amal and phase openings — 27 October 2026 Prototyped / Partial. Extend the existing whole current-turn answer composition diff --git a/docs/TRUTHTRANCE_BRIBE_PROMISES.md b/docs/TRUTHTRANCE_BRIBE_PROMISES.md index 5af3322e..ef790e0b 100644 --- a/docs/TRUTHTRANCE_BRIBE_PROMISES.md +++ b/docs/TRUTHTRANCE_BRIBE_PROMISES.md @@ -1,5 +1,12 @@ # Truthtrance ordinary bribe-payment promises +**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 +underfunding early claim cannot evade the answer. This supersedes only the +ordinary-Charity exclusion below. Other future income/preparations remain +bounded. [Current contract](TRUTHTRANCE_TURN_ACTIONS.md#charity-funded-installments--27-october-2026). + **26 October timing follow-up:** [Mentat bribes](MENTAT_BRIBES.md) extend the current-turn window through phase8. Yes must finish before Mentat Ready; No remains live across opening collection until the actual next turn. Late receipts diff --git a/docs/TRUTHTRANCE_TURN_ACTIONS.md b/docs/TRUTHTRANCE_TURN_ACTIONS.md index fb609a24..845df0f9 100644 --- a/docs/TRUTHTRANCE_TURN_ACTIONS.md +++ b/docs/TRUTHTRANCE_TURN_ACTIONS.md @@ -1,5 +1,48 @@ # Truthtrance combined current-turn actions +## Charity-funded installments — 27 October 2026 + +**Prototyped / Partial.** Ordinary payment witnesses can now schedule one +remaining owned Charity claim and the actual installments around it. After +printed Amal reduces cash 10 to 5, a real payment of 5 can precede the normal +two-spice claim; a later payment of 1 completes 6. Different recipients share +the same cash and claim. Charity itself never counts as a bribe, and actual +recipient receipts remain deferred to their original Mentat opening. + +The shared private planner partitions exact deficits, validates native Charity +and stamps the hypothetical claim once. The same helper serves existing +movement/Revival preparation. The final purse funds every later obligation: +after Amal, payment 6 plus a two-spice desert shipment is unfunded; payment 5 +plus that shipment can use the single claim. An early claim at cash 1 cannot +strand a previously accepted seven-spice payment: pay the remaining 1 first, +then claim 2 and pay the remaining 2. Actual rejected actions stay immutable. + +Current printed windows precede future Charity. A required Thumper spends its +physical cost before the later claim; quotes do not consume hidden normal +Spice cards or turn future money into current cash. Native card preparations +can precede payments where their effect/order requires it. Any original Nexus +agreement must fit before the future phase and retain recipient restrictions. +No chosen branch, timeline or prospective income is installed in a real game. + +Bare, grouped, mixed and combined payment questions use guarded current-phase +payment/Charity packets. Positive acceptance reopens owned Charity readiness; +own Ready cannot forfeit the necessary claim. After an actual claim or phase +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 +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. +Frozen evidence: 24 affected files, 349/349, types/lint/build and seven +native/SSR/control journeys pass (55 GameTable renders, 7 answer callbacks, +23 whole and 3 scalar guide callbacks). Private proof: +`/home/quintine/.local/share/dune/checkpoints/20261027-truth-charity-proof-v3/`; +journeys/HTML: adjacent `20261027-truth-charity-runtime/run-v3/`. +This is local native/SSR evidence, not browser hydration or deployed acceptance. + ## Printed Amal and phase openings — 27 October 2026 **Prototyped / Partial.** Whole questions can now be asked during a supported diff --git a/game/engine.ts b/game/engine.ts index 50ec4642..d43a387b 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -599,6 +599,7 @@ import { resolveBattleWeapons } from './effective-weapons'; import { choamGholaEvent, choamGholaSkillOfferEvent, choamGholaSkillSignature, choamMarketGholaError, choamSaleGholaTiming, type ChoamMarketGhola } from './choam-market-ghola'; import { quoteAdvisorPreparationWindow, quoteAdvisorPreparation, validateAdvisorPreparation, projectAdvisorPreparationWindow, AdvisorPreparationError, type AdvisorPreparationWindow, type AdvisorPreparationSource } from './advisor-preparation-window'; import { quoteFremenRevivalGrant, validateFremenRevivalGrant, projectFremenRevivalGrant, FremenRevivalGrantError, type FremenRevivalGrantSource } from './fremen-revival-grant'; +import { splitOrdinaryPayments, type OrdinaryPayment } from './ordinary-payment-plan'; import { charityAmount, charityQuote, @@ -15117,7 +15118,7 @@ function alliancePromisePlan(g: Game, p: Player, promises = g.alliancePromises ? if (accepts) getPlayer(future, p.id).ally = accepts; if (accepts && hasBribePromises(trial, p.id, true) && !bribePromiseCompletion(future, getPlayer(future, p.id))) { const payment = bribePromiseCompletion(trial, actor)?.find(action => action.target === accepts); - if (!payment) continue; + if (!payment || typeof payment.amount !== 'number' || payment.amount > uncommittedSpice(trial, actor)) continue; const amount = payment.amount as number; actor.spice -= amount; getPlayer(trial, accepts).bribes += amount; @@ -15365,6 +15366,64 @@ 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; + 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; + const player = getPlayer(g, owner); + if (charityQuote(g, player).total <= 0) return null; + try { + if (source.phase < 2 && byFaction(g, 'choam') && g.choamCharity?.turn !== g.turn) { + if (player.faction === 'choam') return null; + // Another faction's ordinary entitlement survives either CHOAM opening + // outcome. Quote the beneficiary only, without choosing that response. + player.spice += charityQuote(g, player).total; player.charityTurn = g.turn; + return g; + } + const next = applyActionInner(g, owner, { type: 'charity' }, 'shipmentPreparation'); + const response = next.response; + if (response?.kind === 'bgCharity' && response.owner === owner) { + next.response = null; + payCharity(next, getPlayer(next, owner), response.amount ?? 2, response.charityHomeworld ?? 0); + } + return next; + } catch (error) { if (error instanceof RuleError) return null; throw error; } +} +function quoteOrdinaryPayments(g: Game, actor: string, payments: readonly OrdinaryPayment[]) { + const owner = getPlayer(g, actor); + for (const payment of payments) { + const recipient = getPlayer(g, payment.recipient); + requireRule(recipient.id !== actor && recipient.id !== owner.ally && + payment.amount <= uncommittedSpice(g, owner), 'Retain legal ordinary payment funding and recipients.'); + checkBureaucratContributors(g, recipient.id, [{ payer: actor, amount: payment.amount }]); + recordBribePayment(g, actor, recipient.id, payment.amount); + owner.spice -= payment.amount; recipient.bribes += payment.amount; + } +} +function* ordinaryPaymentSeeds(source: Game, actor: string, payments: readonly OrdinaryPayment[], + beforeFuture: (game: Game) => void = () => {}): Generator<{ game: Game; actions: Action[] }> { + if (!Number.isSafeInteger(payments.reduce((sum, payment) => sum + payment.amount, 0))) return; + const split = splitOrdinaryPayments(payments, Math.max(0, uncommittedSpice(source, getPlayer(source, actor)))); + try { + const current = structuredClone(source); + quoteOrdinaryPayments(current, actor, split.before); + 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 claimed = forecastOrdinaryCharity(current, actor); + if (!claimed) return; + quoteOrdinaryPayments(claimed, actor, split.after); + yield { game: claimed, actions: [...actions, { type: 'charity' }, + ...split.after.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount }))] }; + } catch (error) { if (!(error instanceof RuleError)) throw error; } +} /** Shared funded completion; alternatives are quotations and never become a * stored route. Record its future payments before checking the remaining purse * so a satisfied bribe is not reserved for a second time by another family. */ @@ -15375,29 +15434,19 @@ function bribePromiseCompletion(g: Game, p: Player, promises = g.bribePromises ? return turnActionCompletion({ ...g, bribePromises: promises, bribeExpressionPromises: expressions }, p.id)?.payments ?? null; for (const required of bribePromiseRule(() => bribeCompletionCandidates(promises, expressions, p.id, g.turn))) { if (!required.length) return []; - const trial = structuredClone(g), actor = getPlayer(trial, p.id); - trial.truthtrance = null; - if (bribeTimingBlock(trial)) return null; - const total = required.reduce((sum, payment) => sum + payment.amount, 0); - if (!Number.isSafeInteger(total) || total > uncommittedSpice(trial, actor)) continue; - trial.bribePromises = structuredClone(promises); - trial.bribeExpressionPromises = structuredClone(expressions); - try { - for (const payment of required) { - if (payment.recipient === p.id || payment.recipient === p.ally || - !g.players.some(player => player.id === payment.recipient)) throw new RuleError('Choose a non-allied bribe recipient.'); - recordBribePayment(trial, p.id, payment.recipient, payment.amount); - } - actor.spice -= total; + const source = structuredClone(g); source.truthtrance = null; + if (bribeTimingBlock(source)) return null; + source.bribePromises = structuredClone(promises); + source.bribeExpressionPromises = structuredClone(expressions); + for (const seed of ordinaryPaymentSeeds(source, p.id, required)) try { + const trial = seed.game; const voluntary = { actor: p.id, action: { type: 'bribe' } }; - for (const payment of required) - checkBureaucratContributors(trial, payment.recipient, [{ payer: p.id, amount: payment.amount }]); reconcileBattlePromises(trial, voluntary); reconcileShipmentPromises(trial, voluntary); reconcileGroundMovementPromises(trial, p.id, voluntary.action, g.phase); reconcileBidPromises(trial, voluntary); reconcileRevivalPromises(trial, voluntary); - return required.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount })); + return seed.actions; } catch (error) { if (!(error instanceof RuleError)) throw error; } } return null; @@ -15411,21 +15460,23 @@ function bribeCompatibleLaterPromises(g: Game, p: Player, function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no' | 'unknown')[] { if (!bribeQuestionWindow(g) || claim.turn !== g.turn || claim.recipient === p.id || claim.recipient === p.ally || !g.players.some(player => player.id === claim.recipient)) return []; - return (['yes', 'no'] as const).filter(answer => !!bribePromiseCompletion(g, p, [...(g.bribePromises ?? []), + const source = g.phase === 2 ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; + return (['yes', 'no'] as const).filter(answer => !!bribePromiseCompletion(source, p, [...(g.bribePromises ?? []), { ...claim, event: 'candidate', player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: 0 }])); } function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionClaim): ('yes' | 'no' | 'unknown')[] { if (!bribeQuestionWindow(g) || claim.turn !== g.turn || bribeExpressionRecipients(claim.claim) .some(recipient => recipient === p.id || recipient === p.ally || !g.players.some(player => player.id === recipient))) return []; - return (['yes', 'no'] as const).filter(answer => !!bribePromiseCompletion(g, p, g.bribePromises ?? [], + const source = g.phase === 2 ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; + return (['yes', 'no'] as const).filter(answer => !!bribePromiseCompletion(source, p, g.bribePromises ?? [], [...(g.bribeExpressionPromises ?? []), { ...claim, event: 'candidate', player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: [] }])); } function reopenBribePayer(g: Game, p: Player, answer: boolean) { - if (answer && (g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g)) && g.ready.includes(p.id)) { + if (answer && (g.phase === 2 || g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g)) && g.ready.includes(p.id)) { g.ready = g.ready.filter(player => player !== p.id); - log(g, `${p.name} has a new Truthtrance bribe commitment and must finish ${g.phase === 8 ? 'Mentat Pause' : 'Collection'} again after paying it.`); + log(g, `${p.name} has a new Truthtrance bribe commitment and must finish ${g.phase === 2 ? 'Charity' : g.phase === 8 ? 'Mentat Pause' : 'Collection'} again after its required preparation.`); } } function bindBribeTruth(g: Game, p: Player, claim: BribeClaim, answer: boolean, asker: string) { @@ -15509,10 +15560,12 @@ function reconcileBribePromises(g: Game, voluntary?: { actor: string; action: Ac const pending = g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard || g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal; if (pending && voluntary) { - const available = Math.max(0, uncommittedSpice(g, getPlayer(g, voluntary.actor))); + const future = structuredClone(g); future.truthtrance = null; + if (future.phaseOpening) projectTurnActionOpeningEnd(future); + future.response = null; future.decision = null; future.pendingKarama = null; future.pendingTreacheryDiscard = null; let affordable = false; for (const required of bribeCompletionCandidates(g.bribePromises ?? [], g.bribeExpressionPromises ?? [], voluntary.actor, g.turn)) { - if (required.reduce((sum, payment) => sum + payment.amount, 0) <= available) { affordable = true; break; } + if (!ordinaryPaymentSeeds(future, voluntary.actor, required).next().done) { affordable = true; break; } } requireRule(affordable, 'Keep the current spice needed by your Truthtrance bribe answer.'); @@ -15530,14 +15583,25 @@ function reconcileBribePromises(g: Game, voluntary?: { actor: string; action: Ac } else kept.push(promise); } } +function ordinaryPaymentActionAvailable(g: Game, player: Player, action: Action): boolean { + if (action.type === 'charity') return g.phase === 2 && !g.ready.includes(player.id) && + player.charityTurn !== g.turn && charityQuote(g, player).total > 0 && + (!byFaction(g, 'choam') || g.choamCharity?.turn === g.turn) && + !g.truthtrance && !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard; + return action.type === 'bribe' && !bribeTimingBlock(g) && typeof action.amount === 'number' && + action.amount > 0 && action.amount <= uncommittedSpice(g, player); +} function projectedBribePromiseGuide(g: Game, player: string): { action: Action } | null { if (!bribeQuestionWindow(g) || g.truthtrance) return null; - const action = bribePromiseCompletion(g, getPlayer(g, player))?.[0]; - if (!action) return null; + const joint = liveTurnActionPromises(g.turnActionPromises ?? [], player, g.turn).length + ? turnActionCompletion(g, player) : null; + if (joint?.paymentsAfterPreparations && joint.cardPreparations?.length) return null; + const action = joint ? joint.payments[0] : bribePromiseCompletion(g, getPlayer(g, player))?.[0]; + if (!action || !ordinaryPaymentActionAvailable(g, getPlayer(g, player), action)) return null; const candidates = [...liveBribePromises(g.bribePromises ?? [], player, g.turn), ...liveBribeExpressionPromises(g.bribeExpressionPromises ?? [], player, g.turn)] - .filter(promise => promise.answer && ('claim' in promise ? bribeExpressionRecipients(promise.claim).includes(action.target as string) - : promise.recipient === action.target)); + .filter(promise => promise.answer && (action.type === 'charity' || ('claim' in promise ? bribeExpressionRecipients(promise.claim).includes(action.target as string) + : promise.recipient === action.target))); const sequence = g.bribePromiseSequence ?? []; candidates.sort((a, b) => sequence.indexOf(a.event) - sequence.indexOf(b.event)); return candidates[0] ? { action: { ...action, bribePromiseEvent: candidates[0].event } } : null; @@ -19151,6 +19215,22 @@ function* turnActionPrintedPreparationSeeds(source: Game, actor: string, cards: for (const window of turnActionWindowCardSeeds(card.game, actor, windows)) yield { game: window.game, actions: [...card.actions, ...window.actions] }; } +/** Preserve current-window chronology when Charity is in a later phase. + * A second order also permits an owned printed effect before its payments. */ +function* turnActionFundedPreparations(source: Game, actor: string, required: readonly OrdinaryPayment[], + cards: readonly TurnActionCardRequirement[], windows: readonly TurnActionWindowCardRequirement[], + afterCurrentPayments: (game: Game) => void): + Generator<{ game: Game; actions: Action[]; payments: Action[]; cardsFirst: boolean }> { + for (const paid of ordinaryPaymentSeeds(source, actor, required, afterCurrentPayments)) + for (const prepared of turnActionPrintedPreparationSeeds(paid.game, actor, cards, windows)) + yield { ...prepared, payments: paid.actions, cardsFirst: false }; + if (required.length && (cards.length || windows.some(required => required.answer))) + for (const prepared of turnActionPrintedPreparationSeeds(structuredClone(source), actor, cards, windows)) { + if (!prepared.actions.length) continue; + for (const paid of ordinaryPaymentSeeds(prepared.game, actor, required, afterCurrentPayments)) + yield { game: paid.game, actions: prepared.actions, payments: paid.actions, cardsFirst: true }; + } +} function recordNativeTurnActionWindowCard(g: Game, actor: string, effect: WindowCardEffect, source: string) { if (g.turnActionPromises) g.turnActionPromises = turnActionRule(() => recordTurnActionWindowCardPlay(g.turnActionPromises!, actor, g.turn, effect, source)); @@ -19519,7 +19599,7 @@ function turnActionAllianceCanClose(g: Game, player: Player): boolean { return !!turnActionCompletion(source, player.id); } catch (error) { if (error instanceof RuleError) return false; throw error; } } -type TurnActionCompletion = MovementCompletion & { payments: Action[]; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action; cardPreparations?: Action[]; battlePower?: Action }; +type TurnActionCompletion = MovementCompletion & { payments: Action[]; paymentsAfterPreparations?: boolean; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action; cardPreparations?: Action[]; battlePower?: Action }; /** Forecast deterministic opening work only. Auction/deck draws and foreign * choices stay at the real table; fixed Mentat receipts arrive after Amal. */ function projectTurnActionOpeningEnd(g: Game) { @@ -19676,35 +19756,32 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va pendingNullentropy: ownedBox ? undefined : trial.pendingNullentropy } : trial; if (required.length && bribeTimingBlock(paymentWindow)) continue; const total = required.reduce((sum, payment) => sum + payment.amount, 0); - if (!Number.isSafeInteger(total) || total > 0 && total > uncommittedSpice(trial, owner) || + if (!Number.isSafeInteger(total) || required.some(payment => payment.recipient === actor || payment.recipient === owner.ally || !trial.players.some(player => player.id === payment.recipient))) continue; for (const alliancePlan of alliancePlans) { - const paid = structuredClone(trial), paidOwner = getPlayer(paid, actor); try { - for (const payment of required) recordBribePayment(paid, actor, payment.recipient, payment.amount); - paidOwner.spice -= total; - for (const action of alliancePlan?.steps ?? []) { - const quote = normalNexusAllianceQuote(paid, getPlayer(paid, action.player), action.target); - for (const player of paid.players) player.ally = quote.allies[player.id]; - paid.allianceOffers = quote.offers; - if (quote.formed) for (const id of quote.formed) getPlayer(paid, id).allySinceTurn = paid.turn; - paid.ready = []; - } - if (alliancePlan) { - const opportunity = allianceOpportunity(paid)!; - paid.alliancePromises = (paid.alliancePromises ?? []).map(promise => promise.player === actor && - promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.released - ? { ...promise, fulfilled: true } : promise); - paid.allianceExpressionPromises = (paid.allianceExpressionPromises ?? []).map(promise => promise.player === actor && - promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.released - ? { ...promise, fulfilled: true } : promise); - } + const prepareAlliance = (paid: Game) => { + for (const action of alliancePlan?.steps ?? []) { + const quote = normalNexusAllianceQuote(paid, getPlayer(paid, action.player), action.target); + for (const player of paid.players) player.ally = quote.allies[player.id]; + paid.allianceOffers = quote.offers; + if (quote.formed) for (const id of quote.formed) getPlayer(paid, id).allySinceTurn = paid.turn; + paid.ready = []; + } + if (alliancePlan) { + const opportunity = allianceOpportunity(paid)!; + paid.alliancePromises = (paid.alliancePromises ?? []).map(promise => promise.player === actor && + promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.released + ? { ...promise, fulfilled: true } : promise); + paid.allianceExpressionPromises = (paid.allianceExpressionPromises ?? []).map(promise => promise.player === actor && + promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.released + ? { ...promise, fulfilled: true } : promise); + } + }; const voluntary = { actor, action: { type: 'bribe' } }; - for (const payment of required) - checkBureaucratContributors(paid, payment.recipient, [{ payer: actor, amount: payment.amount }]); - for (const cardSeed of turnActionPrintedPreparationSeeds(paid, actor, cardRequirements, - source.phaseOpening ? windowRequirements.filter(required => required.effect !== 'amal') : windowRequirements)) { + for (const cardSeed of turnActionFundedPreparations(trial, actor, required, cardRequirements, + source.phaseOpening ? windowRequirements.filter(required => required.effect !== 'amal') : windowRequirements, prepareAlliance)) { const funded = cardSeed.game, payer = getPlayer(funded, actor); try { reconcileBattlePromises(funded, voluntary); @@ -19749,11 +19826,12 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va if (completion) { if (alliancePlan?.uncertain) { unknownConsent!.value = true; continue; } const cardPreparations = [...opening.actions, ...cardSeed.actions]; - return { ...completion, ...(cardPreparations.length ? { cardPreparations } : {}), ...(bid ? { bid } : {}), ...(revival ? { revival } : {}), + return { ...completion, ...(cardPreparations.length ? { cardPreparations } : {}), + ...(cardSeed.cardsFirst ? { paymentsAfterPreparations: true } : {}), ...(bid ? { bid } : {}), ...(revival ? { revival } : {}), ...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}), ...(power.action ? { battlePower: power.action } : {}), ...(alliancePlan ? { alliance: alliancePlan.steps.map(step => ({ type: 'alliance', target: step.target })) } : {}), - payments: required.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount })) }; + payments: cardSeed.payments }; } } catch (error) { if (!(error instanceof RuleError)) throw error; } } @@ -19900,7 +19978,7 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s for (const p of g.players) { const early = g.phase < 5 || sourcePhase < 5; const voluntary = actor === p.id && action?.type !== 'passResponse' && (!early || !!action && - (['card', 'bribe', 'alliance', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive', + (['card', 'bribe', 'charity', 'alliance', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive', 'reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival', 'emperorRevival', 'decision', 'passBid', 'karama', 'specialKarama'].includes(action.type) || action.type === 'richeseBid' && typeof action.amount === 'number' && action.amount > 0 || action.type === 'ready' && [2, 4, 7, 8].includes(sourcePhase))); @@ -19961,7 +20039,9 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act return action ? { event: row.event, action: { ...action, turnActionPromiseEvent: row.event, turnActionPromisePhase: g.phase } } : null; } - let action = !bribeTimingBlock(g) ? completion.payments[0] : undefined; + const nextPayment = completion.payments[0]; + let action = (!completion.paymentsAfterPreparations || !completion.cardPreparations?.length) && nextPayment && + ordinaryPaymentActionAvailable(g, player, nextPayment) ? nextPayment : undefined; if (!action) { const preparation = completion.cardPreparations?.[0]; const shipmentTarget = preparation?.type === 'card' && preparation.mode === 'shipment' @@ -20136,7 +20216,7 @@ function reconcileTravelPromises(g: Game, actor?: string, action?: Action, sourc if (!liveTravelPromises(g.travelPromises, p.id, g.turn).length) continue; const early = g.phase < 5 || sourcePhase < 5; const voluntary = actor === p.id && action?.type !== 'passResponse' && (!early || !!action && - (['card', 'bribe', 'alliance', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive', + (['card', 'bribe', 'charity', 'alliance', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) || action.type === 'richeseBid' && typeof action.amount === 'number' && action.amount > 0 || action.type === 'ready' && [2, 4].includes(sourcePhase))); @@ -20605,7 +20685,7 @@ function reconcileGroundMovementPromises(g: Game, actor?: string, action?: Actio source.producer === 'groundMovement' && source.mover === p.id)) continue; const early = g.phase < 5 || sourcePhase < 5; const voluntary = actor === p.id && action?.type !== 'passResponse' && (!early || !!action && - (['card', 'bribe', 'alliance', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) || + (['card', 'bribe', 'charity', 'alliance', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) || action.type === 'richeseBid' && typeof action.amount === 'number' && action.amount > 0 || action.type === 'ready' && [2, 4].includes(sourcePhase))); const busy = !!(g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard || @@ -28720,30 +28800,11 @@ function earlyRevivalCompletionSeeds(initial: Game, source: Game, owner: string) if (source.phase <= 2 && !(source.phase === 2 && source.ready.includes(owner))) { const charitySeeds: ShipmentCompletionSeed[] = []; for (const seed of seeds) { - const g = structuredClone(seed.game); g.phase = 2; - const p = getPlayer(g, owner); - if (p.charityTurn === g.turn || charityQuote(g, p).total <= 0) continue; - try { + const next = forecastOrdinaryCharity(seed.game, owner); + if (next) { const action: Action = { type: 'charity' }; - // Before CHOAM's opening response, another faction's fixed ordinary - // entitlement is unchanged by its outcome. Quote only that entitlement: - // no CHOAM income, payment or invented receipt enters this forecast. - let next: Game; - if (source.phase < 2 && byFaction(g, 'choam') && g.choamCharity?.turn !== g.turn) { - if (p.faction === 'choam') continue; - p.spice += charityQuote(g, p).total; p.charityTurn = g.turn; next = g; - } else { - next = applyActionInner(g, owner, action, 'shipmentPreparation'); - // Wealthy Advanced BG claims before its counter pays. Witness only - // that owned allowed outcome; the actual table keeps every response. - const response = next.response; - if (response?.kind === 'bgCharity' && response.owner === owner) { - next.response = null; - payCharity(next,getPlayer(next,owner),response.amount ?? 2,response.charityHomeworld ?? 0); - } - } charitySeeds.push({ game: next, actions: [action], early: [{ phase: 2, action }] }); - } catch (error) { if (!(error instanceof RuleError)) throw error; } + } } seeds.push(...charitySeeds); } @@ -29180,7 +29241,7 @@ function reconcileShipmentPromises( voluntary.action.type === 'richeseBid' && typeof voluntary.action.amount === 'number' && voluntary.action.amount > 0 || voluntary.action.type === 'ready' && [2, 4].includes(voluntary.phase ?? g.phase)); const ownVoluntary = voluntary?.actor === p.id && - (['card', 'bribe', 'alliance', 'pledgeAid', 'emperorGift', 'richeseGift'].includes(voluntary.action.type) || + (['card', 'bribe', 'charity', 'alliance', 'pledgeAid', 'emperorGift', 'richeseGift'].includes(voluntary.action.type) || earlyVoluntary || future && voluntary.action.type === 'decision'); // A foreign pending parent may spend or refund its approved funding. Wait diff --git a/game/ordinary-payment-plan.ts b/game/ordinary-payment-plan.ts new file mode 100644 index 00000000..0109ae9b --- /dev/null +++ b/game/ordinary-payment-plan.ts @@ -0,0 +1,22 @@ +export type OrdinaryPayment = { recipient: string; amount: number }; + +/** Partition one cumulative obligation without duplicating a recipient's + * deficit. Native income, timing and entitlement remain engine calculations. */ +export function splitOrdinaryPayments(payments: readonly OrdinaryPayment[], available: number): { + before: OrdinaryPayment[]; after: OrdinaryPayment[]; total: number; +} { + if (!Number.isSafeInteger(available) || available < 0) throw new Error('Use a safe available payment budget.'); + let remaining = available, total = 0; + const before: OrdinaryPayment[] = [], after: OrdinaryPayment[] = []; + for (const payment of payments) { + if (!payment.recipient || payment.recipient !== payment.recipient.trim() || + !Number.isSafeInteger(payment.amount) || payment.amount <= 0 || !Number.isSafeInteger(total + payment.amount)) + throw new Error('Use exact recipients and positive safe payment amounts.'); + total += payment.amount; + const paid = Math.min(remaining, payment.amount); + if (paid) before.push({ recipient: payment.recipient, amount: paid }); + if (paid < payment.amount) after.push({ recipient: payment.recipient, amount: payment.amount - paid }); + remaining -= paid; + } + return { before, after, total }; +} diff --git a/game/reference.ts b/game/reference.ts index ef9c84b9..c78dea13 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3234,6 +3234,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A printed Karama shipment-price clause names the current beneficiary’s unused native shipment. The giver spends the physical printed card; the beneficiary still pays the native bank tariff, such as two spice becoming one. Playing that cost counts even if its price is unused. The source remains while that original recipient is entitled, including another actor’s turn; a different beneficiary or a Cunning second opportunity has its own interval. Own shipping or ending must preserve the whole answer before a foreign counter, while foreign actions retain their rights.', '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.', '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.', @@ -3245,7 +3246,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'] }, + { 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'] }, ], }, { @@ -3404,9 +3405,9 @@ 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.', - 'Only answers supported by current spendable spice and earlier battle, bid, revival, shipment and movement commitments are offered. Future income, card preparations, pledge withdrawal and gifts are not forecast. A later supported question must preserve an earlier bribe Yes too.', + '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.', '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 only an already-ready 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.', + '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.', ], related: ['card-truthtrance', 'bribes', 'truthtrance-bid', 'truthtrance-revival', 'implementation-checklist'], diff --git a/tests/fixture-truth-charity-payments.ts b/tests/fixture-truth-charity-payments.ts new file mode 100644 index 00000000..806435f4 --- /dev/null +++ b/tests/fixture-truth-charity-payments.ts @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import type { TruthQuestion } from '../game/truthtrance'; +import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards'; +import { createTruthTurnAmalFixture, closeTruthTurnAmal, settleTruthTurnAmal, + truthTurnAmalFact, truthTurnAmalGroup, truthTurnAmalPayment, + type TruthTurnAmalExpression } from './fixture-truth-turn-amal'; +import { actTruthTurnThumper, createTruthTurnThumperFixture, nextTruthTurnThumperNative, + truthTurnThumperClean, truthTurnThumperInventory, truthTurnThumperPlayer, truthTurnThumperSpice, + truthTurnThumperTraitors, type TruthTurnThumperStep } from './fixture-truth-turn-thumper'; + +export type TruthCharityPaymentsStep = TruthTurnThumperStep; +export type TruthCharityPaymentsKind = 'turnActions' | 'bribe' | 'bribeExpression' | 'mixedBribe'; +export const actTruthCharityPayments = actTruthTurnThumper; +export const truthCharityPaymentsClean = truthTurnThumperClean; +export const truthCharityPaymentsPlayer = truthTurnThumperPlayer; +export const truthCharityPaymentsInventory = truthTurnThumperInventory; +export const truthCharityPaymentsTraitors = truthTurnThumperTraitors; +export const truthCharityPaymentsSpice = truthTurnThumperSpice; +export const truthCharityPaymentsFact = truthTurnAmalFact; +export const truthCharityPaymentsGroup = truthTurnAmalGroup; +export const truthCharityPaymentsPayment = truthTurnAmalPayment; +export const truthCharityPaymentsShip = (territory = 'polar_sink', minimum = 1): TruthTurnAmalExpression => + ({ kind: 'action', action: { kind: 'ship', territory, minimum } }); +export const nextTruthCharityPaymentsNative = nextTruthTurnThumperNative; +export function nextTruthCharityPayments(game: Game): TruthCharityPaymentsStep | 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 nextTruthCharityPaymentsNative(game); +} +export function advanceTruthCharityPayments(game: Game, until: (state: Game) => boolean, + actions: TruthCharityPaymentsStep[] = [], nativeOnly = false): Game { + for (let count = 0; count < 1900; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original Charity/payment source ended before its native boundary'); + const step = nativeOnly ? nextTruthCharityPaymentsNative(game) : nextTruthCharityPayments(game); + assert.ok(step, 'Supply the actual Truthtrance question or original private plan'); + game = actTruthCharityPayments(game, step.actor, step.action, actions); + } + throw Error('The native Charity/payment chronology did not reach its finite boundary'); +} +export function settleTruthCharityPayments(game: Game, actions: TruthCharityPaymentsStep[] = []): Game { + return advanceTruthCharityPayments(game, truthCharityPaymentsClean, actions, true); +} +export function reachTruthCharityPaymentsClaim(game: Game, actions: TruthCharityPaymentsStep[] = []): Game { + return advanceTruthCharityPayments(game, state => state.phase === 2 && truthCharityPaymentsClean(state), actions, true); +} + +/** Genuine selected-Ix original deals only. Every smaller purse is earned by + * actual Amal; no poverty, receipt, claim marker or pass list is assigned. */ +export function createTruthCharityPaymentsFixture(options: { + source?: 'amalOpening' | 'afterAmal' | 'thumper' | 'phase2'; advanced?: boolean; +} = {}) { + const sourceKind = options.source ?? 'amalOpening'; + const amal = sourceKind === 'thumper' ? null : createTruthTurnAmalFixture({ phase: 1, advanced: options.advanced }); + const thumper = sourceKind === 'thumper' ? createTruthTurnThumperFixture({ advanced: options.advanced }) : null; + const base = amal ?? thumper!; + let game = structuredClone(base.game); const actions = structuredClone(base.sourceActions), inherited = actions.length; + let reduction: { before: Game; steps: TruthCharityPaymentsStep[]; after: Game } | null = null; + let spiceOrder: ReturnType = []; + if (sourceKind === 'afterAmal' || sourceKind === 'phase2') { + assert.ok(amal); const before = structuredClone(game), from = actions.length; + game = actTruthCharityPayments(game, base.target, amal.nativeAction, actions); + game = settleTruthTurnAmal(game, actions); game = closeTruthTurnAmal(game, amal, actions); + reduction = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) }; + assert.equal(truthCharityPaymentsPlayer(game, base.target).spice, 5); assert.equal(beforeIxSpiceDraw(game), true); + spiceOrder = orderIxSpiceCards(game, [{ territory: 'hagga_basin' }, ...(base.advanced ? [{ territory: 'broken_land' }] : [])]); + if (sourceKind === 'phase2') game = reachTruthCharityPaymentsClaim(game, actions); + } + const asker = base.asker, target = base.target, recipient = asker, observer = base.observer; + const minimum = sourceKind === 'thumper' ? 11 : 6; + const printed = sourceKind === 'amalOpening' || sourceKind === 'thumper' + ? { kind: 'action', action: { kind: 'windowCardPlay', effect: base.effect, source: base.source } } as const : null; + const mixed = printed ? truthCharityPaymentsGroup('and', printed, truthCharityPaymentsPayment(recipient, minimum)) + : truthCharityPaymentsPayment(recipient, minimum); + const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion; + const spiceFact = { kind: 'spice', compare: 'eq', value: truthCharityPaymentsPlayer(game, target).spice } as const; + assert.deepEqual(truthCharityPaymentsPlayer(game, asker).hand.map(card => card.id), base.truths); + assert.notEqual(truthCharityPaymentsPlayer(game, target).charityTurn, game.turn); + assert.deepEqual(truthCharityPaymentsInventory(game), base.originalCards); + assert.deepEqual(truthCharityPaymentsTraitors(game), base.originalTraitors); + assert.deepEqual(truthCharityPaymentsSpice(game), base.originalSpice); + return { game, base, amal, thumper, sourceKind, initial: base.initial, setup: base.setup, afterSetup: base.afterSetup, + beforeStartingDeal: base.beforeStartingDeal, asker, target, recipient, observer, advanced: base.advanced, + truths: base.truths, truth: base.truth, card: base.card, effect: base.effect, cardSource: base.source, + nativeAction: base.nativeAction, printed, minimum, mixed, question, spiceFact, reduction, spiceOrder, + payment: { type: 'bribe', target: recipient, amount: minimum } satisfies Action, + charity: { type: 'charity' } satisfies Action, + originalCards: base.originalCards, originalTraitors: base.originalTraitors, originalSpice: base.originalSpice, + actions, sourceActions: actions, sourceActionCount: actions.length, + sourceCounts: { inherited, nativeContinuation: actions.length - inherited, selected: actions.length, + actualAmalReduction: reduction?.steps.length ?? 0 }, + sourceDisclosure: `${base.sourceDisclosure} This adapter follows only actual printed Amal, global opening passes and original native phase controls when requested. ` + + 'The ordinary phase2 Charity remains unclaimed. Smaller purses come from the real all-seat halving, not assigned wealth. Later prepayments, one actual claim, later installments and ordinary shipment remain caller controls. All future recipients share the same physical purse and one current-turn native claim.', + }; +} +export type TruthCharityPaymentsFixture = ReturnType; +export function truthCharityPaymentsQuestion(f: TruthCharityPaymentsFixture, kind: TruthCharityPaymentsKind, + 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: truthCharityPaymentsPayment(recipient, minimum) }; +} +export function declareTruthCharityPayments(f: TruthCharityPaymentsFixture, source = f.game, copy: 0 | 1 = 0, + actions: TruthCharityPaymentsStep[] = []) { + let game = actTruthCharityPayments(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 = actTruthCharityPayments(game, actor.id, { type: 'truthPass' }, actions); + } + assert.equal(game.truthtrance?.stage, 'ask'); return { game, actions }; +} +export function askTruthCharityPayments(game: Game, asker: string, question: TruthQuestion, actions: TruthCharityPaymentsStep[] = []): Game { + return actTruthCharityPayments(game, asker, { type: 'truthAsk', question: structuredClone(question) }, actions); +} +export function answerTruthCharityPayments(game: Game, target: string, answer: 'yes' | 'no' | 'unknown', actions: TruthCharityPaymentsStep[] = []): Game { + return actTruthCharityPayments(game, target, { type: 'truthAnswer', answer }, actions); +} +export function bindTruthCharityPayments(f: TruthCharityPaymentsFixture, answer: 'yes' | 'no' = 'yes', question: TruthQuestion = f.question, + source = f.game, copy: 0 | 1 = 0, actions: TruthCharityPaymentsStep[] = []) { + const declared = declareTruthCharityPayments(f, source, copy, actions), asked = askTruthCharityPayments(declared.game, f.asker, question, actions); + return { game: answerTruthCharityPayments(asked, f.target, answer, actions), declared, asked, actions }; +} +export function truthCharityPaymentsPromise(game: Game, kind: TruthCharityPaymentsKind = 'turnActions', target = game.truthHistory!.at(-1)!.question.target) { + const row = kind === 'bribe' ? game.bribePromises?.find(candidate => candidate.player === target) + : kind === 'bribeExpression' ? game.bribeExpressionPromises?.find(candidate => candidate.player === target) + : kind === 'mixedBribe' ? viewGame(game, target).mixedBribePromises.find(candidate => candidate.player === target) + : viewGame(game, target).turnActionPromises.find(candidate => candidate.player === target); + assert.ok(row); return row; +} +export function finishTruthCharityPayments(game: Game, actions: TruthCharityPaymentsStep[] = []): Game { + const turn = game.turn; + return advanceTruthCharityPayments(game, state => state.turn === turn + 1 && state.phase === 0 && !!state.phaseOpening && + !state.response && !state.decision && !state.truthtrance && !state.pendingTreacheryDiscard, actions); +} diff --git a/tests/ordinary-payment-plan.test.ts b/tests/ordinary-payment-plan.test.ts new file mode 100644 index 00000000..74e75d95 --- /dev/null +++ b/tests/ordinary-payment-plan.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { splitOrdinaryPayments } from '../game/ordinary-payment-plan'; + +void test('payment installments conserve the exact original deficits across recipients', () => { + const payments = [{ recipient: 'harkonnen', amount: 3 }, { recipient: 'atreides', amount: 3 }]; + const original = structuredClone(payments), result = splitOrdinaryPayments(payments, 5); + assert.deepEqual(result, { before: [payments[0], { recipient: 'atreides', amount: 2 }], + after: [{ recipient: 'atreides', amount: 1 }], total: 6 }); + for (const payment of payments) assert.equal([...result.before, ...result.after] + .filter(part => part.recipient === payment.recipient).reduce((sum, part) => sum + part.amount, 0), payment.amount); + assert.deepEqual(payments, original); +}); + +void test('no cash defers the whole payment and enough cash needs no later installment', () => { + const payments = [{ recipient: 'emperor', amount: 6 }]; + assert.deepEqual(splitOrdinaryPayments(payments, 0), { before: [], after: payments, total: 6 }); + assert.deepEqual(splitOrdinaryPayments(payments, 6), { before: payments, after: [], total: 6 }); + assert.deepEqual(splitOrdinaryPayments([], 2), { before: [], after: [], total: 0 }); +}); + +void test('invalid or overflowing payment partitions cannot become a funding witness', () => { + for (const available of [-1, 1.5, Infinity]) assert.throws(() => splitOrdinaryPayments([], available)); + for (const payments of [[{ recipient: ' e', amount: 1 }], [{ recipient: 'e', amount: 0 }], + [{ recipient: 'e', amount: Number.MAX_SAFE_INTEGER }, { recipient: 'a', amount: 1 }]]) + assert.throws(() => splitOrdinaryPayments(payments, 0)); +}); diff --git a/tests/truth-bribe-controls.test.tsx b/tests/truth-bribe-controls.test.tsx index 40d552f0..b3c59986 100644 --- a/tests/truth-bribe-controls.test.tsx +++ b/tests/truth-bribe-controls.test.tsx @@ -66,6 +66,14 @@ function projected(phase = 3): GameView { game.bribePromiseGuide = { action: { type: 'bribe', target: 'r', amount: 2, bribePromiseEvent: 'controlled-bribe-event' } }; return game; } +function charityGame(amount = 2): GameView { + const game = projected(2), me = game.players.find(player => player.id === 'p')!; + me.spice = 2 - amount; me.charityClaimed = false; game.ready = []; + game.charity = { ...game.charity, ordinary: amount, homeworld: 0, total: amount, amount, incomePending: false }; + game.bribeOptions = { ...game.bribeOptions, available: me.spice, targets: [] }; + game.bribePromiseGuide = { action: { type: 'charity', bribePromiseEvent: 'controlled-bribe-event' } }; + return game; +} void test('bribe claim fields keep the turn fixed and accept only current recipients and positive safe whole minima', () => { const value: BribeClaim = { turn: 2, recipient: 'r', minimum: 3 }, changes: BribeClaim[] = []; @@ -130,6 +138,53 @@ void test('actual guide callbacks forward exact ordinary bribes out of turn thro } }); +void test('ordinary payment guides forward exact currently quoted Charity claims and explain later installments', () => { + for (const amount of [1, 2]) { + const game = charityGame(amount), sent: Action[] = [], ownCash = game.players.find(player => player.id === 'p')!.spice; + const 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); + assert.deepEqual(sent[0], { type: 'charity', bribePromiseEvent: 'controlled-bribe-event' }); + assert.equal(game.players.find(player => player.id === 'p')!.spice, ownCash, 'The UI does not install future Charity as cash.'); + const html = markup(game); assert.match(html, new RegExp(`Claim ${amount} Charity spice to continue your payments`)); + assert.match(html, /payments now, one remaining Charity claim and later installments/); + assert.match(html, /future claim is not current spendable spice/); + } +}); + +void test('Charity payment callbacks reject changed native claims, quotes, cash, Ready, phase and callback ownership', () => { + for (const change of [ + (game: GameView) => { game.players.find(player => player.id === 'p')!.charityClaimed = true; }, + (game: GameView) => { game.charity.amount = 0; }, + (game: GameView) => { game.charity = { ...game.charity }; }, + (game: GameView) => { game.charity.incomePending = true; }, + (game: GameView) => { game.players.find(player => player.id === 'p')!.spice = 1; }, + (game: GameView) => { game.ready.push('p'); }, + (game: GameView) => { game.phase = 3; }, + (game: GameView) => { game.bribePromises[0].fulfilled = true; }, + (game: GameView) => { game.bribePromiseGuide!.action.bribePromiseEvent = 'different-event'; }, + ]) { + const game = charityGame(), saved = guide({ game, busy: false, send() { assert.fail('A stale Charity continuation cannot send'); } }); + assert.equal(saved.props.disabled, false); change(game); press(saved); + } + const pending = charityGame(); pending.charity.incomePending = true; + assert.equal(guide({ game: pending, busy: false, send() { assert.fail('CHOAM income must finish before an ordinary claim'); } }).props.disabled, true); + for (const game of [charityGame(), projected()]) { + const props = { game, busy: false, send() { assert.fail('A saved payment callback cannot use a replaced sender'); } }; + const saved = guide(props); props.send = () => assert.fail('A replacement sender cannot receive an old quoted action'); press(saved); + } +}); + +void test('Charity payment guides reject extra native parameters and unrelated nonpayment fallbacks', () => { + for (const extra of [{ target: 'r' }, { amount: 1 }, { card: 'private-card' }, { mode: 'shipment' }]) { + const game = charityGame(); Object.assign(game.bribePromiseGuide!.action, extra); + const button = guide({ game, busy: false, send() { assert.fail('Charity accepts only its exact native action and event'); } }); + assert.equal(button.props.disabled, true); press(button); + } + const unrelated = projected(2); unrelated.bribePromiseGuide!.action.type = 'bid'; + const button = guide({ game: unrelated, busy: false, send() { assert.fail('An unrelated action cannot pass the ordinary bribe path'); } }); + assert.equal(button.props.disabled, true); press(button); +}); + void test('controlled quiet Mentat guide forwards the exact payment for an already-ready payer and explains next-turn collection', () => { const game = projected(8), sent: Action[] = []; game.auction = null; game.active = null; game.ready = ['p']; diff --git a/tests/truth-charity-payments-runtime.test.ts b/tests/truth-charity-payments-runtime.test.ts new file mode 100644 index 00000000..a5ba0cbd --- /dev/null +++ b/tests/truth-charity-payments-runtime.test.ts @@ -0,0 +1,255 @@ +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 { + actTruthCharityPayments as act, advanceTruthCharityPayments as advance, answerTruthCharityPayments as answer, + askTruthCharityPayments as ask, bindTruthCharityPayments as bind, createTruthCharityPaymentsFixture as fixture, + declareTruthCharityPayments as declare, finishTruthCharityPayments as finish, + nextTruthCharityPaymentsNative as nativeStep, reachTruthCharityPaymentsClaim as reachClaim, + settleTruthCharityPayments as settle, truthCharityPaymentsClean as clean, truthCharityPaymentsFact as fact, + truthCharityPaymentsGroup as group, truthCharityPaymentsInventory as inventory, + truthCharityPaymentsPayment as payment, truthCharityPaymentsPlayer as player, + truthCharityPaymentsPromise as promise, truthCharityPaymentsQuestion as question, + truthCharityPaymentsShip as ship, truthCharityPaymentsSpice as spice, truthCharityPaymentsTraitors as traitors, + type TruthCharityPaymentsFixture, type TruthCharityPaymentsKind, type TruthCharityPaymentsStep, +} from './fixture-truth-charity-payments'; + +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: TruthCharityPaymentsFixture, copies: 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: TruthCharityPaymentsFixture, amount: number, recipient = f.recipient, + actions: TruthCharityPaymentsStep[] = []) => act(game, f.target, { type: 'bribe', target: recipient, amount }, actions); + +void test('Charity installment sources preserve native Amal reductions, openings, original cards and an unused actual claim', () => { + for (const source of ['amalOpening', 'afterAmal', 'thumper', 'phase2'] as const) { + const f = fixture({ source }); 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']).map(card => card.id).sort()); + assert.deepEqual(f.originalTraitors, traitorDeck(f.setup.players, true).sort()); assert.ok(f.originalTraitors.includes(CHEAP_HERO_TRAITOR)); + assert.equal(f.originalCards.length, 47); assert.equal(f.originalSpice.length, spiceDeck(true).length); + assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths); + assert.equal(f.game.phase, source === 'phase2' ? 2 : 1); + assert.equal(!!f.game.phaseOpening, source === 'amalOpening'); + assert.equal(player(f.game, f.target).spice, source === 'amalOpening' || source === 'thumper' ? 10 : 5); + assert.notEqual(player(f.game, f.target).charityTurn, f.game.turn); + assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.selected, f.sourceActions.length); + assert.equal(f.sourceCounts.inherited, f.base.sourceActionCount); + assert.equal(f.sourceCounts.nativeContinuation, f.sourceActions.length - f.base.sourceActionCount); + assert.equal(f.sourceCounts.actualAmalReduction, f.reduction?.steps.length ?? 0); + if (f.reduction) { + assert.equal(player(f.reduction.before, f.target).spice, 10); assert.equal(player(f.reduction.after, f.target).spice, 5); + assert.ok(f.reduction.steps.some(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.card)); + } + assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors); + assert.deepEqual(spice(f.game), f.originalSpice); + } +}); + +void test('actual Amal then five paid, one native Charity claim and one later installment fulfills six with frozen facts', () => { + const f = fixture(), raw: TruthQuestion = { ...f.question, mixed: group('and', fact(f.spiceFact), f.mixed) }; + const actions: TruthCharityPaymentsStep[] = []; let game = bind(f, 'yes', raw, f.game, 0, actions).game; + const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); + assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card); + game = act(game, f.target, guide.action, actions); assert.equal(player(game, f.target).spice, 5); + assert.ok(game.phaseOpening); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); + rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 5 }); game = settle(game, actions); + game = pay(game, f, 5, f.recipient, actions); assert.equal(player(game, f.target).spice, 0); + assert.notEqual(viewGame(game, f.target).turnActionGuide?.action.type, 'charity', 'A future-phase quote is not an executable current action'); + game = reachClaim(game, actions); assert.notEqual(player(game, f.target).charityTurn, game.turn); + assert.equal(charityQuote(game, player(game, f.target)).total, 2); + const claim = viewGame(game, f.target).turnActionGuide; assert.ok(claim); assert.equal(claim.action.type, 'charity'); + assert.equal(claim.action.turnActionPromiseEvent, promise(game).event); assert.equal(claim.action.turnActionPromisePhase, 2); + rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, claim.action, actions); + assert.equal(player(game, f.target).charityTurn, game.turn); assert.equal(player(game, f.target).spice, 2); + assert.equal(game.turnActionPromises![0].payments.find(row => row.recipient === f.recipient)?.amount, 5); + const final = viewGame(game, f.target).turnActionGuide; assert.ok(final); assert.equal(final.action.type, 'bribe'); assert.equal(final.action.amount, 1); + game = act(game, f.target, final.action, actions); + assert.equal(player(game, f.target).spice, 1); assert.equal(player(game, f.recipient).bribes, 6); assert.equal(promise(game).fulfilled, true); + assert.deepEqual(game.turnActionPromises![0].mixed, raw.kind === 'turnActions' ? raw.mixed : null); + 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('bare, grouped and mixed payment questions use the same real Charity installment path after actual Amal', () => { + for (const kind of ['bribe', 'bribeExpression', 'mixedBribe'] as const) { + const f = fixture({ source: 'afterAmal' }), raw = question(f, kind); + let game = bind(f, 'yes', raw).game; + const first = viewGame(game, f.target).bribePromiseGuide; assert.ok(first, kind); + assert.equal(first.action.bribePromiseEvent, promise(game, kind).event); assert.notEqual(first.action.type, 'charity'); + game = pay(game, f, 5); game = reachClaim(game); + const claim = viewGame(game, f.target).bribePromiseGuide; assert.ok(claim, kind); assert.equal(claim.action.type, 'charity'); + assert.equal(claim.action.bribePromiseEvent, promise(game, kind).event); + game = act(game, f.target, claim.action); assert.equal(player(game, f.target).spice, 2); + const final = viewGame(game, f.target).bribePromiseGuide; assert.ok(final); assert.equal(final.action.type, 'bribe'); assert.equal(final.action.amount, 1); + game = act(game, f.target, final.action); + assert.equal(promise(game, kind).fulfilled, true); assert.equal(player(game, f.target).spice, 1); + assert.equal(player(game, f.recipient).bribes, 6); assert.equal(player(game, f.target).charityTurn, game.turn); physical(game, f); + } +}); + +void test('required printed Thumper occurs before future Charity and cumulative eleven is funded by ten plus one actual claim', () => { + const f = fixture({ source: 'thumper' }); let game = bind(f).game; + game = settle(act(game, f.target, f.nativeAction)); + assert.equal(game.discard.filter(card => card.id === f.card).length, 1); assert.equal(game.turnActionPromises![0].windowCards?.thumper?.played, true); + assert.equal(player(game, f.target).spice, 10); assert.notEqual(player(game, f.target).charityTurn, game.turn); + game = pay(game, f, 10); game = reachClaim(game); + const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'charity'); + game = act(game, f.target, guide.action); game = pay(game, f, 1); + assert.equal(player(game, f.target).spice, 1); assert.equal(player(game, f.recipient).bribes, 11); + assert.equal(player(game, f.target).charityTurn, game.turn); assert.equal(promise(game).fulfilled, true); physical(game, f); +}); + +void test('different recipients share one purse and one actual Charity while their installments accumulate separately', () => { + const f = fixture({ source: 'afterAmal' }); + const raw: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn, + mixed: group('and', payment(f.recipient, 3), payment(f.observer, 3)) }; + let game = bind(f, 'yes', raw).game; game = pay(game, f, 3); game = pay(game, f, 2, f.observer); + assert.equal(player(game, f.target).spice, 0); game = reachClaim(game); game = act(game, f.target, f.charity); + game = pay(game, f, 1, f.observer); + assert.equal(player(game, f.target).spice, 1); assert.equal(player(game, f.recipient).bribes, 3); assert.equal(player(game, f.observer).bribes, 3); + assert.deepEqual(game.turnActionPromises![0].payments.slice().sort((a, b) => a.recipient.localeCompare(b.recipient)), + [{ recipient: f.observer, amount: 3 }, { recipient: f.recipient, amount: 3 }].sort((a, b) => a.recipient.localeCompare(b.recipient))); + assert.equal(promise(game).fulfilled, true); rejected(game, f.target, f.charity); physical(game, f); +}); + +void test('a cumulative bare No cap survives the genuine Charity income and never treats that claim as another payment', () => { + const f = fixture({ source: 'afterAmal' }), raw = question(f, 'bribe'); let game = bind(f, 'no', raw).game; + game = pay(game, f, 5); game = reachClaim(game); game = act(game, f.target, f.charity); + assert.equal(player(game, f.target).spice, 2); assert.equal(game.bribePromises![0].progress, 5); + rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); + assert.ok(!game.bribePromises![0].released); game = finish(game); + assert.equal(game.bribePromises![0].progress, 5); assert.equal(player(game, f.target).charityTurn, 1); physical(game, f); +}); + +void test('already claimed or genuinely expired Charity cannot be reserved again for a new positive payment', () => { + const claimed = fixture({ source: 'phase2' }); let game = pay(claimed.game, claimed, 5); + game = act(game, claimed.target, claimed.charity); game = pay(game, claimed, 2); + assert.equal(player(game, claimed.target).spice, 0); assert.equal(player(game, claimed.target).charityTurn, game.turn); + rejected(game, claimed.target, claimed.charity); + let asked = ask(declare(claimed, game).game, claimed.asker, question(claimed, 'bribe', 1)); + assert.deepEqual(viewGame(asked, claimed.target).truthBribeAnswers, ['no']); rejected(asked, claimed.target, { type: 'truthAnswer', answer: 'yes' }); + game = answer(asked, claimed.target, 'no'); assert.equal(game.bribePromises![0].progress, 0); physical(game, claimed); + const expired = fixture({ source: 'afterAmal' }); game = pay(expired.game, expired, 5); + game = advance(game, state => state.phase === 3 && clean(state), [], true); + assert.notEqual(player(game, expired.target).charityTurn, game.turn); assert.equal(player(game, expired.target).spice, 0); + 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('a phase2 positive answer reopens real own Ready and cannot forfeit its necessary Charity claim', () => { + for (const kind of ['turnActions', 'bribe'] as const) { + const f = fixture({ source: 'phase2' }); let source = pay(f.game, f, 5); + source = act(source, f.target, { type: 'ready' }); assert.ok(source.ready.includes(f.target)); + let game = bind(f, 'yes', question(f, kind, 1), source).game; + assert.equal(game.ready.includes(f.target), false); rejected(game, f.target, { type: 'ready' }); + const view = viewGame(game, f.target), guide = kind === 'turnActions' ? view.turnActionGuide : view.bribePromiseGuide; + assert.ok(guide); assert.equal(guide.action.type, 'charity'); game = act(game, f.target, guide.action); + game = pay(game, f, 1); assert.equal(promise(game, kind).fulfilled, true); assert.equal(player(game, f.target).spice, 1); + assert.equal(player(game, f.recipient).bribes, 6); assert.equal(player(game, f.target).charityTurn, game.turn); physical(game, f); + } +}); + +void test('one native Charity is shared with shipment and cannot be claimed early to strand remaining payments', () => { + const f = fixture({ source: 'afterAmal' }); + const impossible: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn, + mixed: group('and', payment(f.recipient, 6), ship()) }; + const asked = ask(declare(f).game, f.asker, impossible); + 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, 5), ship()) }; + let game = bind(f, 'yes', funded).game; game = pay(game, f, 5); game = reachClaim(game); game = act(game, f.target, f.charity); + assert.equal(player(game, f.target).spice, 2); + 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.target).charityTurn, game.turn); physical(game, f); + const opening = fixture(); assert.ok(opening.printed); + const seven: TruthQuestion = { kind: 'turnActions', target: opening.target, turn: opening.game.turn, + mixed: group('and', opening.printed, payment(opening.recipient, 7)) }; + let manual = bind(opening, 'yes', seven).game; + manual = settle(act(manual, opening.target, opening.nativeAction)); manual = pay(manual, opening, 4); + manual = reachClaim(manual); assert.equal(player(manual, opening.target).spice, 1); + assert.equal(charityQuote(manual, player(manual, opening.target)).total, 1); + rejected(manual, opening.target, opening.charity); + assert.notEqual(player(manual, opening.target).charityTurn, manual.turn); assert.ok(!promise(manual).released); + const prepay = viewGame(manual, opening.target).turnActionGuide; assert.ok(prepay); + assert.equal(prepay.action.type, 'bribe'); assert.equal(prepay.action.amount, 1); + manual = act(manual, opening.target, prepay.action); assert.equal(player(manual, opening.target).spice, 0); + const claim = viewGame(manual, opening.target).turnActionGuide; assert.ok(claim); assert.equal(claim.action.type, 'charity'); + manual = act(manual, opening.target, claim.action); assert.equal(player(manual, opening.target).spice, 2); + const remaining = viewGame(manual, opening.target).turnActionGuide; assert.ok(remaining); + assert.equal(remaining.action.type, 'bribe'); assert.equal(remaining.action.amount, 2); + manual = act(manual, opening.target, remaining.action); + assert.equal(player(manual, opening.target).spice, 0); assert.equal(player(manual, opening.recipient).bribes, 7); + assert.equal(promise(manual).fulfilled, true); assert.ok(!promise(manual).released); physical(manual, opening); +}); + +void test('two genuine Truths cannot reserve the same Charity twice across different recipient promises', () => { + const f = fixture({ source: 'afterAmal' }); let game = bind(f, 'yes', question(f, 'bribe', 6)).game; + const earlier = structuredClone(game.bribePromises![0]), history = structuredClone(game.truthHistory![0]); + const second = question(f, 'bribe', 2, f.observer), asked = ask(declare(f, game, 1).game, f.asker, second); + 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, 5); assert.notEqual(player(game, f.target).charityTurn, game.turn); physical(game, f, 2); +}); + +void test('current Charity guide packets retain exact owner and original promise event through JSON and reject stale claims', () => { + for (const kind of ['turnActions', 'bribe'] as const) { + const f = fixture({ source: 'phase2' }); let game = bind(f, 'yes', question(f, kind)).game; game = pay(game, f, 5); + const view = viewGame(game, f.target), guide = kind === 'turnActions' ? view.turnActionGuide : view.bribePromiseGuide; + assert.ok(guide); assert.equal(guide.action.type, 'charity'); + const event = kind === 'turnActions' ? guide.action.turnActionPromiseEvent : guide.action.bribePromiseEvent; + assert.equal(event, promise(game, kind).event); rejected(game, f.asker, guide.action); + rejected(game, f.target, kind === 'turnActions' ? { ...guide.action, turnActionPromiseEvent: 'stale-charity-event' } + : { ...guide.action, bribePromiseEvent: 'stale-charity-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).charityTurn, game.turn); + 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, 1); game = act(game, f.target, final.action); + assert.equal(promise(game, kind).fulfilled, true); physical(game, f); + } +}); + +void test('all four minimal profiles execute actual prepayments, one Charity and later installments using current-phase guides', () => { + for (const kind of ['turnActions', 'bribe'] as TruthCharityPaymentsKind[]) { + const f = fixture({ source: kind === 'turnActions' ? 'amalOpening' : 'afterAmal' }); + const raw = kind === 'turnActions' ? f.question : question(f, kind), asked = ask(declare(f).game, f.asker, raw); + for (const difficulty of DIFFICULTIES) { + let game = structuredClone(asked); const actions: TruthCharityPaymentsStep[] = []; + 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 < 100 && !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 next = nativeStep(game); assert.ok(next, `${kind}/${difficulty}`); game = act(game, next.actor, next.action, actions); + } + } + assert.equal(promise(game, kind).fulfilled, true, `${kind}/${difficulty}`); + 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 >= 2); + assert.equal(player(game, f.recipient).bribes, 6); assert.equal(player(game, f.target).charityTurn, game.turn); physical(game, f); + } + } +}); diff --git a/tests/truth-turn-action-controls.test.tsx b/tests/truth-turn-action-controls.test.tsx index 47d4e8fd..a28cb321 100644 --- a/tests/truth-turn-action-controls.test.tsx +++ b/tests/truth-turn-action-controls.test.tsx @@ -85,6 +85,10 @@ function projected(phase = 5, next: Action = { type: 'move', forces: { 'arrakeen game.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 2, mixed: claim(), answer: true, fulfilled: false, released: false }]; game.turnActionGuide = { event, action: { ...next, turnActionPromiseEvent: event, turnActionPromisePhase: phase } }; game.truthTurnActionAnswers = null; + if (next.type === 'charity') { + const me = game.players.find(player => player.id === 'p')!; me.spice = 0; me.charityClaimed = false; + game.charity = { ...game.charity, ordinary: 2, homeworld: 0, total: 2, amount: 2, incomePending: false }; + } if (next.type === 'card' && typeof next.card === 'string') { const cardId = next.card, effect = (['hajr', 'ghola', 'weather', 'atomics', 'harvester', 'thumper', 'amal'] as const) .find(effect => cardId.includes(effect)) ?? 'karama'; @@ -1455,6 +1459,41 @@ void test('turn-action current early native opportunities retain exact preparati assert.match(markup(auction({ type: 'decision', karama: true, card: 'controlled-owned-karama' })), /Use the quoted Karama for the current auction payment/); }); +void test('whole payment timelines forward an exact positive unused Charity quote without treating it as current cash', () => { + const game = projected(2, { type: 'charity' }), sent: Action[] = []; + game.turnActionPromises[0].mixed = payment(3); game.bribeOptions.available = 0; game.bribeOptions.targets = []; + const button = guide({ game, busy: false, act(action) { sent.push(action); } }); + assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action); + assert.deepEqual(sent[0], { type: 'charity', turnActionPromiseEvent: event, turnActionPromisePhase: 2 }); + assert.equal(game.players.find(player => player.id === 'p')!.spice, 0); + assert.match(markup(game), /Claim 2 Charity spice while preserving your whole answer/); + assert.match(markup(game), /future claim is not current spendable spice/); +}); + +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; }, + (game: GameView) => { game.charity.amount = 0; }, + (game: GameView) => { game.charity.incomePending = true; }, + (game: GameView) => { game.charity = { ...game.charity }; }, + (game: GameView) => { game.players.find(player => player.id === 'p')!.spice = 1; }, + (game: GameView) => { game.ready.push('p'); }, + (game: GameView) => { game.phase = 3; }, + ]) { + const game = projected(2, { type: 'charity' }), saved = guide({ game, busy: false, act() { assert.fail('A stale whole-answer Charity claim cannot send'); } }); + assert.equal(saved.props.disabled, false); change(game); press(saved); + } + for (const extra of [{ target: 'r' }, { amount: 2 }, { card: 'private-card' }, { mode: 'shipment' }]) { + const game = projected(2, { type: 'charity' }); Object.assign(game.turnActionGuide!.action, extra); + const button = guide({ game, busy: false, act() { assert.fail('Charity accepts no extra native parameters'); } }); + assert.equal(button.props.disabled, true); press(button); + } + const pending = projected(2, { type: 'charity' }); pending.charity.incomePending = true; + assert.equal(guide({ game: pending, busy: false, act() { assert.fail('Pending CHOAM income cannot be counted as an ordinary claim'); } }).props.disabled, true); + const ready = projected(2, { type: 'charity' }); ready.ready.push('p'); + assert.equal(guide({ game: ready, busy: false, act() { assert.fail('A readied owner cannot open this preparation guide'); } }).props.disabled, true); +}); + void test('turn-action current paid Box choices are exact through phases zero to eight without private custody reads', () => { for (let phase = 0; phase <= 8; phase++) { const game = projected(phase, { type: 'decision', event: 'controlled-paid-search', card: 'controlled-recovered-hajr' });