From c81e00e6dd226069b7f10c21d486ef162201d6e4 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:41:10 +1100 Subject: [PATCH] Allow late Mentat bribes and retain turn-end promises --- components/bribe-claim-fields.tsx | 3 +- components/bribe-promises.tsx | 8 +- components/bribes.tsx | 2 +- components/truthtrance.tsx | 4 +- docs/BRIBE_CONTROLS.md | 8 +- docs/CURRENT_STATUS.md | 29 +++++ docs/IMPLEMENTATION_STATUS.md | 36 ++++++ docs/MENTAT_BRIBES.md | 93 +++++++++++++++ docs/RULE_DECISIONS.md | 16 +++ docs/TRUTHTRANCE_BRIBE_PROMISES.md | 20 +++- game/bribe-options.ts | 11 +- game/engine.ts | 16 +-- game/reference.ts | 22 ++-- game/truthtrance.ts | 2 +- tests/bribe-controls.test.tsx | 17 ++- tests/fixture-mentat-bribes.ts | 96 +++++++++++++++ tests/mentat-bribes-runtime.test.ts | 175 ++++++++++++++++++++++++++++ tests/truth-bribe-controls.test.tsx | 36 +++++- tests/truth-bribe-runtime.test.ts | 47 ++++++-- 19 files changed, 590 insertions(+), 51 deletions(-) create mode 100644 docs/MENTAT_BRIBES.md create mode 100644 tests/fixture-mentat-bribes.ts create mode 100644 tests/mentat-bribes-runtime.test.ts diff --git a/components/bribe-claim-fields.tsx b/components/bribe-claim-fields.tsx index e97bdced..e0d282ef 100644 --- a/components/bribe-claim-fields.tsx +++ b/components/bribe-claim-fields.tsx @@ -41,7 +41,8 @@ export function BribeClaimFields(props: { {error &&

{error}

}

Count cumulative future ordinary bribe payments to this recipient after the answer; smaller payments add together. - Earlier payments, gifts and ally aid do not count. The sender pays now; the recipient can spend the payment at Mentat Pause. + Earlier payments, gifts and ally aid do not count. The sender pays now; 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.

; } diff --git a/components/bribe-promises.tsx b/components/bribe-promises.tsx index f54d8b76..7b496d55 100644 --- a/components/bribe-promises.tsx +++ b/components/bribe-promises.tsx @@ -12,14 +12,15 @@ type BribePromiseProps = { game: GameView; busy: boolean; send: (action: Action) export function BribePromiseGuideButton(props: BribePromiseProps) { const game = props.game, guide = game.bribePromiseGuide; if (!guide) return null; - const actor = game.me, turn = game.turn, action = guide.action; + const actor = game.me, turn = game.turn, phase = game.phase, action = guide.action; const event = action.bribePromiseEvent, signature = JSON.stringify(action); 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' && - Number.isSafeInteger(current.phase) && current.phase >= 0 && current.phase <= 7 && current.turn === turn && current.me === actor && + 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' && liveBribePromises(current.bribePromises, actor, turn).some(promise => promise.event === event) && @@ -56,7 +57,8 @@ export function BribePromises(props: BribePromiseProps) { Prior payments, gifts and ally aid do not count.

- The sender pays immediately. The recipient can spend the payment only at Mentat Pause. + 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. {' '}Bribe promise rules.

diff --git a/components/bribes.tsx b/components/bribes.tsx index e1399396..71f1fd14 100644 --- a/components/bribes.tsx +++ b/components/bribes.tsx @@ -19,7 +19,7 @@ export function Bribes({ game: g, act, busy }: { return (
Bribes{options.incoming > 0 ? ` · ${options.incoming} spice awaiting Mentat` : ''} -

Pay another non-allied faction now. The amount is public; the recipient can spend their share only from the next Mentat Pause. A message or promise alone does not pay spice.

+

Pay another non-allied faction now. The amount is public; the recipient collects their share at the start of Mentat Pause. Payments made during Mentat Pause wait until the start of next turn's Mentat Pause. A message or promise alone does not pay spice.

Your incoming bribes: {options.incoming} spice, separate from your spendable supply.

{options.blocked ? {options.blocked} : null} {options.targets.length === 0 ?

No non-allied recipient is available.

: ( diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 68466e6f..151ca3e5 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -266,7 +266,7 @@ export function Truthtrance({ ) : w.question!.kind === 'bribe' ? ( <> -

Yes requires cumulative ordinary bribe payments to this named recipient while possible. No keeps all later payments below the minimum. Earlier payments, gifts and allied aid do not count. You pay now; the recipient spends the receipt at Mentat Pause.

+

Yes requires cumulative ordinary bribe payments to this named recipient while possible. No keeps all later payments below the minimum. Earlier payments, gifts and allied aid do not count. You pay now; the recipient collects at the next Mentat opening. Payments during Mentat wait until next turn's opening.

{(g.truthBribeAnswers ?? []).map(answer =>
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
)} @@ -453,7 +453,7 @@ export function Truthtrance({ setBribeSelection({ recipient: claim.recipient, minimum: claim.minimum })} />

Count cumulative ordinary payer payments after this answer, before this turn ends. Keep the current spendable spice needed by every Yes. Actual bribe timing and deferred recipient receipts remain; optional modules and future-income preparation remain unfinished.

- {!g.truthBribeTargets.includes(target) &&

Choose a supported participant before Mentat Pause and finish pending decisions.

} + {!g.truthBribeTargets.includes(target) &&

Choose a supported participant before this turn ends and finish pending decisions.

}

{truthQuestionText(question, leaderName, locationName, name)}

) : kind === 'revival' ? ( diff --git a/docs/BRIBE_CONTROLS.md b/docs/BRIBE_CONTROLS.md index d44515d5..199b397b 100644 --- a/docs/BRIBE_CONTROLS.md +++ b/docs/BRIBE_CONTROLS.md @@ -1,5 +1,9 @@ # Ordinary bribe controls +**26 October follow-up:** [quiet Mentat payments](MENTAT_BRIBES.md) are now +allowed after opening collection, with their receipts deferred to the next +turn's actual opening. This supersedes the original blanket Mentat restriction. + 21 September 2026. A connected prototype exposes the existing authoritative ordinary bribe action beneath the table. This does not complete negotiation, Leader Skills or expansion acceptance. @@ -10,8 +14,8 @@ Choose a non-allied recipient and a positive whole amount, then explicitly pay. The payer loses spice immediately. The recipient's share is deferred until the next Mentat Pause; it is not current spending money. The public chronicle records the amount and recipient, while the panel exposes only its owning seat's supply -and incoming escrow. Self/allied targets, Mentat payments and Inflation Double -remain prohibited. Bribes are allowed out of turn at otherwise open boundaries. +and incoming escrow. Self/allied targets and Inflation Double remain prohibited. +Bribes are allowed out of turn at otherwise open boundaries, including Mentat. These preserve the existing rules and [Bureaucrat source contract](BUREAUCRAT_PAYMENTS.md). The official [November FAQ, p. 9](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=9) diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 31d5bc4b..3d4ce4d2 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,35 @@ open mode gates. ## Current checkpoint and work +**Ordinary Mentat bribes / turn-end commitments — 26 October. Prototyped / Partial.** +Quiet Mentat payments now debit the payer and wait for next turn's actual +Mentat opening. The original opening-only receipt mechanism stays. Bribe No +survives opening collection until actual turn advance; unpaid Yes blocks +Mentat Ready and a new Yes reopens only its ready payer. A known automatic +Inflation Double closure still requires payment before Collection Ready, +including reopening an already-ready payer. Native packet, allied/self, +funding, pending-choice and Bureaucrat guards remain. + +Root integration with disjoint control/native lanes passes **13 files, +119/119**, types/lint/build and **six native paths**,26 GameTable SSR renders/ +two actual guide callbacks. Original H/A/E base33 Basic32/Advanced35-action +Mentat sources add30/31 actions: late3 spends E10→7, survives next Storm and +Collection, then credits exactly3 at the next opening. Advanced city income2 +is accounted separately. A new phase8 Yes reopens only its payer; a phase7 Yes +crosses Collection unpaid and pays late. No caps pre/post-opening payments +until the actual next turn. Already-ready ordinary payments retain readiness. +All33 physical cards remain; four minimal profiles use the exact legal guide. + +Twelve native cases and existing controlled tests cover the changed timing; +the known future Double supplement controls only a token frame and does not +claim an original CHOAM placement journey. Frozen v2 proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-mentat-bribes-proof-v2/`; +journeys/HTML: adjacent `20261026-mentat-bribes-runtime/`. +Broader faction/skill/module timing, original Inflation journeys, +browser/mobile/deployed and final assurance remain open. No saved-game, +database, server, restart or deployment operation is performed; mode gates stay. +[Source, explicit Basic interpretation and boundaries](MENTAT_BRIBES.md). + **Truthtrance / grouped native Revival — 26 October. Prototyped / Partial.** Whole AND/OR questions now combine future physical force minima and named personal native leader/Kwisatz returns in quiet bare native phases0–4. One diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index a7d7b83c..56e7a965 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,41 @@ # Dune implementation status +## 26 October 2026 — Ordinary Mentat bribes and actual turn-end promises + +**Prototyped / Partial.** Remove the inherited blanket phase8 payment ban. +Native ordinary payments debit current spice and keep recipient shares deferred +until the next real Mentat opening. No new immediate cash or second collection +is created. Current-turn Truth bribe questions now include phase8 in engine, +parser, controls and guides. No spans the opening until real turn advance; +unpaid Yes blocks Mentat Ready. New Yes reopens only its already-ready payer; +Collection readiness stays except before a known automatic Double closure. +That closure retains its earlier deadline and reopens an already-ready payer. +Existing terminal answers stay terminal; source/Basic interpretation is explicit. + +Root integration and disjoint control/native lanes pass **13 files,119/119**, +types/lint/build and **six native paths**,26 GameTable SSR renders/two actual +guide callbacks. Genuine H/A/E base33 sources reach first quiet Mentat in +Basic32/Advanced35 actions, with both original Truths still unplayed. +Late3 costs E10→7 and remains deferred through real next Storm/Collection; +30/31 further actions earn exactly3 at the next opening. Advanced city income2 +is separate. Other paths bind a new phase8 Yes after readiness, carry a phase7 +Yes unpaid across Collection, cap No payments across that receipt and expire +only on actual turn advance, and pay while already ready. All33 cards remain. +Four minimal profiles execute the exact legal payment; strategy stays deferred. + +The first frozen batch passed quick but exposed four native Mentat questions +still rejected by the inherited parser phase gate. The repaired parser and +ready-before-known-Double case passed five focused cases before the v2 batch. +Twelve native cases supplement existing controls/funding/Inflation coverage; +the controlled token-only closure case claims no native CHOAM placement history. +Frozen proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-mentat-bribes-proof-v2/`; +journeys/HTML: adjacent `20261026-mentat-bribes-runtime/`. +Broader faction/skill/module timing, original Inflation journeys, +browser/mobile/deployed and final assurance remain required. No saved-game, +database, server, reset, restart or deployment operation is performed. +[Contract and source interpretation](MENTAT_BRIBES.md). + ## 26 October 2026 — Whole AND/OR native Revival commitments **Prototyped / Partial.** Force minima and named personal native leader/KH diff --git a/docs/MENTAT_BRIBES.md b/docs/MENTAT_BRIBES.md new file mode 100644 index 00000000..67a8c49d --- /dev/null +++ b/docs/MENTAT_BRIBES.md @@ -0,0 +1,93 @@ +# Ordinary bribes after Mentat opens + +## Timing cutover — 26 October 2026 + +**Prototyped / Partial.** Quiet Mentat Pause now permits an ordinary non-allied +bribe. The payer spends current spice; the recipient's share stays in the +existing deferred `bribes` supply until the next actual Mentat opening. The +current opening has already collected its snapshot. A late payment never +creates immediate recipient cash or a second collection in this phase. + +## Source and interpretation + +The publisher [base rulebook, printed12 BRIBERY](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf) +allows non-allied deals subject to the listed rules/component restrictions; +it supplies no separate Mentat payment ban. Its indexed passage was checked for +this cutover. The [November2020 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf) +indexed Advanced clarification retains deferred recipient spice; a fresh direct +fetch returned403, so no dedicated new FAQ Mentat-payment ruling is claimed. +The earlier checked FAQ source remains recorded in [bribe controls](BRIBE_CONTROLS.md). + +The supplied authorized Advanced physical16/printed14 BRIBERY passage was read +for this cutover: collection happens only at each turn's Mentat start. Its +listed limitations also supply no blanket Mentat payment ban. Under standing +14October choose-recommended priority, preserve the app's existing opening-only +collection in both modes and carry a later payment to the next opening. +Basic keeps its existing deferred-receipt protocol rather than adopting new +Advanced faction rules. The Basic late-payment interval and source/online +cutover remain explicit software interpretation, not a dedicated publisher +clarification of this exact late-payment scenario. + +## Connected behavior + +`game/bribe-options.ts` removes only the inherited phase8 ban. Existing game, +pending-choice, Inflation Double, nonally, amount, uncommitted-funding and +Bureaucrat checks remain. The original `bribe` packet, payer debit and recipient +receipt producer are unchanged. The existing phase8 opening collects receipts +once; native next-turn initialization leaves late receipts intact. Incoming +deferred spice cannot fund another bribe or the next auction before collection. + +The existing [Truthtrance bribe contract](TRUTHTRANCE_BRIBE_PROMISES.md) now spans +phases0–8. Current-turn No survives the collection opening and caps subsequent +late payments until actual turn advance. Unpaid Yes prevents finishing Mentat, +rather than ordinary Collection. New Yes reopens only its already-ready Mentat +payer; Collection readiness stays intact except before a known automatic +Inflation flip to Double. That known closure requires payment before Collection +Ready and reopens an already-ready payer when a new Yes is bound. +An unknown future opposing choice is not fabricated for a witness. + +Only actual payer payments record promise progress. Later recipient collection +does not fulfill or pay the same promise again. Existing terminal historical +answers remain terminal; no save migration, retroactive promise reactivation or +database rewrite is performed. + +Ordinary and promise controls explain the opening boundary and next-turn late +receipt. Guides support Mentat and reject callbacks after their original phase +changes. Exact native packet/event, current options, room/busy and ownership +guards remain. All four minimal policies use the newly legal guided payment; +strategic negotiation and difficulty calibration stay deferred. + +## Evidence and remaining gates + +`tests/fixture-mentat-bribes.ts` derives real phase7/8 and next-turn openings +from the existing original H/A/E base33 source. Native cases are in +`tests/mentat-bribes-runtime.test.ts`; existing controlled bribe and promise +tests retain their labels. One controlled token-only supplement in the old +Truth runtime file covers a known future Double closure and does not claim a +native CHOAM placement history. + +The frozen v2 batch passes **13 files,119/119**, types/lint and the production +build. Six native gameplay paths produce26 GameTable SSR renders/two actual +guide callbacks. Original H/A/E base33 Basic32/Advanced35-action sources reach +quiet first Mentat; late3 spends E10→7 and survives real next Storm/Collection +before exactly3 is credited at the next opening. Advanced city income2 is +separate. Other paths reopen only an already-ready phase8 Yes payer, carry a +phase7 Yes unpaid across Collection, retain a cumulative No across the opening +until actual turn advance, and allow an already-ready ordinary late payer. +All33 cards remain; all four minimal profiles use the legal payment guide. +These are local native/SSR/callback results, not browser or deployed acceptance. + +The first batch's quick check passed, then four new native cases exposed a +remaining parser phase gate. The parser repair and ready-before-known-Double +closure passed five focused cases; the unchanged repaired tree then passed the +complete v2 selection/build/runtime. Its source snapshot is +`ea2e7ed4055807a02cd3a0bb1b96706097c37a306513a8b55ff22554c6b1eb9d` +(3128 files); final evidence prose is bound separately by the checkpoint manifest. +Proof/report/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-mentat-bribes-proof-v2/`; +journeys/HTML: adjacent `20261026-mentat-bribes-runtime/`. + +Broader late-phase skill/faction/module compositions, original Inflation +placement/closure journeys, browser/mobile/deployed and final assurance remain +required. No complete faction/mode or publication acceptance is certified. +No saved-game/database/server/reset/restart/deployment operation is performed. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 784724d0..c3cd1c9a 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,21 @@ # Rule decision index +## Late Mentat bribes and actual turn-end commitments — 26 October 2026 + +Remove the inherited whole-Mentat bribe ban. Publisher base printed12's +enumerated restrictions supply no such ban; the supplied Advanced physical16 +collects only at Mentat start. Under standing14October priority, keep the +existing opening collection in both modes and carry later receipts to next +turn's opening. Basic's exact late interval is a documented software +interpretation, not a newly fetched dedicated publisher ruling; direct FAQ +retrieval returned403 and only indexed/previously recorded text was available. +Native payer debit and deferred recipient accounting remain. Bribe No spans the +opening to actual turn advance; unpaid Yes blocks Mentat Ready, reopening only +that ready payer on a new answer. A known automatic Double closure still +requires payment before Collection Ready. Terminal old answers stay terminal; +there is no database rewrite or mode/publication gate change. +[Contract and sources](MENTAT_BRIBES.md). + ## Truthtrance grouped native Revival — 26 October 2026 Apply recorded publisher FAQp8 AND/OR/current-turn authority and supplied diff --git a/docs/TRUTHTRANCE_BRIBE_PROMISES.md b/docs/TRUTHTRANCE_BRIBE_PROMISES.md index 98f2f57f..5af3322e 100644 --- a/docs/TRUTHTRANCE_BRIBE_PROMISES.md +++ b/docs/TRUTHTRANCE_BRIBE_PROMISES.md @@ -1,10 +1,16 @@ # Truthtrance ordinary bribe-payment promises +**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 +wait for the next opening. This supersedes the historical Collection deadline +and Mentat exclusion below, without reopening already terminal old answers. + ## Funded current-turn prototype — 26 October 2026 **Prototyped / Partial.** Bind cumulative future ordinary spice payments to one named non-allied recipient during this turn. Registered bare native Basic and -Advanced tables use their existing bribe window in phases0–7. This adds no start +Advanced tables use their bribe window in phases0–8. This adds no start permission and does not certify complete rules, all deals or optional modules. Future income, pledge withdrawal, card preparation and wider negotiated effects remain required work; the first answer witness uses currently spendable funds. @@ -31,8 +37,8 @@ real purse and every No cap. This current-funding/cumulative/recipient and accepted-order release model is provisional software composition, not a dedicated publisher bribe-specific -Truthtrance ruling. The existing software bribe window excludes Mentat; that -inherited restriction is not newly attributed to the supplied physical16 text. +Truthtrance ruling. The inherited software Mentat ban has been removed by the +linked timing follow-up; it was not a supplied physical16 restriction. No can be fixed while Inflation shows Double; Yes needs a legal funded payment. Wider timing/source reconciliation remains part of complete rules acceptance. @@ -62,14 +68,18 @@ profiles use feasible answers and the exact native guide. Strategy and difficult calibration remain deferred. Own voluntary spending or an alliance offer to the unpaid named recipient cannot -evade Yes. Collection Ready requires its payment; a new Collection Yes reopens -that respondent's readiness. Pending consequences finish before forced release; +evade Yes. Mentat Ready requires its payment; a new Mentat Yes reopens only +that respondent's readiness. Collection readiness stays intact except before a +known automatic Double closure. Pending consequences finish before forced release; known own spending retains the promised current funds. Completed opposing impossibility releases only incompatible Yes answers in accepted order. No ends at the original turn boundary and never binds the next turn. ## Evidence and remaining gates +The following first-slice evidence predates the linked timing cutover; its +Collection deadline/reopening assertions are superseded by the Mentat follow-up. + Pure model, original-deal/payment/deferred-collection and controlled/native UI cases live in `tests/bribe-promises.test.ts`, `tests/fixture-truth-bribe.ts`, `tests/truth-bribe-runtime.test.ts` and `tests/truth-bribe-controls.test.tsx`. diff --git a/game/bribe-options.ts b/game/bribe-options.ts index 99eb91cc..42c84a0d 100644 --- a/game/bribe-options.ts +++ b/game/bribe-options.ts @@ -7,10 +7,10 @@ export interface BribeOptions { targets: { id: string; maximum: number; limitation: string | null }[]; } -/** Mirror only the existing bribe timing gates; bribes are allowed out of turn. */ +/** Ordinary bribes remain out of turn, including after Mentat's opening + * collection. Late receipts wait for the next opening, not another collection. */ export function bribeTimingBlock(g: Game): string | null { if (g.status !== 'playing') return 'Bribes require a game in progress.'; - if (g.phase === 8) return 'Bribes cannot be made during Mentat Pause.'; if (g.inflation?.side === 'double') return 'Bribes are prohibited while Inflation shows Double.'; if (g.pendingTreacheryDiscard || g.pendingNullentropy || g.bureaucratPayments?.pending || ['name', 'reveal'].includes(g.battle?.mentatQuestion?.stage ?? '') || @@ -21,6 +21,13 @@ export function bribeTimingBlock(g: Game): string | null { return null; } +/** The existing automatic token lifecycle will turn an unflipped Cancel into + * Double at this opening. An owed payment must precede that known closure. */ +export function bribesCloseBeforeNextMentat(g: Pick): boolean { + const token = g.inflation; + return !!token && token.updatedTurn < g.turn && !token.flipped && token.side === 'cancel'; +} + /** Payment only removes spendable spice, so a preserved completion is monotonic. */ export function maximumBribe(available: number, preservesCommitments: (amount: number) => boolean): number { let low = 0, high = Math.max(0, Math.floor(available)); diff --git a/game/engine.ts b/game/engine.ts index 375f4c4f..b5208f63 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -34,7 +34,7 @@ import { BidPromiseError, bidPromiseModeSupported, bidPromiseRange, bidRequired, recordPromisedBid, validateBidPromises, type BidClaim, type BidOpportunity, type BidPromise } from './bid-promises'; import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile'; import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3LeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; -import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options'; +import { bribeTimingBlock, bribesCloseBeforeNextMentat, maximumBribe, type BribeOptions } from './bribe-options'; import { quoteAtreidesFullPlan } from './atreides-full-plan'; import { quoteTleilaxuSpecialKarama } from './tleilaxu-special-karama'; import { quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError, type AdvisorStance, type AutomaticAdvisorFlipSource, type AutomaticAdvisorTransport, type AutomaticAdvisorGamont, type AutomaticAdvisorBattle, type AutomaticAdvisorHomeworldTransport, type AutomaticAdvisorFaceDance, type AutomaticAdvisorStorm, type AutomaticAdvisorRelocation } from './advisor-automatic-flip'; @@ -15067,7 +15067,7 @@ function bribePromiseIntegrity(g: Game) { bribePromiseRule(() => validateBribePromises(g.bribePromises ?? [], g.players.map(player => player.id))); } function bribePromiseTurn(g: Game): boolean { - return g.status === 'playing' && g.phase >= 0 && g.phase < 8 && bribePromiseModeSupported(g); + return g.status === 'playing' && g.phase >= 0 && g.phase <= 8 && bribePromiseModeSupported(g); } function bribeQuestionWindow(g: Game): boolean { // A current Double side can fix No without authorizing any payment. Every @@ -15125,9 +15125,9 @@ function bindBribeTruth(g: Game, p: Player, claim: BribeClaim, answer: boolean, requireRule(bribeTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'), 'Choose a bribe answer compatible with the current funds and earlier answers.'); (g.bribePromises ??= []).push({ ...claim, event: crypto.randomUUID(), player: p.id, asker, answer, progress: 0 }); - if (answer && g.phase === 7 && g.ready.includes(p.id)) { + if (answer && (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 Collection again after paying it.`); + log(g, `${p.name} has a new Truthtrance bribe commitment and must finish ${g.phase === 8 ? 'Mentat Pause' : 'Collection'} again after paying it.`); } } function reconcileBribePromises(g: Game, voluntary?: { actor: string; action: Action }) { @@ -38581,7 +38581,7 @@ function applyActionInner( p.spice -= n; log( g, - `${p.name} paid a ${n}-spice bribe to ${target.name}; its recipient share becomes spendable at the next Mentat Pause.`, + `${p.name} paid a ${n}-spice bribe to ${target.name}; its recipient share becomes spendable ${g.phase === 8 ? 'at the start of next turn’s Mentat Pause' : 'at the next Mentat Pause opening'}.`, ); const source = stampBureaucratPayment(g,'bribe',p.id,target.id,n).bureaucratPayment; if (!offerBureaucratPayment(g,source,{kind:'bribe'})) target.bribes += n; @@ -39900,8 +39900,10 @@ function applyActionInner( } if (t === 'ready') { if (allianceQuestionWindow(g)) guardAlliancePromiseReady(g, p); - if (g.phase === 7) requireRule(!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer), - 'Complete your Truthtrance bribe payments before finishing Collection.'); + const bribeWindowCloses = g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g); + if (bribeWindowCloses) requireRule(!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer), + g.phase === 8 ? 'Complete your Truthtrance bribe payments before finishing Mentat Pause.' + : 'Pay your Truthtrance bribes before the known Inflation Double opening prevents them.'); if (g.phase === 4) { guardPromisedRevival(g, p, action); guardPromisedLeaderRevival(g, p, action); guardRevivalExpressionReady(g, p); } requireRule( [1, 2, 4, 7, 8].includes(g.phase) || diff --git a/game/reference.ts b/game/reference.ts index 610c1c5b..4415d725 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -290,22 +290,22 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Getting started', coverage: 'Partial', developmentStage: 'Prototyped', - summary: 'Pay a non-allied faction now; its recipient share becomes spendable at the next Mentat Pause.', + summary: 'Pay a non-allied faction now; its recipient share becomes spendable at the next Mentat opening, including next turn for a late Mentat payment.', steps: [ 'Open Bribes beneath the table. Choose a non-allied faction and a positive whole amount, then use the named Pay button. The amount is public and leaves your supply immediately. A message or promise alone does not pay spice.', - 'The recipient keeps paid spice separately until the next Mentat Pause. Your incoming bribes are shown separately from your spendable supply; they cannot fund bids, shipments or another bribe yet.', - 'You may pay out of turn when no decision or response is pending. You cannot bribe yourself or your ally, during Mentat Pause, or while Inflation shows Double. Emperor gifts to allies use their own power controls.', + 'The recipient keeps paid spice separately until the next Mentat opening. A payment after the current opening waits for next turn’s opening. Your incoming bribes are shown separately from your spendable supply; they cannot fund bids, shipments or another bribe yet.', + 'You may pay out of turn when no decision or response is pending, including during Mentat after opening collection. You cannot bribe yourself or your ally, or while Inflation shows Double. Emperor gifts to allies use their own power controls.', 'Your payment limit excludes reserved spending and preserves binding Truthtrance answers. If an alliance, pending decision or available supply changes, recheck the recipient and amount. A rejected payment does not change the game.', 'A qualifying Bureaucrat may divert two spice to the Bank without reducing the payer’s cost. The remainder stays deferred. Unsupported combinations remain unavailable and do not imply an official rule prohibition.', 'Minimal AI follows supported Truthtrance ordinary-payment answers and their legal guides. Strategic bribe initiation and negotiation remain deferred. Discuss other agreements with human players; broader prose deals are not automatically enforced.', ], related: ['table-discussion', 'alliance-funding', 'card-truthtrance', 'privacy'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Existing payment and escrow are connected to private server-derived limits and supported Bureaucrat continuation. Complete negotiation and all module combinations remain open.', evidence: ['game/bribe-options.ts', 'game/engine.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Existing payment and escrow include quiet Mentat payments deferred to next turn’s opening, with private server-derived limits and supported Bureaucrat continuation. Complete negotiation and all module combinations remain open.', evidence: ['game/bribe-options.ts', 'game/engine.ts'] }, { area: 'Player controls', status: 'Implemented', detail: 'Named recipient, amount, explicit payment, deferred incoming supply and current availability guidance.', evidence: ['components/bribes.tsx'] }, { area: 'AI', status: 'Partial', detail: 'Passive receipt/collection and existing Bureaucrat responses work. Strategic bribe initiation and negotiation wait for feature completion.' }, - { area: 'Documentation', status: 'Implemented', detail: 'Payment, escrow, restrictions, saved continuation and remaining boundaries are recorded.', evidence: ['docs/BRIBE_CONTROLS.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Projection/JSON, committed budgets, privacy, target validation, Bureaucrat decisions, rendered controls and concurrent HTTP payment are covered. Full integrated acceptance remains open.', evidence: ['tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts', 'tests/battle-promises.test.ts', 'tests/shipment-promises.test.ts'] }, + { area: 'Documentation', status: 'Implemented', detail: 'Payment, escrow, restrictions, saved continuation and remaining boundaries are recorded.', evidence: ['docs/BRIBE_CONTROLS.md', 'docs/MENTAT_BRIBES.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Original late Mentat/next-turn receipt journeys supplement existing projection/JSON, committed budgets, privacy, target validation, Bureaucrat, controls and concurrent HTTP evidence. Full integrated acceptance remains open.', evidence: ['tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts', 'tests/battle-promises.test.ts', 'tests/shipment-promises.test.ts'] }, ], }, { @@ -3243,19 +3243,19 @@ export const RULE_TOPICS: RuleTopic[] = [ steps: [ '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 original Mentat collection. The question creates no immediate recipient cash, discount or refund. Current auction and sealed-battle liabilities stay reserved.', + '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.', '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 Collection cannot evade an unpaid Yes. An already-ready Collection respondent must finish again after a new Yes. Completed opposing consequences may release impossible answers in accepted order. No finishes at the original turn boundary.', - 'The current software bribe window excludes Mentat and Inflation Double. 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.', + '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.', + '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'], checklist: [ { area: 'Implementation', status: 'Partial', detail: 'Recipient-specific cumulative ordinary payments, joint funding/caps, real payer completion, deferred native receipt and accepted-order release retain original mechanics.', evidence: ['game/bribe-promises.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/bribe-promises.test.ts', 'tests/truth-bribe-runtime.test.ts'] }, { area: 'Player controls', status: 'Partial', detail: 'Typed current-turn named-recipient fields, public preview, target-only answers and persistent guarded out-of-turn payment guide; ordinary recipient limits retain other commitments.', evidence: ['components/bribe-claim-fields.tsx', 'components/bribe-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-bribe-controls.test.tsx'] }, { area: 'AI', status: 'Partial', detail: 'Existing four minimal profiles answer and execute legal ordinary-payment guides. Strategic initiation/negotiation and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-bribe-runtime.test.ts'] }, - { area: 'Documentation', status: 'Implemented', detail: 'General Truthtrance/physical bribery sources, provisional cumulative meaning, funded scope, original timing and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_BRIBE_PROMISES.md', 'docs/RULE_DECISIONS.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Focused pure, original deal/payment/Mentat runtime and controlled/native UI evidence. Native alliance campaigns, optional compositions, future preparations and final browser/deployed acceptance remain open.', evidence: ['tests/fixture-truth-bribe.ts', 'tests/truth-bribe-runtime.test.ts', 'tests/truth-bribe-controls.test.tsx'] }, + { area: 'Documentation', status: 'Implemented', detail: 'General Truthtrance/physical bribery sources, provisional cumulative meaning, funded scope, original timing and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_BRIBE_PROMISES.md', 'docs/MENTAT_BRIBES.md', 'docs/RULE_DECISIONS.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Focused pure, original deal/payment/Mentat/next-turn runtime and controlled/native UI evidence. Native alliance campaigns, optional compositions, future preparations and final browser/deployed acceptance remain open.', evidence: ['tests/fixture-mentat-bribes.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/fixture-truth-bribe.ts', 'tests/truth-bribe-runtime.test.ts', 'tests/truth-bribe-controls.test.tsx'] }, ], }, { diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 837b658f..f3587f2f 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -256,7 +256,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { check(Object.keys(v).sort().join(',') === 'kind,minimum,recipient,target,turn', 'Supply the current turn, named bribe recipient, minimum and respondent.'); const claim = parseBribeClaim(v), respondent = g.players.find(player => player.id === v.target)!; - check(bribePromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase < 8 && + check(bribePromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 8 && claim.turn === g.turn && claim.recipient !== respondent.id && claim.recipient !== respondent.ally && g.players.some(player => player.id === claim.recipient) && !g.response && !g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama, diff --git a/tests/bribe-controls.test.tsx b/tests/bribe-controls.test.tsx index afb17b17..53a4b186 100644 --- a/tests/bribe-controls.test.tsx +++ b/tests/bribe-controls.test.tsx @@ -31,7 +31,7 @@ function reject(g: Game, actor: string, action: object) { assert.equal(JSON.stringify(g), before); } -void test('out-of-turn bribes debit once, stay private as escrow and collect once at Mentat after JSON restoration', () => { +void test('out-of-turn bribes collect at a controlled Mentat opening and late payments remain deferred after JSON restoration', () => { let g = game(); g.active = 'e'; const before = JSON.stringify(g); @@ -55,8 +55,14 @@ void test('out-of-turn bribes debit once, stay private as escrow and collect onc assert.equal(g.phase, 8); assert.equal(g.players[1].spice, 14); assert.equal(options(g, 'h').incoming, 0); - assert.match(options(g).blocked!, /Mentat/); + assert.equal(options(g).blocked, null); assert.deepEqual(options(JSON.parse(JSON.stringify(g)), 'h'), options(g, 'h')); + g = applyAction(g, 'a', bribeAction(options(g), 'h', 2)!); + assert.equal(g.phase, 8); + assert.equal(g.players[0].spice, 4); + assert.equal(g.players[1].spice, 14); + assert.equal(options(g, 'h').incoming, 2); + assert.equal(options(g, 'h').available, 14); }); void test('invalid amounts, self/allied recipients and stale client options never authorize a payment', () => { @@ -73,7 +79,6 @@ void test('invalid amounts, self/allied recipients and stale client options neve reject(g, 'a', { type: 'bribe', target: 'e', amount }); } for (const paused of [ - { phase: 8 }, { inflation: { side: 'double', turn: 2 } }, { phaseOpening: { passed: [], initialize: false } }, { response: { kind: 'emperorGift', owner: 'e', recipient: 'h', amount: 1, passed: [] } }, @@ -126,12 +131,14 @@ void test('bribe controls render recipient choices, private escrow and disabled assert.match(html, /4 spice awaiting Mentat/); assert.match(html, /Bribe recipient/); assert.match(html, /Harkonnen recipient/); - assert.match(html, /the next Mentat Pause/); + assert.match(html, /collects their share at the start of Mentat Pause/); + assert.match(html, /Payments made during Mentat Pause wait until the start of next turn's Mentat Pause/); assert.match(html, /disabled=""[^>]*>Pay bribe/); assert.match(html, /rules\?topic=bribes#bribes/); assert.ok((render(g, true).match(/disabled=""/g) ?? []).length >= 3); g.phase = 8; - assert.match(render(g), /Bribes cannot be made during Mentat Pause/); + assert.equal(options(g).blocked, null); + assert.match(render(g), /Payments made during Mentat Pause wait until the start of next turn's Mentat Pause/); g.status = 'lobby'; assert.equal(render(g), ''); }); diff --git a/tests/fixture-mentat-bribes.ts b/tests/fixture-mentat-bribes.ts new file mode 100644 index 00000000..51e1f61d --- /dev/null +++ b/tests/fixture-mentat-bribes.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import type { Game } from '../game/engine'; +import { + actTruthBribe, advanceTruthBribe, answerTruthBribe, askTruthBribe, createTruthBribeFixture, + declareTruthBribe, finishTruthBribe, nextTruthBribe, settleTruthBribe, truthBribeBalances, + truthBribeClean, truthBribeDeferred, truthBribeInventory, truthBribePlayer, + type TruthBribeStep, type TruthBribeTransition, +} from './fixture-truth-bribe'; + +export type MentatBribeStep = TruthBribeStep; +export type MentatBribeTransition = TruthBribeTransition; +export const actMentatBribe = actTruthBribe; +export const nextMentatBribe = nextTruthBribe; +export const advanceMentatBribe = advanceTruthBribe; +export const settleMentatBribe = settleTruthBribe; +export const finishMentatBribe = finishTruthBribe; +export const mentatBribeClean = truthBribeClean; +export const mentatBribeInventory = truthBribeInventory; +export const mentatBribePlayer = truthBribePlayer; +export const mentatBribeBalances = truthBribeBalances; +export const mentatBribeDeferred = truthBribeDeferred; +export const declareMentatBribe = declareTruthBribe; +export const askMentatBribe = askTruthBribe; +export const answerMentatBribe = answerTruthBribe; + +/** Reach the current turn's actual Mentat from earlier phases, or the next + * actual opening when this turn's collection has already happened. */ +export function nextMentatBribeOpening(game: Game, actions: MentatBribeStep[] = []): Game { + const turn = game.turn + Number(game.phase === 8); + return advanceMentatBribe(game, state => state.turn === turn && state.phase === 8 && mentatBribeClean(state), actions); +} + +/** The inherited original H/A/E base33 source earns Collection and Mentat by + * real native actions. H still owns both original, unplayed Truthtrances and E + * its original Shield and ten spendable spice. No played state is installed. */ +export function createMentatBribeFixture(options: { advanced?: boolean } = {}) { + const base = createTruthBribeFixture({ advanced: options.advanced, phase: 7 }); + const actions = structuredClone(base.sourceActions), before = structuredClone(base.game), from = actions.length; + const game = nextMentatBribeOpening(base.game, actions); + const phase7: MentatBribeTransition = { + before: structuredClone(base.base.game), + steps: structuredClone(base.sourceActions.slice(base.base.sourceActionCount)), + after: structuredClone(base.game), + }; + const mentat: MentatBribeTransition = { + before, steps: structuredClone(actions.slice(from)), after: structuredClone(game), + }; + assert.equal(game.phase, 8); assert.equal(game.turn, 1); assert.ok(mentatBribeClean(game)); + assert.equal(mentatBribePlayer(game, base.payer).spice, 10); + assert.ok(game.players.every(player => player.bribes === 0 && player.ally === null)); + assert.deepEqual(mentatBribePlayer(game, base.asker).hand.map(card => card.id), base.truths); + assert.deepEqual(mentatBribeInventory(game), base.originalCards); + return { ...base, game, sourceGame: structuredClone(game), base, phase: 8 as const, phase7, mentat, + originalBalances: mentatBribeBalances(game), originalDeferred: mentatBribeDeferred(game), + actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length, + staging: [] as string[], + sourceDisclosure: 'Inherited genuine public original H/A/E base33 setup, ordinary auction and native Collection/Mentat actions. H retains both original unplayed Truthtrances; E retains its original Shield and10spice. Only inherited unopened deal/lot/Spice positions and scalar lotteries are selected. The current Mentat opening already collected its receipts. A new payment spends the payer now and waits in the exact recipient bribes until the next actual Mentat opening. No played clock, purse, hand, receipt or promise is installed.' }; +} +export type MentatBribeFixture = ReturnType; + +export function bindMentatBribe(f: MentatBribeFixture, answer: 'yes' | 'no' = 'yes', minimum = f.minimum, + recipient = f.recipient, state = f.game) { + const declared = declareMentatBribe(f, state), actions = [...declared.actions]; + const asked = askMentatBribe(declared.game, f.asker, f.target, recipient, minimum, actions); + const game = answerMentatBribe(asked, f.target, answer, actions); + return { game, declared, asked, actions, actionCount: actions.length }; +} + +/** Keep next Storm and ordinary Collection visible before the next real + * Mentat opening. Incoming escrow cannot masquerade as cash along this path. */ +export function completeMentatBribe(f: MentatBribeFixture, state = f.game) { + assert.equal(state.phase, 8); + let game = state; const turn = game.turn, actions: MentatBribeStep[] = []; + const transition = (until: (state: Game) => boolean): MentatBribeTransition => { + const before = structuredClone(game), from = actions.length; + game = advanceMentatBribe(game, until, actions); + return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) }; + }; + const nextTurn = transition(state => state.turn === turn + 1 && state.phase === 0 && mentatBribeClean(state)); + const collection = transition(state => state.turn === turn + 1 && state.phase === 7 && mentatBribeClean(state)); + const mentat = transition(state => state.turn === turn + 1 && state.phase === 8 && mentatBribeClean(state)); + assert.deepEqual(mentatBribeInventory(game), f.originalCards); + return { game, nextTurn, collection, mentat, actions, actionCount: actions.length }; +} + +/** Move Collection readiness explicitly so an unpaid Yes is carried into + * Mentat instead of automatically consuming the adapter's suggested payment. */ +export function enterMentatBribeWithUnpaidPromise(game: Game, actions: MentatBribeStep[] = []): Game { + assert.equal(game.phase, 7); assert.ok(mentatBribeClean(game)); + const turn = game.turn; + for (const player of game.players) { + if (game.phase === 7 && !game.ready.includes(player.id)) + game = actMentatBribe(game, player.id, { type: 'ready' }, actions); + } + return advanceMentatBribe(game, state => state.turn === turn && state.phase === 8 && mentatBribeClean(state), actions); +} diff --git a/tests/mentat-bribes-runtime.test.ts b/tests/mentat-bribes-runtime.test.ts new file mode 100644 index 00000000..d057ccd6 --- /dev/null +++ b/tests/mentat-bribes-runtime.test.ts @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { bindTruthBribe } from './fixture-truth-bribe'; +import { + actMentatBribe as act, advanceMentatBribe as advance, bindMentatBribe as bind, + completeMentatBribe as complete, createMentatBribeFixture as fixture, + enterMentatBribeWithUnpaidPromise as enterMentat, finishMentatBribe as finish, + mentatBribeBalances as balances, mentatBribeClean as clean, + mentatBribeDeferred as deferred, mentatBribeInventory as inventory, mentatBribePlayer as player, + nextMentatBribeOpening as opening, settleMentatBribe as settle, +} from './fixture-mentat-bribes'; + +function rejected(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before); +} +const promise = (game: Game, target: string, recipient: string) => + game.bribePromises!.find(promise => promise.player === target && promise.recipient === recipient)!; +const pay = (game: Game, actor: string, target: string, amount: number) => act(game, actor, { type: 'bribe', target, amount }); + +for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} late Mentat payment waits through real next Storm and Collection for one opening receipt`, () => { + const f = fixture({ advanced }); + assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); + assert.equal(f.originalCards.length, 33); assert.deepEqual(f.staging, []); + assert.equal(f.sourceActionCount, f.sourceActions.length); assert.ok(f.mentat.steps.length > 0); + assert.equal(f.phase7.before.phase, 3); assert.equal(f.phase7.after.phase, 7); + assert.equal(f.mentat.before.phase, 7); assert.equal(f.mentat.after.phase, 8); + assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths); + const source = structuredClone(f.game), before = balances(f.game); + let game = pay(f.game, f.payer, f.recipient, 3); + assert.deepEqual(f.game, source); + assert.equal(player(game, f.payer).spice, 7); assert.equal(player(game, f.recipient).spice, before[f.recipient]); + assert.equal(player(game, f.recipient).bribes, 3); + const result = complete(f, game); + assert.equal(result.nextTurn.after.turn, 2); assert.equal(result.nextTurn.after.phase, 0); + for (const state of [result.nextTurn.after, result.collection.after, result.mentat.before]) { + assert.equal(player(state, f.recipient).bribes, 3); + assert.equal(player(state, f.payer).spice, 7); + assert.deepEqual(player(state, f.asker).hand.map(card => card.id), f.truths); + assert.deepEqual(inventory(state), f.originalCards); + } + assert.equal(player(result.nextTurn.after, f.recipient).spice, before[f.recipient]); + assert.equal(player(result.collection.after, f.recipient).spice - before[f.recipient], advanced ? 2 : 0); + assert.equal(player(result.mentat.after, f.recipient).spice - player(result.mentat.before, f.recipient).spice, 3); + assert.equal(player(result.mentat.after, f.recipient).bribes, 0); + const received = balances(result.game); + game = settle(act(result.game, f.payer, { type: 'ready' })); + assert.equal(game.phase, 8); assert.deepEqual(balances(game), received); + assert.ok(game.players.every(seat => seat.bribes === 0)); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native late incoming Mentat spice cannot fund another payment or the next actual auction bid before collection', () => { + const f = fixture(), game = pay(f.game, f.payer, f.recipient, 3), cash = player(game, f.recipient).spice; + rejected(game, f.recipient, { type: 'bribe', target: f.opponent, amount: cash + 1 }); + const auction = advance(game, state => state.turn === 2 && state.phase === 3 && clean(state) && state.auction?.active === f.recipient); + assert.equal(player(auction, f.recipient).spice, cash); assert.equal(player(auction, f.recipient).bribes, 3); + rejected(auction, f.recipient, { type: 'bid', amount: cash + 1 }); + rejected(auction, f.recipient, { type: 'bribe', target: f.opponent, amount: cash + 1 }); + assert.deepEqual(player(auction, f.asker).hand.map(card => card.id), f.truths); + assert.deepEqual(inventory(auction), f.originalCards); +}); + +void test('native multiple late payer payments sum once in the exact next Mentat recipient receipt', () => { + const f = fixture(), before = balances(f.game); + let game = pay(f.game, f.payer, f.recipient, 3); game = pay(game, f.opponent, f.recipient, 2); + assert.equal(player(game, f.payer).spice, before[f.payer] - 3); + assert.equal(player(game, f.opponent).spice, before[f.opponent] - 2); + assert.equal(player(game, f.recipient).spice, before[f.recipient]); assert.equal(player(game, f.recipient).bribes, 5); + const result = complete(f, game); + assert.equal(player(result.mentat.before, f.recipient).bribes, 5); + assert.equal(player(result.game, f.recipient).spice - player(result.mentat.before, f.recipient).spice, 5); + assert.ok(result.game.players.every(seat => seat.bribes === 0)); assert.deepEqual(inventory(result.game), f.originalCards); +}); + +void test('native pre-Mentat and late-Mentat payments belong to their separate actual openings', () => { + const f = fixture(); let game = pay(f.base.game, f.payer, f.recipient, 2); + const firstCash = player(game, f.recipient).spice; + game = opening(game); + assert.equal(player(game, f.recipient).spice, firstCash + 2); assert.equal(player(game, f.recipient).bribes, 0); + game = pay(game, f.payer, f.recipient, 3); + assert.equal(player(game, f.payer).spice, 5); assert.equal(player(game, f.recipient).spice, firstCash + 2); + assert.equal(player(game, f.recipient).bribes, 3); + const result = complete(f, game); + assert.equal(player(result.nextTurn.after, f.recipient).spice, firstCash + 2); + assert.equal(player(result.game, f.recipient).spice - player(result.mentat.before, f.recipient).spice, 3); + assert.equal(player(result.game, f.recipient).bribes, 0); assert.deepEqual(inventory(result.game), f.originalCards); +}); + +void test('native new Mentat Truth Yes requires the real payment before payer Ready and leaves its receipt deferred', () => { + const f = fixture(), bound = bind(f); let game = bound.game; + assert.equal(game.phase, 8); assert.deepEqual(viewGame(bound.asked, f.payer).truthBribeAnswers, ['yes', 'no']); + rejected(game, f.payer, { type: 'ready' }); + const guide = viewGame(game, f.payer).bribePromiseGuide; assert.ok(guide); + assert.equal(guide.action.target, f.recipient); assert.equal(guide.action.amount, 3); + const before = balances(game); game = act(game, f.payer, guide.action); + assert.equal(player(game, f.payer).spice, 7); assert.equal(player(game, f.recipient).spice, before[f.recipient]); + assert.equal(player(game, f.recipient).bribes, 3); assert.equal(promise(game, f.payer, f.recipient).fulfilled, true); + game = act(game, f.payer, { type: 'ready' }); assert.ok(game.ready.includes(f.payer)); assert.equal(game.phase, 8); + const result = complete(f, game); + assert.equal(player(result.game, f.recipient).spice - player(result.mentat.before, f.recipient).spice, 3); + assert.equal(game.discard.filter(card => card.id === f.truth).length, 1); assert.deepEqual(inventory(result.game), f.originalCards); +}); + +void test('native Collection Truth Yes survives Collection Ready and remains binding in actual Mentat', () => { + const f = fixture(); let game = bindTruthBribe(f.base).game; + const before = balances(game); game = enterMentat(game); + assert.equal(game.phase, 8); assert.deepEqual(balances(game), before); assert.equal(player(game, f.recipient).bribes, 0); + assert.equal(promise(game, f.payer, f.recipient).progress, 0); assert.ok(!promise(game, f.payer, f.recipient).released); + rejected(game, f.payer, { type: 'ready' }); + const guide = viewGame(game, f.payer).bribePromiseGuide; assert.ok(guide); game = act(game, f.payer, guide.action); + assert.equal(player(game, f.payer).spice, 7); assert.equal(player(game, f.recipient).spice, before[f.recipient]); + assert.equal(player(game, f.recipient).bribes, 3); assert.equal(promise(game, f.payer, f.recipient).fulfilled, true); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native Collection No cap spans its old receipt and late Mentat payments until actual turn advance', () => { + const f = fixture(); let game = bindTruthBribe(f.base, 'no').game; + game = pay(game, f.payer, f.recipient, 1); const before = player(game, f.recipient).spice; + game = enterMentat(game); + assert.equal(player(game, f.recipient).spice, before + 1); assert.equal(player(game, f.recipient).bribes, 0); + assert.equal(promise(game, f.payer, f.recipient).progress, 1); + game = pay(game, f.payer, f.recipient, 1); + assert.equal(promise(game, f.payer, f.recipient).progress, 2); assert.equal(player(game, f.recipient).bribes, 1); + rejected(game, f.payer, { type: 'bribe', target: f.recipient, amount: 1 }); + game = finish(game); assert.equal(game.turn, 2); assert.equal(game.phase, 0); + game = pay(game, f.payer, f.recipient, 3); + assert.equal(player(game, f.payer).spice, 5); assert.equal(player(game, f.recipient).bribes, 4); + assert.equal(promise(game, f.payer, f.recipient).progress, 2); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native new Mentat Yes reopens only its already-ready payer while another seat is still unready', () => { + const f = fixture(); let game = act(f.game, f.payer, { type: 'ready' }); + game = act(game, f.asker, { type: 'ready' }); assert.equal(game.phase, 8); + assert.ok(game.ready.includes(f.payer)); assert.ok(game.ready.includes(f.asker)); assert.ok(!game.ready.includes(f.opponent)); + game = bind(f, 'yes', 3, f.recipient, game).game; + assert.ok(!game.ready.includes(f.payer)); assert.ok(game.ready.includes(f.asker)); assert.ok(!game.ready.includes(f.opponent)); + rejected(game, f.payer, { type: 'ready' }); + const guide = viewGame(game, f.payer).bribePromiseGuide; assert.ok(guide); game = act(game, f.payer, guide.action); + game = act(game, f.payer, { type: 'ready' }); + assert.equal(game.phase, 8); assert.ok(game.ready.includes(f.payer)); assert.ok(game.ready.includes(f.asker)); + assert.equal(player(game, f.recipient).bribes, 3); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native an already-ready Mentat payer can make an ordinary late payment while other seats wait', () => { + const f = fixture(); let game = act(f.game, f.payer, { type: 'ready' }); + const before = balances(game), ready = [...game.ready]; game = pay(game, f.payer, f.recipient, 3); + assert.equal(game.phase, 8); assert.deepEqual(game.ready, ready); + assert.equal(player(game, f.payer).spice, 7); assert.equal(player(game, f.recipient).spice, before[f.recipient]); + assert.equal(player(game, f.recipient).bribes, 3); + assert.deepEqual(player(game, f.asker).hand.map(card => card.id), f.truths); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native a new Mentat No still caps cumulative payments until the real next turn', () => { + const f = fixture(); let game = bind(f, 'no').game; + game = pay(game, f.payer, f.recipient, 1); rejected(game, f.payer, { type: 'bribe', target: f.recipient, amount: 2 }); + const cash = balances(game), receipts = deferred(game); game = finish(game); + assert.equal(game.turn, 2); assert.deepEqual(balances(game), cash); assert.deepEqual(deferred(game), receipts); + game = pay(game, f.payer, f.recipient, 3); + assert.equal(player(game, f.payer).spice, 6); assert.equal(player(game, f.recipient).bribes, 4); + assert.equal(promise(game, f.payer, f.recipient).progress, 1); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('all four minimal profiles execute the exact legal Mentat Truth Yes payment before Ready', () => { + const f = fixture(), game = bind(f).game; + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, f.payer); view.players.find(seat => seat.id === f.payer)!.bot = difficulty; + const action = botActions(view).find(action => action.type === 'bribe' && action.target === f.recipient && action.amount === 3); + assert.ok(action, difficulty); const paid = act(structuredClone(game), f.payer, action); + assert.equal(player(paid, f.payer).spice, 7); assert.equal(player(paid, f.recipient).spice, player(game, f.recipient).spice); + assert.equal(player(paid, f.recipient).bribes, 3); assert.equal(promise(paid, f.payer, f.recipient).fulfilled, true); + assert.deepEqual(inventory(paid), f.originalCards); + } +}); diff --git a/tests/truth-bribe-controls.test.tsx b/tests/truth-bribe-controls.test.tsx index 2120c868..a41d0a76 100644 --- a/tests/truth-bribe-controls.test.tsx +++ b/tests/truth-bribe-controls.test.tsx @@ -75,6 +75,8 @@ void test('bribe claim fields keep the turn fixed and accept only current recipi assert.match(html, /Turn 2/); assert.match(html, /Recipient/); assert.match(html, /min="1"/); assert.match(html, /max="9007199254740991"/); assert.match(html, /smaller payments add together/); assert.match(html, /Earlier payments, gifts and ally aid do not count/); + assert.match(html, /recipient collects the payment at the start of Mentat Pause/); + assert.match(html, /Payments made during Mentat Pause wait until the start of next turn's Mentat Pause/); minimum(props, 1); minimum(props, Number.MAX_SAFE_INTEGER); recipient(props, 'a'); assert.deepEqual(changes, [{ ...value, minimum: 1 }, { ...value, minimum: Number.MAX_SAFE_INTEGER }, { ...value, recipient: 'a' }]); for (const next of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { @@ -102,7 +104,8 @@ void test('own bribe promises persist with named cumulative progress, delayed sp assert.match(html, /No forbids reaching that minimum during the rest of this turn/); assert.match(html, /Smaller payments to the named recipient add together/); assert.match(html, /Prior payments, gifts and ally aid do not count/); - assert.match(html, /The sender pays immediately/); assert.match(html, /recipient can spend the payment only at Mentat Pause/); + assert.match(html, /The sender pays immediately/); assert.match(html, /recipient collects the payment at the start of Mentat Pause/); + assert.match(html, /Payments made during Mentat Pause wait until the start of next turn's Mentat Pause/); assert.match(html, /Fulfilled: this commitment is complete/); assert.match(html, /Released: this commitment is no longer possible/); assert.match(html, /href="\/rules#truthtrance-bribe"/); assert.doesNotMatch(html, /at least 99|foreign-event|controlled-bribe-event/); @@ -127,10 +130,39 @@ void test('actual guide callbacks forward exact ordinary bribes out of turn thro } }); +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']; + const before = structuredClone(game); + const button = guide({ game, busy: false, send(action) { sent.push(action); } }); + assert.equal(button.props.disabled, false); + press(button); + assert.deepEqual(sent, [game.bribePromiseGuide!.action]); + assert.equal(sent[0], game.bribePromiseGuide!.action); + assert.deepEqual(game, before); + assert.match(markup(game), /Payments made during Mentat Pause wait until the start of next turn's Mentat Pause/); +}); + +void test('controlled bribe callbacks expire when their original phase changes between otherwise supported windows', () => { + for (const [original, current] of [[3, 8], [8, 7], [7, 8]]) { + const game = projected(original), sent: Action[] = []; + const props = { game, busy: false, send(action: Action) { sent.push(action); } }; + const button = guide(props); + assert.equal(button.props.disabled, false); + game.phase = current; + press(button); + assert.deepEqual(sent, []); + const currentButton = guide(props); + assert.equal(currentButton.props.disabled, false); + press(currentButton); + assert.deepEqual(sent, [game.bribePromiseGuide!.action]); + } +}); + void test('stale bribe guides respect current scope, exact recipient, strictly clear timing and projected payment bounds', () => { const changes: ((game: GameView) => void)[] = [ game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.status = 'setup'; }, - game => { game.phase = 8; }, game => { game.phase = -1; }, + game => { game.phase = 9; }, game => { game.phase = -1; }, game => { game.bribePromiseGuide = { action: { ...game.bribePromiseGuide!.action } }; }, game => { game.bribePromiseGuide = null; }, game => { game.bribePromiseGuide!.action.amount = 3; }, game => { game.bribePromises[0].event = 'expired'; }, game => { game.bribePromises[0].player = 'a'; }, diff --git a/tests/truth-bribe-runtime.test.ts b/tests/truth-bribe-runtime.test.ts index d4b32f0a..cb9c6c14 100644 --- a/tests/truth-bribe-runtime.test.ts +++ b/tests/truth-bribe-runtime.test.ts @@ -6,7 +6,7 @@ import { DIFFICULTIES } from '../game/bot-profiles'; import { actTruthBribe as act, advanceTruthBribe as advance, answerTruthBribe as answer, askTruthBribe as ask, bindTruthBribe as bind, completeTruthBribe as complete, - createTruthBribeFixture as fixture, declareTruthBribe as declare, settleTruthBribe as settle, + createTruthBribeFixture as fixture, declareTruthBribe as declare, settleTruthBribe as settle, finishTruthBribe as finish, truthBribeBalances as balances, truthBribeClean as clean, truthBribeInventory as inventory, truthBribePlayer as player, } from './fixture-truth-bribe'; import { createTruthRevivalFixture } from './fixture-truth-revival'; @@ -135,32 +135,61 @@ void test('native already committed auction spice cannot supply an unfunded thre assert.equal(player(game, f.target).spice, 10); assert.deepEqual(inventory(game), f.originalCards); }); -void test('native last Collection readiness cannot erase an unpaid Bribe Yes before actual Mentat', () => { +void test('native unpaid Bribe Yes passes Collection but cannot finish Mentat before its late payment', () => { const f = fixture({ phase: 7 }); let game = act(f.game, f.asker, { type: 'ready' }); game = act(game, f.opponent, { type: 'ready' }); assert.equal(game.phase, 7); game = bind(f, 'yes', 3, f.recipient, game).game; + const before = player(game, f.recipient).spice; + game = act(game, f.target, { type: 'ready' }); + assert.equal(game.phase, 8); assert.equal(player(game, f.recipient).spice, before); + assert.ok(!promise(game, f.target, f.recipient).fulfilled); + assert.ok(!promise(game, f.target, f.recipient).released); rejected(game, f.target, { type: 'ready' }); - const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); game = act(game, f.target, guide.action); - const before = player(game, f.recipient).spice; game = act(game, f.target, { type: 'ready' }); - assert.equal(game.phase, 8); assert.equal(player(game, f.recipient).spice, before + 3); - assert.equal(player(game, f.recipient).bribes, 0); assert.deepEqual(inventory(game), f.originalCards); + const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); + game = act(game, f.target, guide.action); + assert.equal(player(game, f.recipient).spice, before); + assert.equal(player(game, f.recipient).bribes, 3); + game = finish(game); + assert.equal(game.turn, 2); assert.equal(player(game, f.recipient).bribes, 3); + assert.deepEqual(inventory(game), f.originalCards); }); -void test('a real already-ready Collection payer reopens readiness when accepting a new Bribe Yes', () => { +void test('a new Bribe Yes retains already-ready Collection status and permits its original out-of-turn payment', () => { const f = fixture({ phase: 7 }); let game = act(f.game, f.target, { type: 'ready' }); assert.equal(game.phase, 7); assert.ok(game.ready.includes(f.target)); game = bind(f, 'yes', 3, f.recipient, game).game; - assert.equal(game.phase, 7); assert.ok(!game.ready.includes(f.target)); - rejected(game, f.target, { type: 'ready' }); + assert.equal(game.phase, 7); assert.ok(game.ready.includes(f.target)); const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); game = act(game, f.target, guide.action); + assert.ok(game.ready.includes(f.target)); assert.equal(player(game, f.recipient).bribes, 3); const result = complete(f, game); assert.equal(player(result.mentat.after, f.recipient).spice - player(result.mentat.before, f.recipient).spice, 3); assert.deepEqual(inventory(result.game), f.originalCards); }); +void test('controlled known Inflation Double closure reopens a ready payer and requires payment before Collection readiness', () => { + const f = fixture({ phase: 7 }); + const controlled = structuredClone(f.game); + // Only the token frame is controlled; the real H/A/E source has no CHOAM + // player and does not claim a native token-placement history. + controlled.inflation = { side: 'cancel', placedTurn: 0, updatedTurn: 0, flipped: false }; + let game = act(controlled, f.target, { type: 'ready' }); + assert.ok(game.ready.includes(f.target)); + game = bind(f, 'yes', 3, f.recipient, game).game; + assert.ok(!game.ready.includes(f.target)); + const before = structuredClone(game); + assert.throws(() => act(game, f.target, { type: 'ready' }), /Inflation Double/); + assert.deepEqual(game, before); + const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); + game = act(game, f.target, guide.action); + game = act(game, f.target, { type: 'ready' }); + assert.ok(game.ready.includes(f.target)); + assert.equal(promise(game, f.target, f.recipient).fulfilled, true); + assert.deepEqual(inventory(game), f.originalCards); +}); + void test('native Truth bribe owner current-turn and guided event checks retain out-of-turn legal payment', () => { const f = fixture(), declared = declare(f); rejected(declared.game, f.opponent, f.questionAction);