diff --git a/components/bribe-claim-fields.tsx b/components/bribe-claim-fields.tsx new file mode 100644 index 00000000..e97bdced --- /dev/null +++ b/components/bribe-claim-fields.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { parseBribeClaim, type BribeClaim } from '@/game/bribe-promises'; +import { Input } from './ui/input'; + +type Recipient = { id: string; name: string }; +export function bribeClaimInputError(value: unknown, recipients?: readonly Recipient[]): string | null { + try { + const claim = parseBribeClaim(value); + return recipients && !recipients.some(recipient => recipient.id === claim.recipient) + ? 'Choose one of the current bribe recipients.' : null; + } catch (error) { return error instanceof Error ? error.message : 'Choose a recipient and a positive safe whole minimum.'; } +} + +/** The parent supplies this turn and its current public recipient choices. */ +export function BribeClaimFields(props: { + value: BribeClaim; + onChange: (claim: BribeClaim) => void; + recipients: readonly Recipient[]; + disabled?: boolean; +}) { + const error = bribeClaimInputError(props.value, props.recipients); + const change = (value: unknown) => { + if (!props.disabled && !bribeClaimInputError(value, props.recipients)) props.onChange(parseBribeClaim(value)); + }; + return
+ Future ordinary bribe payments +

Turn {props.value.turn}

+ + + + change({ ...props.value, minimum: event.currentTarget.valueAsNumber })} /> + {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. +

+
; +} diff --git a/components/bribe-promises.tsx b/components/bribe-promises.tsx new file mode 100644 index 00000000..f54d8b76 --- /dev/null +++ b/components/bribe-promises.tsx @@ -0,0 +1,65 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { bribeAction } from '@/game/bribe-options'; +import { bribeClaimText, bribePromiseModeSupported, liveBribePromises } from '@/game/bribe-promises'; +import { Button } from './ui/button'; + +type BribePromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void }; + +/** Native timing/funding remains in the current projected bribe options. An + * ordinary bribe may be made outside this sender's phase action turn. */ +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 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 && + 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) && + !!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) && + !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 ; +} + +export function BribePromises(props: BribePromiseProps) { + const own = props.game.bribePromises.filter(promise => promise.player === props.game.me); + if (!own.length) return null; + const name = (id: string) => props.game.players.find(player => player.id === id)?.name ?? 'the named recipient'; + return
+

Your bribe promises

+ +

+ Yes requires reaching the cumulative minimum while it remains possible; preserve the needed spendable spice. + No forbids reaching that minimum during the rest of this turn. Smaller payments to the named recipient add together. + Prior payments, gifts and ally aid do not count. +

+

+ The sender pays immediately. The recipient can spend the payment only at 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/game-table.tsx b/components/game-table.tsx index f38f48e9..a6d623ec 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -24,6 +24,7 @@ import { liveShipmentPromises, } from '@/game/shipment-promises'; import { ShipmentPromises } from './shipment-promises'; +import { BribePromises } from './bribe-promises'; import { RevivalPromises } from './revival-promises'; import { BidPromises } from './bid-promises'; import { HomeworldShipment } from './homeworld-shipment'; @@ -2032,6 +2033,7 @@ export function GameTable({ {g.status === 'playing' && g.phase < 5 && ( )} + {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } {g.guildBetrayalReaction ? ( diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index f75085b8..abf7dca8 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -1,5 +1,7 @@ 'use client'; import { MovementClaimFields } from './movement-claim-fields'; +import { BribeClaimFields, bribeClaimInputError } from './bribe-claim-fields'; +import type { BribeClaim } from '@/game/bribe-promises'; import { RevivalClaimFields, revivalClaimInputError } from './revival-claim-fields'; import type { RevivalClaim } from '@/game/revival-promises'; import { BidClaimFields, bidClaimInputError } from './bid-claim-fields'; @@ -57,8 +59,12 @@ export function Truthtrance({ g.players.find((p) => p.id !== me.id)!.id, ); const [kind, setKind] = useState< - 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' + 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' >('fact'); + const [bribeSelection, setBribeSelection] = useState>({ recipient: g.me, minimum: 3 }); + const bribeClaim: BribeClaim = { turn: g.turn, ...bribeSelection }; + const bribeRespondent = g.players.find(player => player.id === target); + const bribeRecipients = g.players.filter(player => player.id !== target && player.id !== bribeRespondent?.ally); const [revivalSelection, setRevivalSelection] = useState>({ counter: 'total', minimum: 1 }); const revivalClaim: RevivalClaim = { turn: g.turn, ...revivalSelection }; const [bidMinimum, setBidMinimum] = useState(1); @@ -102,6 +108,7 @@ export function Truthtrance({ } : kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' } : kind === 'movement' ? { kind, target, ...movement } + : kind === 'bribe' ? { kind, target, ...bribeClaim } : kind === 'revival' ? { kind, target, ...revivalClaim } : kind === 'bid' ? { kind, target, ...bidClaim } : kind === 'shipment' @@ -175,7 +182,7 @@ export function Truthtrance({

{w.question && (
-

{truthQuestionText(w.question, leaderName, locationName)}

+

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

Asked of {name(w.question.target)}

)} @@ -211,6 +218,13 @@ 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.

+ {(g.truthBribeAnswers ?? []).map(answer =>
+ {button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })} +
)} + ) : w.question!.kind === 'revival' ? ( <>

Yes requires the chosen total of future ordinary force returns while possible. No forbids reaching that total, including split requests. Only completed returns after this answer count. Keep the original revival funding and finish all remaining Yes answers before ending Revival.

@@ -342,6 +356,7 @@ export function Truthtrance({ Verified current facts + @@ -359,14 +374,22 @@ export function Truthtrance({ )} - {kind === 'revival' ? ( + {kind === 'bribe' ? ( + <> + 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.

} +

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

+ + ) : kind === 'revival' ? ( <> setRevivalSelection({ counter: claim.counter, minimum: claim.minimum })} /> {g.phase < 4 &&

Revival comes later this turn. Keep its required funding; actual Charity or an owned auction payment may prepare the return. Unknown future draws, gifts and casualties are not assumed.

}

Count physical total, normal or elite counters returned by ordinary native Revival after the answer. Smaller requests add together. Earlier returns, Ghola, Emperor-funded extras and other special returns do not count. Current native limits, prices and response choices remain.

{!g.truthRevivalTargets.includes(target) &&

Choose a respondent whose native Revival opportunity remains this turn and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported first.

} -

{truthQuestionText(question, leaderName, locationName)}

+

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

) : kind === 'bid' ? ( <> @@ -374,13 +397,13 @@ export function Truthtrance({ onChange={claim => setBidMinimum(claim.minimum)} />

One accepted bid from now until this lot closes. Earlier bids do not count. A larger opposing bid may make Yes impossible; your own pass, lower bid or voluntary spending cannot evade it.

{!g.truthBidTargets.includes(target) &&

Choose a bidder with an unused opportunity in the current ordinary lot and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported; other auctions and module compositions remain unfinished.

} -

{truthQuestionText(question, leaderName, locationName)}

+

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

) : kind === 'traitorCall' ? ( <>

Choose a still-unsubmitted voter in the current revealed battle. A definite answer binds that player’s original declaration, without reopening any sealed vote or promising that a later response will allow it.

{!g.truthTraitorTargets.includes(target) &&

Reveal both plans and choose a voter who has not submitted their Traitor decision.

} - {g.battle?.revealed &&

{truthQuestionText(question, leaderName, locationName)}

} + {g.battle?.revealed &&

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

} ) : kind === 'movement' ? ( <> @@ -390,7 +413,7 @@ export function Truthtrance({ {g.advanced &&

Advanced movement counts physical forces, including each elite once. Hajr remains available only immediately after a completed movement and its required arrival decisions.

}

An owned No-Field may prepare a route through its normal shipment, movement or voluntary reveal. The concealed marker does not count toward the promised physical minimum; revelation uses only available reserves.

{!g.truthMovementTargets.includes(target) &&

Choose a supported Basic or Advanced respondent with declared native families and an unused turn this round, and finish pending decisions. Optional modules remain unfinished.

} -

{truthQuestionText(question, leaderName, locationName)}

+

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

) : kind === 'shipment' || kind === 'mixedShipment' ? ( @@ -424,7 +447,7 @@ export function Truthtrance({

)} {kind === 'mixedShipment' &&

- {invalidMixed ?? truthQuestionText(question, leaderName, locationName)} + {invalidMixed ?? truthQuestionText(question, leaderName, locationName, name)}

} ) : kind === 'battlePlan' ? ( @@ -473,6 +496,7 @@ export function Truthtrance({ 'Ask publicly', { type: 'truthAsk', question }, (kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) || + (kind === 'bribe' && (!!bribeClaimInputError(bribeClaim, bribeRecipients) || !g.truthBribeTargets.includes(target))) || (kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) || (kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) || (kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) || @@ -495,8 +519,8 @@ export function TruthHistory({ game: g }: { game: GameView }) {
Truthtrance answers · Turn {g.turn}

- Structured battle-plan, ordinary-bid and supported reserve-shipment answers are - enforced. Current-turn freeform promises remain binding when possible, + Supported battle-plan, bid, revival, bribe, movement and reserve-shipment + answers are enforced. Current-turn freeform promises remain binding when possible, but their automatic enforcement is unfinished.

    @@ -509,6 +533,7 @@ export function TruthHistory({ game: g }: { game: GameView }) { ? 'Cheap Hero / Heroine' : (g.allLeaders.find((l) => l.id === id)?.name ?? id), id => g.combatLocations.find(place => place.id === id)!.name, + name, )}

    diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 1ed678f6..7854ee6c 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,45 @@ open mode gates. ## Current checkpoint and work +**Truthtrance / ordinary bribe commitments — 26 October. Prototyped / Partial.** +Registered bare native Basic/Advanced tables now support funded current-turn +cumulative ordinary payments to one named non-allied recipient. Actual payer +payments after the answer record progress; split payments add together and +original receipts remain deferred to Mentat. Joint per-recipient Yes/No, +current liabilities, earlier obligations, Collection deadline/reopening and +out-of-turn controls/minimal AI are connected. Timing/current-funding and +accepted-order release are explicit provisional compositions; optional modules, +future preparation and broader deals remain due. No start gate opens. + +Root shared integration plus disjoint pure/native/control lanes and bounded +quote review passes **15 files,171/171**, types/lint/build and **five native +paths**,21 GameTable SSR renders/five actual payment callbacks. Original H/A/E +Basic20/Advanced23-action sources add21 steps: E pays3 from10 while H is the +active bidder, H's spendable cash stays unchanged, and actual Mentat later +credits exactly3. Advanced Collection2 stays separate. Four minimal policies +per source submit legal out-of-turn payments; all33 original cards remain. + +A20+23-action No3 path accepts1+1, rejects crossing3, collects2 at actual Mentat +and permits3 after the real next-turn expiry. A29+12 Collection source requires +payment before Ready. An Advanced23+24 two-original-Truth source reserves and +pays H3/A2 from one purse and later credits each exact recipient. Nineteen native, +ten pure and six control cases also retain prior-payment exclusion, recipient +isolation, joint bid/revival/shipment funding, exact events and actual already- +ready Collection reopening. No played hand, purse, clock or promise is installed. +Controlled UI projections retain their labels; no alliance-history campaign. + +Feedback repairs an inherited preparation-extraction regression: actual phase5 +shipment quotes now retain their already active Karama rate and original +allowances instead of entering future phase resets. Existing Karama/ally-escrow +cases pass. Detached accepted-bid quotes now satisfy their own planned Bid Yes +before nested bribe compatibility; the real declaration remains authoritative. +Frozen v2 report/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-bribe-proof-v2/`; +journeys/HTML: adjacent `20261026-truth-bribe-runtime/`. +No saved-room/D1/server/reset/restart or deployment operation is performed. +Browser/mobile/deployed, broader compositions and complete rules remain open. +[Source and boundaries](TRUTHTRANCE_BRIBE_PROMISES.md). + **Truthtrance / earlier-turn native Revival promises — 26 October. Prototyped / Partial.** Structured ordinary-return questions now work in quiet phases0–4, retaining current Tanks/elite usage and projecting the real future normal quota reset. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 70668d5c..3da4b515 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,45 @@ # Dune implementation status +## 26 October 2026 — Truthtrance ordinary bribe-payment promises + +**Prototyped / Partial.** Funded ordinary bribe questions now bind cumulative +future payer payments to a named non-allied recipient during this turn. Actual +accepted payments count; recipient collection remains deferred to original +Mentat. Split payments, overlapping same-recipient Yes, distinct-recipient cash +reserves and No caps are connected. Public named questions, target-only answers, +persistent progress and guarded out-of-turn guides use native packets. Current +unpaid bid/sealed-battle liabilities and earlier typed obligations retain funds; +new supported answers preserve earlier Bribe Yes. Own spending, alliance offers +and Collection Ready cannot evade Yes; binding after Ready reopens that payer. +Current-funding/timing/release meaning remains provisional. Optional modules, +future preparation and broader deals remain due; no start/publication gate opens. + +Root integration and disjoint pure/native/control lanes with bounded quote +review pass **15 files,171/171**, types/lint/build and **five native paths**, +21 GameTable SSR renders/five actual payment callbacks. Original public H/A/E +Basic20/Advanced23 prefixes add21 steps: out-of-turn E pays3 from10, H cash stays +unchanged, then actual Mentat credits exactly3. Advanced city Collection2 stays +separate; four minimal policies per source submit a legal payment. All33 cards +remain. A20+23 No3 path pays1+1, rejects the threshold, credits2 and permits a new3 +after real expiry. A29+12 Collection source retains the Ready deadline. An +Advanced23+24 two-original-Truth source reserves H3/A2, pays both and later +credits each exact nonally. Nineteen native/ten pure/six control cases also retain +prior/recipient isolation, current and future funding, exact events and actual +already-ready Collection reopening. Native sources install no played hand, +purse, clock or promise; controlled UI staging is explicit. + +Feedback repairs an inherited actual-phase5 preparation fallthrough that cleared +an active Karama shipping rate; direct Movement quotes now retain their native +rate/allowances. Existing active-Karama/ally-escrow cases pass. Planned accepted +bids satisfy their own detached Bid Yes before nested bribe checks, avoiding a +false second-bid requirement while real fulfillment remains native acceptance. +Frozen v2 proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-bribe-proof-v2/`; +journeys/HTML: adjacent `20261026-truth-bribe-runtime/`. +No saved-room/D1/server/reset/restart or deployment operation is performed. +Broader rules, browser/mobile/deployed and final assurance remain open. +[Contract](TRUTHTRANCE_BRIBE_PROMISES.md). + ## 26 October 2026 — Earlier-turn native Revival promises **Prototyped / Partial.** Ordinary cumulative physical return questions now diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index e65ba1df..eacacba2 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,23 @@ # Rule decision index +## Truthtrance ordinary bribe-payment commitments — 26 October 2026 + +Apply general current-turn Truthtrance authority (FAQp8; supplied Advanced +physical39) to cumulative future ordinary payments to one named non-allied +recipient. Supplied physical16 retains payer transfer and deferred Mentat +collection; physical29 prohibits spice bribes under Inflation Double. Under +standing14October choose-recommended authority, count accepted real payer +payments after each answer, across split payments, retaining original receipts. +Earlier payments, gifts and allied aid do not satisfy this ordinary-bribe claim. + +This exact-recipient/cumulative/current-funding protocol is provisional, not a +dedicated publisher Truthtrance-bribe ruling. Current unpaid bids and sealed +battle support stay reserved; earlier commitments remain compatible. The first +prototype uses the existing software window0–7 and excludes optional modules, +forecast income/withdrawal/card preparations and broader deals. The inherited +Mentat timing restriction is not newly claimed as publisher source text. No +start or publication gate opens. [Contract](TRUTHTRANCE_BRIBE_PROMISES.md). + ## Earlier-turn native Revival promises — 26 October 2026 Extend the existing general current-turn Truthtrance composition from phase4 diff --git a/docs/TRUTHTRANCE_BRIBE_PROMISES.md b/docs/TRUTHTRANCE_BRIBE_PROMISES.md new file mode 100644 index 00000000..98f2f57f --- /dev/null +++ b/docs/TRUTHTRANCE_BRIBE_PROMISES.md @@ -0,0 +1,105 @@ +# Truthtrance ordinary bribe-payment promises + +## 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 +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. + +## Authority and provisional composition + +General current-turn question/compliance/impossibility authority is recorded in +the [November2020 FAQp8 source audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) +and the [ordinary-bid contract](TRUTHTRANCE_BID_PROMISES.md). The authorized +Advanced source physical39 replacement Truthtrance text was read for the prior +slices. For this slice, physical16 BRIBERY and physical29 Inflation were read: +non-allied payment and deferred collection at Mentat remain; Double prohibits +spice bribes. The local supplied source remains bound by +[the authority amendment](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026). + +Under standing14October choose-recommended priority, explicitly ask about +**cumulative future ordinary payer payments** to one named recipient. Only real +accepted bribe payments after the answer count. Smaller payments add together. +Prior payments, Emperor gifts, allied aid and unrelated payments do not count. +Yes requires the matching minimum while possible. No keeps the entire later +matching total below that minimum. Same-recipient Yes deficits overlap; distinct +recipient Yes deficits reserve separate spice. Several answers must fit one +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. +No can be fixed while Inflation shows Double; Yes needs a legal funded payment. +Wider timing/source reconciliation remains part of complete rules acceptance. + +## Connected mechanics and controls + +The question packet is `{kind:'bribe',target,turn,recipient,minimum}` within the +original `truthAsk`; minimum is a positive safe integer. A real definite answer +retains the original declaration, priority, public reply, one physical discard +and interrupted phase continuation. A distinct event starts progress0. Public +text names the recipient. Only the respondent receives feasible answer choices, +its own promise records and its own next ordinary-payment guide. + +The original native bribe action owns payer debit and the original deferred +recipient receipt. The promise records payer progress once; Mentat collection +does not pay or fulfill it again. Current unpaid bids and sealed battle support +stay reserved. Private compatibility quotations complete their own planned +payments before checking earlier battle, bid, revival, shipment and movement +obligations, without creating live receipts or spendable recipient cash. +An earlier bribe Yes also constrains a later supported typed answer. + +The persistent panel shows named cumulative progress and fulfilled/released +status. Its guarded native bribe callback may act out of turn in a quiet legal +window. Current ordinary payment limits enforce each No cap and preserve funds +for other Yes answers; no separate legality calculation is installed in the UI. +Stale, wrong-owner or modified guided packets reject. Existing four minimal AI +profiles use feasible answers and the exact native guide. Strategy and difficulty +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; +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 + +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`. +Affected feedback also repaired an inherited preparation-extraction regression: +a real phase5 Shipment/Movement quotation now bypasses future Revival/Movement +resets and retains its already active Karama rate and original allowances. The +existing active-Karama/ally-escrow cases exercise that boundary. +Frozen v2 proof passes **15files/171cases**, `npm run check:quick` and +`npm run build`. Five native paths produce21 GameTable SSR renders and exercise +five actual hook-free payment callbacks. Original H/A/E Basic20/Advanced23 +prefixes add21 steps: E pays3 from10 out of turn while H owns the bid turn, H cash stays +unchanged, and real Mentat credits3. Advanced Collection2 remains separate. +Four minimal policies per source submit legal payments; all33 cards remain. + +A20+23 No3 path pays1+1, rejects crossing3, credits2 and permits3 after the real +next-turn expiry. A29+12 Collection source retains the Ready deadline. An +Advanced23+24 two-original-Truth path reserves/pays H3/A2 from one purse, then +credits each exact recipient. Nineteen native,10pure and6control cases also +cover prior/recipient isolation, two-card constraints, current and future funding, +exact events and real already-ready Collection reopening. Detached accepted-bid +quotes fulfill their own planned declaration before nested bribe reconciliation; +actual bid fulfillment remains at native acceptance. No native alliance/aid +history is claimed from these sources; controlled projections are labelled. + +Report/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-bribe-proof-v2/`; +journeys/HTML: adjacent `20261026-truth-bribe-runtime/`. +No played hand, purse, clock or promise is installed in native sources. +SSR/callback evidence does not prove DOM/hydration or deployed acceptance. +Original alliance/aid histories, optional-module compositions, future preparation +and broader deals remain due. No full faction/mode, preservation/privacy/custody, +browser/mobile or deployed acceptance is certified. No saved game, database, +server or deployment is changed by this prototype. diff --git a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md index fc42e582..b07c5fa3 100644 --- a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md +++ b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md @@ -1,5 +1,10 @@ # Truthtrance nonbattle readiness and first implementation slice +The [ordinary-bribe follow-up](TRUTHTRANCE_BRIBE_PROMISES.md) adds a funded +current-turn named-recipient payment commitment. Its cumulative payer progress +and original deferred receipt supersede the historical bribe absence entry; +future preparations and broader negotiated deals remain required. + The [26 October ordinary-bid follow-up](TRUTHTRANCE_BID_PROMISES.md) adds a structured current-lot bid commitment. The historical absence table below is superseded for that slice, as well as the previously linked fact, shipment and diff --git a/game/bots.ts b/game/bots.ts index cbe43a5e..cf63bcb2 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1307,6 +1307,7 @@ function policyActions(g: GameView): Action[] { ? [{ type: 'decision', event: search.event, card: ordered[0].id }] : []; } + if (g.bribePromiseGuide) return [g.bribePromiseGuide.action]; if (g.revivalPromiseGuide) return [g.revivalPromiseGuide.action]; if (g.bidPromiseGuide) return [g.bidPromiseGuide.action]; if (g.truthtrance) { @@ -1315,6 +1316,10 @@ function policyActions(g: GameView): Action[] { return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }]; if (w.stage === 'answer') { if (w.question?.target !== me.id) return []; + if (w.question.kind === 'bribe') { + const answer = g.truthBribeAnswers?.[0]; + return answer ? [{ type: 'truthAnswer', answer }] : []; + } if (w.question.kind === 'revival') { const answer = g.truthRevivalAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; diff --git a/game/bribe-promises.ts b/game/bribe-promises.ts new file mode 100644 index 00000000..20e4543d --- /dev/null +++ b/game/bribe-promises.ts @@ -0,0 +1,134 @@ +import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope'; + +export type BribeClaim = { turn: number; recipient: string; minimum: number }; +export type BribePromise = BribeClaim & { + event: string; + player: string; + asker: string; + answer: boolean; + /** Matching ordinary payments after this answer, before their later receipt. */ + progress: number; + fulfilled?: boolean; + released?: boolean; +}; +type BribePayment = { recipient: string; amount: number }; + +export class BribePromiseError extends Error { + constructor(message: string) { + super(message); + this.name = 'BribePromiseError'; + } +} + +const identifier = (value: unknown): value is string => + typeof value === 'string' && value.length > 0 && value === value.trim(); +const positiveInteger = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value > 0; +const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean'; + +export function parseBribeClaim(value: unknown): BribeClaim { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new BribePromiseError('Choose a current-turn ordinary bribe question.'); + const input = value as Record; + if (!positiveInteger(input.turn) || !identifier(input.recipient) || !positiveInteger(input.minimum)) + throw new BribePromiseError('Choose the current turn, exact recipient and a positive safe whole bribe minimum.'); + return { turn: input.turn, recipient: input.recipient, minimum: input.minimum }; +} + +/** Permission for the existing bare native context, not a game-start gate. + * The engine retains nonally targets, timing and ordinary payment authority. */ +export function bribePromiseModeSupported(game: NativePromiseScope & { + semutaPreview?: unknown; kullPreview?: unknown; nexusKullPreview?: unknown; + richeseBetrayalPreview?: unknown; +}): boolean { + return nativePromiseScopeSupported(game) && !game.semutaPreview && !game.kullPreview && + !game.nexusKullPreview && !game.richeseBetrayalPreview; +} + +export function bribeClaimText(claim: BribeClaim, recipientName: (id: string) => string = id => id): string { + return `pay at least ${claim.minimum} spice to ${recipientName(claim.recipient)} in ordinary bribes from now until this turn ends`; +} + +export function liveBribePromises(promises: readonly BribePromise[], player: string, turn: number): BribePromise[] { + return promises.filter(promise => promise.player === player && promise.turn === turn && + !promise.fulfilled && !promise.released); +} + +function validateProgress(promise: BribePromise): void { + parseBribeClaim(promise); + if (typeof promise.answer !== 'boolean' || !Number.isSafeInteger(promise.progress) || promise.progress < 0 || + !optionalBoolean(promise.fulfilled) || !optionalBoolean(promise.released) || + (promise.fulfilled && promise.released) || + (!promise.answer && promise.progress >= promise.minimum) || + (promise.answer && !promise.released && !!promise.fulfilled !== (promise.progress >= promise.minimum))) + throw new BribePromiseError('The bribe commitment has invalid cumulative answer progress.'); +} + +/** Provisional cumulative composition: only the actual payer's completed + * ordinary payments count. Prior payments, card effects, gifts and the later + * Mentat receipt do not call this function or add progress again. */ +export function recordPromisedBribe(promises: readonly BribePromise[], player: string, turn: number, + payment: BribePayment): BribePromise[] { + if (!payment || typeof payment !== 'object' || Array.isArray(payment) || + !identifier(payment.recipient) || !positiveInteger(payment.amount)) + throw new BribePromiseError('Retain the exact recipient and positive safe whole ordinary bribe payment.'); + const progress = new Map(); + for (const promise of liveBribePromises(promises, player, turn)) { + if (promise.recipient !== payment.recipient) continue; + validateProgress(promise); + const next = promise.progress + payment.amount; + if (!Number.isSafeInteger(next)) + throw new BribePromiseError('The cumulative bribe amount exceeds the safe integer range.'); + if (!promise.answer && next >= promise.minimum) + throw new BribePromiseError('This payment conflicts with your cumulative Truthtrance No answer.'); + progress.set(promise, next); + } + return promises.map(promise => { + const next = progress.get(promise); + return next === undefined ? promise : { + ...promise, progress: next, + ...(promise.answer && next >= promise.minimum ? { fulfilled: true } : {}), + }; + }); +} + +/** Minimal final additional ordinary payments, grouped by exact recipient. + * Overlapping Yes answers share the same future payment; different recipients + * each require their own spice. No caps remain binding after smaller payments. */ +export function bribePromiseRequirements(promises: readonly BribePromise[], player: string, turn: number): BribePayment[] | null { + const live = liveBribePromises(promises, player, turn); + const required = new Map(); + for (const promise of live) { + validateProgress(promise); + if (promise.answer) + required.set(promise.recipient, Math.max(required.get(promise.recipient) ?? 0, promise.minimum - promise.progress)); + } + for (const promise of live) { + const amount = required.get(promise.recipient) ?? 0; + if (!promise.answer && amount >= promise.minimum - promise.progress) return null; + if (!Number.isSafeInteger(promise.progress + amount)) return null; + } + let total = 0; + const payments: BribePayment[] = []; + for (const [recipient, amount] of required) { + total += amount; + if (!Number.isSafeInteger(total)) return null; + if (amount > 0) payments.push({ recipient, amount }); + } + return payments; +} + +export function validateBribePromises(promises: readonly BribePromise[], players: readonly string[]): void { + if (!Array.isArray(promises)) throw new BribePromiseError('The bribe commitment record is invalid.'); + const events = new Set(); + for (const promise of promises) { + parseBribeClaim(promise); + if (!identifier(promise.event) || events.has(promise.event) || + !identifier(promise.player) || !identifier(promise.asker) || + !players.includes(promise.player) || !players.includes(promise.asker) || !players.includes(promise.recipient) || + promise.player === promise.asker || promise.player === promise.recipient) + throw new BribePromiseError('The bribe commitment lost its original event or participants.'); + validateProgress(promise); + events.add(promise.event); + } +} diff --git a/game/engine.ts b/game/engine.ts index 0ea7436c..096b2332 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,5 @@ +import { BribePromiseError, bribePromiseModeSupported, liveBribePromises, recordPromisedBribe, + bribePromiseRequirements, validateBribePromises, type BribeClaim, type BribePromise } from './bribe-promises'; import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income'; import { createRevivalOrder, validateRevivalOrder, revivalOrderBlock, startRevivalOrder, saphoRevivalOrderEvent, reorderSaphoRevivalOrder, @@ -2139,6 +2141,7 @@ export type Game = { shipmentPromises?: ShipmentPromise[]; bidPromises?: BidPromise[]; revivalPromises?: RevivalPromise[]; + bribePromises?: BribePromise[]; movementPromises?: MovementPromise[]; /** Server-only certainty of movement-relevant cards still in the discard. */ movementDiscardKnowledge?: MovementDiscardKnowledge; @@ -14892,6 +14895,120 @@ function pledgeAid(g: Game, p: Player, requested: unknown) { g.aid[p.id] = { recipient: p.ally, amount }; log(g, `${p.name} updated the spice available to their ally this phase.`); } +function bribePromiseRule(calculate: () => T): T { + try { return calculate(); } + catch (error) { if (error instanceof BribePromiseError) throw new RuleError(error.message); throw error; } +} +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); +} +function bribeQuestionWindow(g: Game): boolean { + // A current Double side can fix No without authorizing any payment. Every + // original pending-decision guard still applies to the structured question. + return bribePromiseTurn(g) && !bribeTimingBlock({ ...g, truthtrance: null, + inflation: g.inflation?.side === 'double' ? null : g.inflation }); +} +/** First funded native prototype: reserve a jointly legal payment now, rather + * than inventing future income or moving incoming bribes before Mentat. */ +function bribePromiseCompletion(g: Game, p: Player, promises = g.bribePromises ?? []): Action[] | null { + if (!bribePromiseTurn(g)) return null; + const required = bribePromiseRequirements(promises, p.id, g.turn); + if (!required) return null; + 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 (total > uncommittedSpice(trial, actor)) return null; + trial.bribePromises = structuredClone(promises); + for (const payment of required) { + if (payment.recipient === p.id || payment.recipient === p.ally || + !g.players.some(player => player.id === payment.recipient)) return null; + trial.bribePromises = bribePromiseRule(() => recordPromisedBribe(trial.bribePromises ?? [], p.id, + g.turn, payment)); + } + actor.spice -= total; + // This is a private compatibility quotation. It creates neither a payment + // receipt nor recipient cash; the real native action owns both operations. + try { + 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 })); + } catch (error) { if (error instanceof RuleError) return null; throw error; } +} +function bribeCompatibleLaterPromises(g: Game, p: Player, + later: Pick, 'shipmentPromises' | 'movementPromises' | 'revivalPromises' | 'battle'>): boolean { + if (!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) return true; + return !!bribePromiseCompletion({ ...g, ...later }, p); +} +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 ?? []), + { ...claim, event: 'candidate', player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host, + answer: answer === 'yes', progress: 0 }])); +} +function bindBribeTruth(g: Game, p: Player, claim: BribeClaim, answer: boolean, asker: string) { + 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)) { + 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.`); + } +} +function reconcileBribePromises(g: Game, voluntary?: { actor: string; action: Action }) { + if (!g.bribePromises?.some(promise => !promise.fulfilled && !promise.released)) return; + bribePromiseIntegrity(g); + if (g.truthtrance) return; + const kept: BribePromise[] = []; + const no = g.bribePromises.filter(promise => !promise.answer && !promise.fulfilled && !promise.released); + const pending = g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard || + g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal; + if (pending && voluntary) { + const required = bribePromiseRequirements(g.bribePromises, voluntary.actor, g.turn); + requireRule(required && required.reduce((sum, payment) => sum + payment.amount, 0) <= + Math.max(0, uncommittedSpice(g, getPlayer(g, voluntary.actor))), + 'Keep the current spice needed by your Truthtrance bribe answer.'); + } + if (pending) return; + for (const promise of g.bribePromises) { + if (promise.fulfilled || promise.released) continue; + const same = bribePromiseTurn(g) && promise.turn === g.turn; + if (!promise.answer) { if (!same) promise.fulfilled = true; continue; } + if (!same || !bribePromiseCompletion(g, getPlayer(g, promise.player), [...no, ...kept, promise])) { + requireRule(voluntary?.actor !== promise.player, + 'You cannot voluntarily evade your Truthtrance bribe answer.'); + promise.released = true; + log(g, `${getPlayer(g, promise.player).name} can no longer fulfill the Truthtrance bribe answer; that answer is no longer binding.`); + } else kept.push(promise); + } +} +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 promise = liveBribePromises(g.bribePromises ?? [], player, g.turn) + .find(promise => promise.answer && promise.recipient === action.target); + return promise ? { action: { ...action, bribePromiseEvent: promise.event } } : null; +} +function bribePaymentPreservesPromises(g: Game, p: Player, recipient: string, amount: number): boolean { + const trial = structuredClone(g), actor = getPlayer(trial, p.id); + try { + trial.bribePromises = recordPromisedBribe(trial.bribePromises ?? [], p.id, g.turn, { recipient, amount }); + actor.spice -= amount; + return !!bribePromiseCompletion(trial, actor); + } catch (error) { if (error instanceof BribePromiseError || error instanceof RuleError) return false; throw error; } +} function projectedBribes(g: Game, p: Player): BribeOptions { const available = Math.max(0, uncommittedSpice(g, p)); const blocked = bribeTimingBlock(g); @@ -14899,9 +15016,11 @@ function projectedBribes(g: Game, p: Player): BribeOptions { promise.player === p.id && !promise.released && !g.battle?.revealed && !g.battle?.plans[p.id]); const shipmentPromises = liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length > 0; const movementPromises = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer); + const revivalPromises = liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer); + const bribePromises = liveBribePromises(g.bribePromises ?? [], p.id, g.turn).length > 0; // Reuse the authoritative promise checks on an isolated resource-only copy. // No payment, receipt or live continuation is created by this projection. - const maximum = !blocked && (battlePromises || shipmentPromises || movementPromises) + const maximum = !blocked && (battlePromises || shipmentPromises || movementPromises || revivalPromises) ? maximumBribe(available, amount => { const probe = structuredClone(g); getPlayer(probe, p.id).spice -= amount; @@ -14909,6 +15028,7 @@ function projectedBribes(g: Game, p: Player): BribeOptions { if (battlePromises) reconcileBattlePromises(probe, { actor: p.id, action: { type: 'bribe' } }); if (shipmentPromises) reconcileShipmentPromises(probe, { actor: p.id, action: { type: 'bribe' } }); if (movementPromises && !groundMovementCompletion(probe, p.id)) return false; + if (revivalPromises) reconcileRevivalPromises(probe, { actor: p.id, action: { type: 'bribe' } }); return true; } catch (error) { if (error instanceof RuleError) return false; @@ -14920,8 +15040,11 @@ function projectedBribes(g: Game, p: Player): BribeOptions { available, incoming: p.bribes, blocked, targets: g.players.filter(target => target.id !== p.id && target.id !== p.ally).map(target => { let limit = maximum; + if (!blocked && bribePromises) limit = maximumBribe(limit, + amount => bribePaymentPreservesPromises(g, p, target.id, amount)); let limitation: string | null = maximum < available ? 'Some spice must remain available to fulfill your Truthtrance answer.' : null; + if (limit < maximum) limitation = 'Your Truthtrance bribe answers limit this recipient’s payment.'; if (!blocked && limit >= 5) { try { checkBureaucratContributors(g, target.id, [{ payer: p.id, amount: limit }]); } catch (error) { @@ -15125,15 +15248,20 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? [] const action: Action = { type: 'bid', ...normalBidQuote(g, p, range.minimum) }; if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length || liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length || - liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length) { + liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length || + liveBribePromises(g.bribePromises ?? [], p.id, g.turn).length) { const trial = structuredClone(g); trial.truthtrance = null; trial.auction!.bid = action.amount as number; trial.auction!.bidder = p.id; trial.auction!.allyPayment = action.allyPayment as number; + // The detached accepted declaration satisfies its own Bid Yes before + // another promise checks the remaining purse; it is not a second bid. + trial.bidPromises = recordPromisedBid(promises, p.id, opportunity, action.amount as number); reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase }); reconcileGroundMovementPromises(trial, p.id, action, g.phase); reconcileRevivalPromises(trial, { actor: p.id, action }); + reconcileBribePromises(trial, { actor: p.id, action }); } return action; } @@ -18211,7 +18339,8 @@ function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAn const promise = { ...claim, turn: g.turn, player: p.id, asker: g.players.find(other => other.id !== p.id)!.id, answer }; const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; if (groundMovementCompletion(answerSource, p.id, [...live, promise]) && - revivalCompatibleLaterPromises(answerSource, p, { movementPromises: [...(g.movementPromises ?? []), promise] })) + revivalCompatibleLaterPromises(answerSource, p, { movementPromises: [...(g.movementPromises ?? []), promise] }) && + bribeCompatibleLaterPromises(answerSource, p, { movementPromises: [...(g.movementPromises ?? []), promise] })) answers.push(answer ? 'yes' : 'no'); } return answers.length ? answers : ['unknown']; @@ -26426,6 +26555,9 @@ function earlyRevivalCompletionSeeds(initial: Game, source: Game, owner: string) } function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string): ShipmentCompletionSeed[] { const seeds = earlyRevivalCompletionSeeds(initial, source, owner); + // A real Movement quote already owns its active Karama/rate and allowances. + // Only earlier phases may pass through the future Revival/Movement reset. + if (source.phase > 4) return seeds; const revivalSeeds: ShipmentCompletionSeed[] = []; for (const seed of seeds) { const g = structuredClone(seed.game); g.phase = 4; @@ -26670,7 +26802,8 @@ function shipmentTruthAnswers( const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; if (findShipmentCompletion(answerSource, p, promises) && - revivalCompatibleLaterPromises(answerSource, p, { shipmentPromises: [...(g.shipmentPromises ?? []), promises[promises.length - 1]] })) + revivalCompatibleLaterPromises(answerSource, p, { shipmentPromises: [...(g.shipmentPromises ?? []), promises[promises.length - 1]] }) && + bribeCompatibleLaterPromises(answerSource, p, { shipmentPromises: [...(g.shipmentPromises ?? []), promises[promises.length - 1]] })) answers.push(answer ? 'yes' : 'no'); } return answers.length ? answers : ['unknown']; @@ -26803,7 +26936,8 @@ function battleTruthAnswers( if ( findReachableBattlePlan(g, p, { promises: [...promises, { player: p.id, asker: '', claim, answer }], - }) + }) && bribeCompatibleLaterPromises(g, p, { battle: g.battle ? { ...g.battle, + truthPromises: [...promises, { player: p.id, asker: '', claim, answer }] } : null }) ) answers.push(answer ? 'yes' : 'no'); return answers.length ? answers : ['unknown']; @@ -29914,7 +30048,8 @@ function revivalTruthAnswers(g: Game, p: Player, claim: RevivalClaim): ('yes' | return (['yes', 'no'] as const).filter(answer => { const promises = [...(g.revivalPromises ?? []), { ...claim, event: 'candidate', player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: 0 }]; - if (!revivalPromiseCompletion(g, p, promises)) return false; + if (!revivalPromiseCompletion(g, p, promises) || + !bribeCompatibleLaterPromises(g, p, { revivalPromises: promises })) return false; const lot = bidOpportunity(g); // An earlier Bid Yes reserves a future declaration even before it becomes // the actual leading invoice. Check that declaration with this candidate. @@ -34810,6 +34945,7 @@ function normalizeCardNames(g: Game) { export function applyAction(state: Game, id: string, action: Action): Game { bidPromiseIntegrity(state); revivalPromiseIntegrity(state); + bribePromiseIntegrity(state); revivalOrderIntegrity(state); state = migrateAtomicsTargets(state); state = migrateFremenNexusRevival(state); @@ -34913,6 +35049,12 @@ export function applyAction(state: Game, id: string, action: Action): Game { ); return normalizeAutomaticGame(state); } + if (action.bribePromiseEvent !== undefined) { + const guide = projectedBribePromiseGuide(state, id); + requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') && + Object.keys(guide.action).every(key => action[key] === guide.action[key]), + 'This bribe commitment guide is stale or belongs to another payment.'); + } if (action.revivalPromiseEvent !== undefined) { const guide = projectedRevivalPromiseGuide(state, id); requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') && @@ -34922,6 +35064,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { const g = applyActionInner(state, id, action); reconcileBidPromises(g, { actor: id, action }); reconcileRevivalPromises(g, { actor: id, action }); + reconcileBribePromises(g, { actor: id, action }); reconcileGroundMovementPromises(g, id, action, state.phase); reconcileTraitorTruth(g, id, action); harkonnenNexusBetrayalIntegrity(g); @@ -35045,6 +35188,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { reconcileShipmentPromises(g, { actor: id, action, phase: state.phase }); reconcileBidPromises(g, { actor: id, action }); reconcileRevivalPromises(g, { actor: id, action }); + reconcileBribePromises(g, { actor: id, action }); settleAutomaticContinuations(g); observeOccupation(g); reconcileHomeworldEcazDuke(g); @@ -35247,6 +35391,7 @@ function settleAutomaticContinuations(g: Game) { reconcileShipmentPromises(g); reconcileBidPromises(g); reconcileRevivalPromises(g); + reconcileBribePromises(g); reconcileGroundMovementPromises(g); reconcileTraitorTruth(g); requireRule( @@ -35262,6 +35407,7 @@ function settleAutomaticContinuations(g: Game) { export function normalizeAutomaticGame(state: Game): Game { bidPromiseIntegrity(state); revivalPromiseIntegrity(state); + bribePromiseIntegrity(state); state = migrateAtomicsTargets(state); state = migrateFremenNexusRevival(state); harkonnenNexusBetrayalIntegrity(state); @@ -35347,6 +35493,7 @@ export function normalizeAutomaticGame(state: Game): Game { reconcileShipmentPromises(g); reconcileBidPromises(g); reconcileRevivalPromises(g); + reconcileBribePromises(g); reconcileGroundMovementPromises(g); reconcileTraitorTruth(g); settleAutomaticContinuations(g); @@ -35592,6 +35739,8 @@ function applyActionInner( bindShipment: bindShipmentTruth, movementAnswers: movementTruthAnswers, bindMovement: bindMovementTruth, + bribeAnswers: bribeTruthAnswers, + bindBribe: bindBribeTruth, revivalAnswers: revivalTruthAnswers, bindRevival: bindRevivalTruth, bidAnswers: bidTruthAnswers, @@ -35605,7 +35754,7 @@ function applyActionInner( ) return g; } catch (error) { - if (error instanceof TruthError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError) + if (error instanceof TruthError || error instanceof BribePromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError) throw new RuleError(error.message); throw error; } @@ -37905,6 +38054,8 @@ function applyActionInner( ); const n = integer(action.amount, 1, uncommittedSpice(g, p), 'Spice'); checkBureaucratContributors(g,target.id,[{payer:p.id,amount:n}]); + if (liveBribePromises(g.bribePromises ?? [], p.id, g.turn).length) + g.bribePromises = bribePromiseRule(() => recordPromisedBribe(g.bribePromises ?? [], p.id, g.turn, { recipient: target.id, amount: n })); p.spice -= n; log( g, @@ -37953,6 +38104,9 @@ function applyActionInner( if (target !== null) { other = getPlayer(g, target); requireRule(other.id !== id, 'Choose another player.'); + requireRule(!liveBribePromises(g.bribePromises ?? [], p.id, g.turn) + .some(promise => promise.answer && promise.recipient === other!.id), + 'Pay your promised ordinary bribe before offering that recipient an alliance.'); const blocked = homeworldAllianceReason(g, id, other.id); requireRule(!blocked, blocked ?? 'This alliance is unavailable.'); } @@ -38093,12 +38247,15 @@ function applyActionInner( const a = g.auction; if (t === 'bid') { const quotedBid = normalBidQuote(g, p, action.amount, action.allyPayment); - if (liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer)) { + if (liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) || + liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) { const trial = structuredClone(g); trial.auction!.bid = quotedBid.amount; trial.auction!.bidder = p.id; trial.auction!.allyPayment = quotedBid.allyPayment; + guardPromisedBid(trial, getPlayer(trial, p.id), action, quotedBid.amount); reconcileRevivalPromises(trial, { actor: p.id, action }); + reconcileBribePromises(trial, { actor: p.id, action }); } guardPromisedBid(g, p, action, quotedBid.amount); a.bid = quotedBid.amount; @@ -39331,6 +39488,8 @@ function applyActionInner( return g; } if (t === 'ready') { + if (g.phase === 7) requireRule(!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer), + 'Complete your Truthtrance bribe payments before finishing Collection.'); if (g.phase === 4) guardPromisedRevival(g, p, action); requireRule( [1, 2, 4, 7, 8].includes(g.phase) || @@ -39525,6 +39684,11 @@ export function viewGame(state: Game, id: string) { }).map(player => player.id) : [], truthBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'bid' ? bidTruthAnswers(g, me, g.truthtrance.question) : null, + truthBribeTargets: bribeQuestionWindow(g) ? g.players.map(player => player.id) : [], + truthBribeAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && + g.truthtrance.question.kind === 'bribe' ? bribeTruthAnswers(g, me, g.truthtrance.question) : null, + bribePromises: (g.bribePromises ?? []).filter(promise => promise.player === id).map(promise => ({ ...promise })), + bribePromiseGuide: projectedBribePromiseGuide(g, id), truthRevivalTargets: revivalQuestionWindow(g) ? g.players.filter(player => revivalOpportunity(g, player.id)).map(player => player.id) : [], truthRevivalAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'revival' ? revivalTruthAnswers(g, me, g.truthtrance.question) : null, diff --git a/game/reference.ts b/game/reference.ts index 8f5a9e33..b7691d2b 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -297,7 +297,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', '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.', - 'AI seats can receive bribes but do not negotiate promises yet. Discuss an agreement with human players, then use the corresponding game controls; prose deals are not automatically enforced.', + '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: [ @@ -3208,6 +3208,31 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Verification', status: 'Partial', detail: '13 files101/101, types/lint/build and six native engine/answer-callback/GameTable SSR journeys pass. Genuine26/72/77-action Basic/allied/Cunning sources earn original cards, alliances, payments, reveal and draw/return. Four profiles answer/vote legally. Unmatched-card staging is labelled; submitted-false and response/typing fixture feedback is repaired. Full future promises, combinations and final assurance remain open.', evidence: ['tests/traitor-decision-promises.test.ts', 'tests/fixture-truth-traitor-decision.ts', 'tests/fixture-truth-traitor-harkonnen.ts', 'tests/truth-traitor-decision-engine.test.ts', 'tests/truth-traitor-harkonnen-engine.test.ts', 'tests/truth-traitor-decision-controls.test.tsx', 'docs/TRUTHTRANCE_TRAITOR_DECISIONS.md'] }, ], }, + { + id: 'truthtrance-bribe', + title: 'Truthtrance: ordinary bribe-payment promises', + category: 'Cards', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Bind cumulative future ordinary spice payments to a named non-allied recipient, retaining actual funding and deferred collection.', + 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.', + '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.', + ], + 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'] }, + ], + }, { id: 'truthtrance-revival', title: 'Truthtrance: ordinary force-revival promises', @@ -3407,7 +3432,7 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Cards', coverage: 'Partial', summary: - 'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.', + 'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, ordinary bribe-payment, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.', steps: [ 'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.', 'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.', @@ -3419,6 +3444,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier Basic phases with declared native families, owned Charity/ordinary revival preparation and exact normal/native auction funding, plus active or unused waiting phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.', 'After both plans reveal, a separate current-Traitor question binds an actual unsubmitted native voter to Reveal or decline. The declaration fulfills it before later Karama/betrayal prevention. Cunning cannot voluntarily return a matching card needed by a live Yes; completed declarations retain their native later custody rights. See the current-Traitor guide for scope.', 'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.', + 'A bribe-payment question binds cumulative future ordinary spice paid to one named non-allied recipient during this turn. Only the real payer action counts; the recipient still spends its receipt at Mentat. Split payments add together, and earlier obligations retain their funding. See the ordinary-bribe guide for the funded prototype and unfinished preparations.', 'An ordinary force-revival question binds cumulative future total, normal or elite returns in native Revival later this turn, from supported phases0–4. Earlier and special returns do not count. Yes retains the original costs and limits; No also applies across smaller requests. See the revival-promise guide for exact timing and unfinished compositions.', 'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. The complete grouped builder supports eight children per group, sixteen total conditions including groups and depth four. Add/wrap/remove/collapse retains the whole claim and validates every field. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.', 'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.', @@ -3438,6 +3464,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'truthtrance-movement', 'truthtrance-bid', 'truthtrance-revival', + 'truthtrance-bribe', 'richese-no-field', 'card-reinforcements', ], diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 75799120..02dbceca 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1,3 +1,4 @@ +import { parseBribeClaim, bribeClaimText, bribePromiseModeSupported, type BribeClaim } from './bribe-promises'; import { parseRevivalClaim, revivalClaimText, revivalPromiseModeSupported, type RevivalClaim } from './revival-promises'; import { parseMovementClaim, movementClaimText, movementPromiseTargetAvailable, MovementClaimError, type MovementClaim } from './movement-promises'; import { shipmentAvailable } from './shipment-opportunity'; @@ -61,6 +62,7 @@ export type TruthFact = | { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number } | { kind: 'and' | 'or'; terms: TruthFact[] }; export type TruthQuestion = + | ({ kind: 'bribe'; target: string } & BribeClaim) | ({ kind: 'revival'; target: string } & RevivalClaim) | ({ kind: 'bid'; target: string } & BidClaim) | { kind: 'traitorCall'; target: string; battle: string; territory: string } @@ -214,6 +216,17 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { g.players.some((p) => p.id === v.target), 'Ask one other player.', ); + if (v.kind === 'bribe') { + 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 && + 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, + 'Choose a current-turn ordinary bribe to another non-allied participant in a supported table.'); + return { kind: 'bribe', target: v.target, ...claim }; + } if (v.kind === 'revival') { check(Object.keys(v).sort().join(',') === 'counter,kind,minimum,target,turn', 'Supply the current Revival turn, physical counter type, minimum and respondent.'); @@ -459,8 +472,10 @@ export function truthQuestionText( q: TruthQuestion, leaderName: (id: string) => string, locationName: (id: string) => string, + playerName: (id: string) => string = id => id, ): string { if (q.kind === 'freeform') return q.text; + if (q.kind === 'bribe') return `Will you ${bribeClaimText(q, playerName)}?`; if (q.kind === 'revival') return `Will you ${revivalClaimText(q)}?`; if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`; if (q.kind === 'traitorCall') return `In this revealed battle in ${locationName(q.territory)}, will you declare the opposing leader a Traitor?`; @@ -508,6 +523,8 @@ export function resolveTruthAction( ) => void; movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[]; bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void; + bribeAnswers?: (g: Game, p: Player, claim: BribeClaim) => TruthAnswer[]; + bindBribe?: (g: Game, p: Player, claim: BribeClaim, answer: boolean, asker: string) => void; revivalAnswers?: (g: Game, p: Player, claim: RevivalClaim) => TruthAnswer[]; bindRevival?: (g: Game, p: Player, claim: RevivalClaim, answer: boolean, asker: string) => void; bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[]; @@ -630,6 +647,9 @@ export function resolveTruthAction( 'Waiting for the Truthtrance holder to ask a question.', ); w.question = parseQuestion(g, p.id, a.question); + if (w.question.kind === 'bribe') + check(effects.bribeAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, + 'Choose a current bribe question compatible with earlier answers.'); if (w.question.kind === 'revival') check(effects.revivalAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, 'Choose a respondent with a remaining native Revival opportunity.'); @@ -645,7 +665,7 @@ export function resolveTruthAction( )!; effects.log( g, - `${p.name} asks ${target.name}: ${truthQuestionText(w.question, (id) => (id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : battleLeaderIdentities(g).find((leader) => leader.id === id)!.name), id => combatLocationName(g, id))}`, + `${p.name} asks ${target.name}: ${truthQuestionText(w.question, (id) => (id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : battleLeaderIdentities(g).find((leader) => leader.id === id)!.name), id => combatLocationName(g, id), id => g.players.find(player => player.id === id)!.name)}`, ); } else if (a.type === 'truthSave') { check( @@ -686,6 +706,11 @@ export function resolveTruthAction( 'Choose an answer consistent with legal ground movement and earlier promises.'); if (a.answer !== 'unknown') effects.bindMovement(g, p, q, a.answer === 'yes', current.player); } + if (q.kind === 'bribe') { + check(effects.bribeAnswers && effects.bindBribe && effects.bribeAnswers(g, p, q).includes(a.answer), + 'Choose an answer consistent with actual ordinary bribe funding and earlier promises.'); + if (a.answer !== 'unknown') effects.bindBribe(g, p, q, a.answer === 'yes', current.player); + } if (q.kind === 'revival') { check(effects.revivalAnswers && effects.bindRevival && effects.revivalAnswers(g, p, q).includes(a.answer), 'Choose an answer consistent with native force revivals and earlier answers.'); diff --git a/tests/bribe-promises.test.ts b/tests/bribe-promises.test.ts new file mode 100644 index 00000000..6910f0c1 --- /dev/null +++ b/tests/bribe-promises.test.ts @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + BribePromiseError, + bribeClaimText, + bribePromiseModeSupported, + bribePromiseRequirements, + liveBribePromises, + parseBribeClaim, + recordPromisedBribe, + validateBribePromises, + type BribePromise, +} from '../game/bribe-promises'; + +const seats = ['payer', 'asker', 'recipient-a', 'recipient-b', 'other-payer'] as const; +function promise(event: string, minimum: number, extra: Partial = {}): BribePromise { + return { event, turn: 2, recipient: 'recipient-a', minimum, player: 'payer', asker: 'asker', + answer: true, progress: 0, ...extra }; +} + +void test('bribe claims canonicalize positive safe turn and threshold while retaining an exact trimmed recipient', () => { + const input = { turn: 2, recipient: 'recipient-a', minimum: 6, priorPayments: 4, privateSpice: 9 }; + const parsed = parseBribeClaim(input); + assert.deepEqual(parsed, { turn: 2, recipient: 'recipient-a', minimum: 6 }); + assert.notEqual(parsed, input); + assert.deepEqual(parseBribeClaim({ turn: Number.MAX_SAFE_INTEGER, recipient: 'recipient-a', minimum: Number.MAX_SAFE_INTEGER }), + { turn: Number.MAX_SAFE_INTEGER, recipient: 'recipient-a', minimum: Number.MAX_SAFE_INTEGER }); + for (const field of ['turn', 'minimum'] as const) + for (const value of [0, -1, 0.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '6', null, undefined]) + assert.throws(() => parseBribeClaim({ ...input, [field]: value }), BribePromiseError); + for (const recipient of ['', ' ', ' recipient-a', 'recipient-a ', 1, null, undefined]) + assert.throws(() => parseBribeClaim({ ...input, recipient }), BribePromiseError); + for (const value of [null, undefined, [], 'six', 6]) assert.throws(() => parseBribeClaim(value), BribePromiseError); + assert.match(bribeClaimText(parsed, id => id === 'recipient-a' ? 'Atreides' : id), /at least 6 spice to Atreides/); + assert.match(bribeClaimText(parsed), /ordinary bribes from now until this turn ends/); +}); + +void test('ordinary bribe scope preserves bare classic and selected native modes without widening optional modules', () => { + const classic = { advanced: false, expansions: [], players: [{ faction: 'atreides' }, { faction: 'emperor' }] }; + const native = { advanced: true, expansions: ['ix', 'choam', 'ecaz'], + players: ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].map(faction => ({ faction })) }; + for (const game of [classic, { ...classic, advanced: true }, native, { ...native, advanced: false }, + { ...classic, expansions: ['ix', 'choam', 'ecaz'] }, + { ...classic, advanced: true, expansions: ['ecaz'], players: [{ faction: 'moritani' }, { faction: 'harkonnen' }] }, + ]) assert.equal(bribePromiseModeSupported(game), true); + for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards', + 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery', + 'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const) + assert.equal(bribePromiseModeSupported({ ...native, [option]: true }), false, option); + for (const game of [ + { ...native, expansions: ['ix', 'choam'] }, { ...native, expansions: ['ix', 'ix', 'ecaz'] }, + { ...classic, expansions: ['unknown'] }, { ...classic, players: [{ faction: 'atreides' }, { faction: 'unknown' }] }, + { ...classic, players: [{ faction: 'atreides' }] }, { ...classic, players: [...native.players, { faction: 'atreides' }] }, + { ...classic, advanced: 'basic' as unknown as boolean }, + ]) assert.equal(bribePromiseModeSupported(game), false); +}); + +void test('future payments accumulate only for the exact recipient and a later answer excludes earlier payments', () => { + const original = [promise('first-answer', 5), promise('other-recipient', 2, { recipient: 'recipient-b' })]; + const first = recordPromisedBribe(original, 'payer', 2, { recipient: 'recipient-a', amount: 2 }); + assert.equal(first[0].progress, 2); assert.equal(first[0].fulfilled, undefined); assert.equal(first[1], original[1]); + const second = recordPromisedBribe(first, 'payer', 2, { recipient: 'recipient-a', amount: 3 }); + assert.equal(second[0].progress, 5); assert.equal(second[0].fulfilled, true); + const later = [...second, promise('later-answer', 2)]; + assert.deepEqual(bribePromiseRequirements(later, 'payer', 2), [ + { recipient: 'recipient-b', amount: 2 }, { recipient: 'recipient-a', amount: 2 }, + ], 'The earlier five-spice payment does not prepay the newly answered question'); + const third = recordPromisedBribe(later, 'payer', 2, { recipient: 'recipient-a', amount: 1 }); + assert.equal(third[0], second[0]); assert.equal(third[2].progress, 1); assert.equal(third[2].fulfilled, undefined); + assert.equal(original[0].progress, 0); assert.equal(first[0].progress, 2); +}); + +void test('overlapping Yes answers share a recipient payment while different recipients require separate spice', () => { + const records = [promise('a-five', 5, { progress: 2 }), promise('a-eight', 8, { progress: 3 }), + promise('a-below-ten', 10, { answer: false, progress: 4 }), + promise('b-four', 4, { recipient: 'recipient-b', progress: 1 })]; + assert.deepEqual(bribePromiseRequirements(records, 'payer', 2), [ + { recipient: 'recipient-a', amount: 5 }, { recipient: 'recipient-b', amount: 3 }, + ], 'Use maximum additional Yes deficit per recipient, not the sum of overlapping questions'); + const paidA = recordPromisedBribe(records, 'payer', 2, { recipient: 'recipient-a', amount: 5 }); + assert.deepEqual(paidA.map(record => record.progress), [7, 8, 9, 1]); + assert.deepEqual(paidA.map(record => !!record.fulfilled), [true, true, false, false]); + assert.deepEqual(bribePromiseRequirements(paidA, 'payer', 2), [{ recipient: 'recipient-b', amount: 3 }]); + const paidB = recordPromisedBribe(paidA, 'payer', 2, { recipient: 'recipient-b', amount: 3 }); + assert.deepEqual(bribePromiseRequirements(paidB, 'payer', 2), []); + assert.equal(paidB[2].fulfilled, undefined, 'The smaller No recipient total remains constrained to turn end'); +}); + +void test('a recipient No cap rejects incompatible joint Yes while independent recipients and No-only answers remain possible', () => { + const yes = promise('a-needs-five-more', 8, { progress: 3 }); + assert.equal(bribePromiseRequirements([yes, promise('a-only-four-more', 9, { answer: false, progress: 4 })], 'payer', 2), null); + assert.deepEqual(bribePromiseRequirements([yes, + promise('a-can-pay-five', 10, { answer: false, progress: 4 }), + promise('b-below-one', 1, { recipient: 'recipient-b', answer: false }), + ], 'payer', 2), [{ recipient: 'recipient-a', amount: 5 }]); + assert.deepEqual(bribePromiseRequirements([promise('only-no', 3, { answer: false, progress: 2 })], 'payer', 2), []); + assert.deepEqual(bribePromiseRequirements([], 'payer', 2), []); +}); + +void test('No prevents threshold evasion by split payments and rejected payment cannot partly fulfill another answer', () => { + const original = [promise('yes-seven', 7), promise('no-seven', 7, { answer: false })]; + const first = recordPromisedBribe(original, 'payer', 2, { recipient: 'recipient-a', amount: 3 }); + const second = recordPromisedBribe(first, 'payer', 2, { recipient: 'recipient-a', amount: 3 }); + assert.deepEqual(second.map(record => record.progress), [6, 6]); + const before = structuredClone(second); second.forEach(Object.freeze); Object.freeze(second); + assert.throws(() => recordPromisedBribe(second, 'payer', 2, { recipient: 'recipient-a', amount: 1 }), BribePromiseError); + assert.deepEqual(second, before); assert.equal(second[0].fulfilled, undefined); + assert.equal(original[0].progress, 0); assert.equal(original[1].progress, 0); +}); + +void test('terminal, other-player, other-turn and other-recipient records preserve their exact references', () => { + const records = [promise('live', 4), promise('different-asker', 6, { asker: 'recipient-b', answer: false }), + promise('fulfilled', 1, { progress: 1, fulfilled: true }), promise('released', 1, { released: true }), + promise('other-player', 1, { player: 'other-payer' }), promise('other-turn', 1, { turn: 1 }), + promise('other-recipient', 1, { recipient: 'recipient-b' })]; + records.forEach(Object.freeze); Object.freeze(records); + assert.deepEqual(liveBribePromises(records, 'payer', 2), [records[0], records[1], records[6]]); + const paid = recordPromisedBribe(records, 'payer', 2, { recipient: 'recipient-a', amount: 4 }); + assert.equal(paid[0].fulfilled, true); assert.equal(paid[1].progress, 4); assert.equal(paid[1].fulfilled, undefined); + for (let index = 2; index < records.length; index++) assert.equal(paid[index], records[index]); + const unrelated = recordPromisedBribe(records, 'absent', 2, { recipient: 'recipient-a', amount: 1 }); + records.forEach((record, index) => assert.equal(unrelated[index], record)); +}); + +void test('safe currency arithmetic preserves the inclusive maximum and refuses overflowing records or combined requirements', () => { + const maximum = Number.MAX_SAFE_INTEGER; + const lastOne = promise('largest-progress', maximum, { progress: maximum - 1 }); + assert.deepEqual(bribePromiseRequirements([lastOne], 'payer', 2), [{ recipient: 'recipient-a', amount: 1 }]); + assert.equal(recordPromisedBribe([lastOne], 'payer', 2, { recipient: 'recipient-a', amount: 1 })[0].progress, maximum); + const before = structuredClone(lastOne); + assert.throws(() => recordPromisedBribe([lastOne], 'payer', 2, { recipient: 'recipient-a', amount: 2 }), BribePromiseError); + assert.deepEqual(lastOne, before); + assert.equal(bribePromiseRequirements([promise('a-maximum', maximum), + promise('b-one', 1, { recipient: 'recipient-b' })], 'payer', 2), null); + assert.deepEqual(bribePromiseRequirements([promise('a-maximum-minus-one', maximum - 1), + promise('b-one', 1, { recipient: 'recipient-b' })], 'payer', 2), [ + { recipient: 'recipient-a', amount: maximum - 1 }, { recipient: 'recipient-b', amount: 1 }, + ]); + assert.equal(bribePromiseRequirements([lastOne, + promise('no-maximum', maximum, { answer: false, progress: maximum - 1 })], 'payer', 2), null); + assert.throws(() => recordPromisedBribe([promise('no-maximum', maximum, { answer: false, progress: maximum - 1 })], + 'payer', 2, { recipient: 'recipient-a', amount: 1 }), BribePromiseError); +}); + +void test('actual payment inputs and malformed live progress fail without creating requirements or altering records', () => { + const records = [promise('original', 3)]; const before = structuredClone(records); + for (const amount of [0, -1, 0.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) + assert.throws(() => recordPromisedBribe(records, 'payer', 2, { recipient: 'recipient-a', amount }), BribePromiseError); + for (const recipient of ['', ' ', ' recipient-a', 'recipient-a ']) + assert.throws(() => recordPromisedBribe(records, 'payer', 2, { recipient, amount: 1 }), BribePromiseError); + for (const progress of [-1, 0.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + const malformed = [{ ...records[0], progress }]; + assert.throws(() => bribePromiseRequirements(malformed, 'payer', 2), BribePromiseError); + assert.throws(() => recordPromisedBribe(malformed, 'payer', 2, { recipient: 'recipient-a', amount: 1 }), BribePromiseError); + } + assert.deepEqual(records, before); +}); + +void test('record validation binds events, actual payer and recipient seats and consistent cumulative terminal states', () => { + const valid = [promise('live-yes', 3, { progress: 2 }), + promise('fulfilled-yes', 3, { progress: 3, fulfilled: true }), + promise('released-yes', 3, { progress: 1, released: true }), + promise('live-no', 3, { progress: 2, answer: false }), + promise('completed-no', 3, { progress: 2, answer: false, fulfilled: true }), + promise('released-no', 3, { progress: 2, answer: false, released: true }), + promise('pay-the-asker', 1, { recipient: 'asker' })]; + const before = structuredClone(valid); + assert.doesNotThrow(() => validateBribePromises(valid, seats)); assert.deepEqual(valid, before); + assert.doesNotThrow(() => validateBribePromises(JSON.parse(JSON.stringify(valid)) as BribePromise[], seats)); + assert.doesNotThrow(() => validateBribePromises([], seats)); + const base = promise('event', 3); + const invalid: unknown[] = [null, {}, [null], [base, { ...base }], + ...[{ event: '' }, { event: ' ' }, { player: 'outsider' }, { asker: 'outsider' }, { asker: 'payer' }, + { recipient: 'outsider' }, { recipient: 'payer' }, { recipient: ' recipient-a' }, + { answer: 'yes' }, { progress: -1 }, { progress: 0.5 }, { progress: Number.NaN }, + { progress: Number.MAX_SAFE_INTEGER + 1 }, { fulfilled: 1 }, { released: 'true' }, + { fulfilled: true, progress: 2 }, { fulfilled: false, progress: 3 }, { progress: 3 }, + { fulfilled: true, released: true, progress: 3 }, { answer: false, progress: 3 }, + { answer: false, progress: 3, fulfilled: true }, { answer: false, progress: 3, released: true }, + { turn: 0 }, { minimum: 0 }].map(extra => [{ ...base, ...extra }])]; + for (const records of invalid) + assert.throws(() => validateBribePromises(records as BribePromise[], seats), BribePromiseError); + assert.deepEqual(valid, before); +}); diff --git a/tests/fixture-truth-bribe.ts b/tests/fixture-truth-bribe.ts new file mode 100644 index 00000000..baed414b --- /dev/null +++ b/tests/fixture-truth-bribe.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { + actTruthBid, createTruthBidFixture, nextTruthBid, truthBidClean, truthBidInventory, truthBidPlayer, + type TruthBidStep, +} from './fixture-truth-bid'; + +export type TruthBribeStep = TruthBidStep; +export type TruthBribeTransition = { before: Game; steps: TruthBribeStep[]; after: Game }; +export const actTruthBribe = actTruthBid; +export const truthBribeClean = truthBidClean; +export const truthBribeInventory = truthBidInventory; +export const truthBribePlayer = truthBidPlayer; +export const truthBribeBalances = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.spice])); +export const truthBribeDeferred = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.bribes])); + +export function nextTruthBribe(game: Game): TruthBribeStep | null { + if (game.truthtrance) return null; + for (const player of game.players) { + const view = viewGame(game, player.id); + if (view.bribePromiseGuide) return { actor: player.id, action: view.bribePromiseGuide.action }; + if (view.bidPromiseGuide) return { actor: player.id, action: view.bidPromiseGuide.action }; + if (view.revivalPromiseGuide?.phase === game.phase) return { actor: player.id, action: view.revivalPromiseGuide.action }; + } + return nextTruthBid(game); +} +export function advanceTruthBribe(game: Game, until: (state: Game) => boolean, actions: TruthBribeStep[] = []): Game { + for (let count = 0; count < 1200; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The genuine bribe source ended before its native control'); + const next = nextTruthBribe(game); assert.ok(next, 'The original Truthtrance question and answer belong to their callers'); + game = actTruthBribe(game, next.actor, next.action, actions); + } + throw Error('The genuine bribe source did not reach its finite boundary'); +} +export function settleTruthBribe(game: Game, actions: TruthBribeStep[] = []): Game { + return advanceTruthBribe(game, truthBribeClean, actions); +} +export function finishTruthBribe(game: Game, actions: TruthBribeStep[] = []): Game { + const turn = game.turn; + return advanceTruthBribe(game, state => state.turn === turn + 1 && state.phase === 0 && truthBribeClean(state), actions); +} + +/** The original public H/A/E source supplies both physical Truthtrances and + * ten actually available Emperor spice. No optional modules or alliances are + * introduced. Phase7 is earned through real quiet phase actions and original + * Collection, never a clock/wallet/army/hand/question/promise installation. */ +export function createTruthBribeFixture(options: { + advanced?: boolean; window?: 'waiting' | 'active'; recipient?: 'harkonnen' | 'atreides'; minimum?: number; phase?: 3 | 7; +} = {}) { + const base = createTruthBidFixture({ advanced: options.advanced, window: options.window ?? 'waiting' }); + const recipient = options.recipient ?? base.asker, minimum = options.minimum ?? 3, phase = options.phase ?? 3; + let game = structuredClone(base.game); const actions = structuredClone(base.sourceActions); + if (phase === 7) game = advanceTruthBribe(game, state => state.phase === 7 && truthBribeClean(state), actions); + assert.equal(game.phase, phase); assert.equal(truthBribePlayer(game, base.target).spice, 10); + assert.equal(truthBribePlayer(game, recipient).ally, null); + assert.deepEqual(truthBribeInventory(game), base.originalCards); + const question = { kind: 'bribe' as const, target: base.target, turn: game.turn, recipient, minimum }; + return { ...base, game, sourceGame: structuredClone(game), base, recipient, minimum, phase, payer: base.target, + question, questionAction: { type: 'truthAsk', question } as Action, + bribeAction: { type: 'bribe', target: recipient, amount: minimum } as Action, + originalBalances: truthBribeBalances(game), originalDeferred: truthBribeDeferred(game), + actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length, + staging: [] as string[], sourceDisclosure: 'Genuine public original H/A/E base33 setup and ordinary auction; H holds both original Truthtrances, E its original Shield and10spice. Only inherited unopened deal/lot/Spice positions and scalar lotteries are selected. Phase7, if requested, follows actual native controls/Collection. Ordinary bribes debit the payer now and credit the named nonally only at actual Mentat. No played state or promise is installed.' }; +} +export type TruthBribeFixture = ReturnType; +type TruthBribeSource = { game: Game; asker: string; target: string; truths: string[] }; +export function declareTruthBribe(f: TruthBribeSource, state = f.game, options: { copies?: 1 | 2 } = {}) { + let game = state; const before = structuredClone(game), actions: TruthBribeStep[] = []; + const declaration: Action = { type: 'card', card: f.truths[0], ...(options.copies === 2 ? { cards: [f.truths[1]] } : {}) }; + game = actTruthBribe(game, f.asker, declaration, actions); + for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) { + const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor); + game = actTruthBribe(game, actor.id, { type: 'truthPass' }, actions); + } + assert.equal(game.truthtrance?.stage, 'ask'); + return { game, before, declaration, actions, actionCount: actions.length }; +} +export function askTruthBribe(game: Game, asker: string, target: string, recipient: string, minimum = 3, + actions: TruthBribeStep[] = []): Game { + return actTruthBribe(game, asker, { type: 'truthAsk', question: { kind: 'bribe', target, turn: game.turn, recipient, minimum } }, actions); +} +export function answerTruthBribe(game: Game, target: string, answer: 'yes' | 'no', actions: TruthBribeStep[] = []): Game { + return actTruthBribe(game, target, { type: 'truthAnswer', answer }, actions); +} +export function bindTruthBribe(f: TruthBribeFixture, answer: 'yes' | 'no' = 'yes', minimum = f.minimum, + recipient = f.recipient, state = f.game) { + const declared = declareTruthBribe(f, state), actions = [...declared.actions]; + const asked = askTruthBribe(declared.game, f.asker, f.target, recipient, minimum, actions); + const game = answerTruthBribe(asked, f.target, answer, actions); + return { game, declared, asked, actions, actionCount: actions.length }; +} + +/** Isolate deferred payment from ordinary Collection first, then enter the + * actual Mentat that credits it. A final real turn boundary permits callers to + * inspect the old No's expiration without installing a new clock. */ +export function completeTruthBribe(f: TruthBribeFixture, state = f.game) { + let game = state; const actions: TruthBribeStep[] = []; + const transition = (until: (state: Game) => boolean): TruthBribeTransition => { + const before = structuredClone(game), from = actions.length; + game = advanceTruthBribe(game, until, actions); + return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) }; + }; + const collection = transition(state => state.phase === 7 && truthBribeClean(state)); + const mentat = transition(state => state.phase === 8 && truthBribeClean(state)); + const nextTurn = transition(state => state.turn === f.game.turn + 1 && state.phase === 0 && truthBribeClean(state)); + assert.deepEqual(truthBribeInventory(game), f.originalCards); + return { game, collection, mentat, nextTurn, actions, actionCount: actions.length }; +} diff --git a/tests/truth-bribe-controls.test.tsx b/tests/truth-bribe-controls.test.tsx new file mode 100644 index 00000000..2120c868 --- /dev/null +++ b/tests/truth-bribe-controls.test.tsx @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { BribeClaimFields, bribeClaimInputError } from '../components/bribe-claim-fields'; +import { BribePromises, BribePromiseGuideButton } from '../components/bribe-promises'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; +import type { BribeClaim } from '../game/bribe-promises'; +import { createTechTokens } from '../game/tech-tokens'; +import { createTruthBribeFixture, bindTruthBribe, actTruthBribe, settleTruthBribe, truthBribeInventory } from './fixture-truth-bribe'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); + return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true }; + }, +}); +const { Truthtrance } = await import('../components/truthtrance'); +aliases.deregister(); + +type ElementProps = { children?: ReactNode; [key: string]: unknown }; +type Element = ReactElement; +function elements(node: ReactNode): Element[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!isValidElement(node)) return []; + return [node, ...elements(node.props.children)]; +} +function field(props: Parameters[0], id: string) { + const found = elements(BribeClaimFields(props)).find(element => element.props.id === id && typeof element.props.onChange === 'function'); + assert.ok(found); return found; +} +function minimum(props: Parameters[0], value: number) { + (field(props, 'truth-bribe-minimum').props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)( + { currentTarget: { valueAsNumber: value } }); +} +function recipient(props: Parameters[0], value: string) { + (field(props, 'truth-bribe-recipient').props.onChange as (event: { currentTarget: { value: string } }) => void)( + { currentTarget: { value } }); +} +function guide(props: Parameters[0]) { + const button = BribePromiseGuideButton(props); assert.ok(button); return button; +} +const press = (button: ReturnType) => button.props.onClick(); +const markup = (game: GameView, busy = false) => renderToStaticMarkup(createElement(BribePromises, { game, busy, send() {} })); + +/** Controlled personalized quotes supplement the native producer below. They + * are UI projections, rather than installed authoritative payments or promises. */ +function projected(phase = 3): GameView { + const source = createGame('TRUTHBRIBECONTROLS', newPlayer('p', 'Respondent', 'emperor')); + joinGame(source, newPlayer('a', 'Asker', 'atreides')); + joinGame(source, newPlayer('r', 'Recipient', 'harkonnen')); + const game = viewGame(source, 'p'); + game.status = 'playing'; game.turn = 2; game.phase = phase; game.active = 'a'; + game.auction = { bid: 0, bidder: null, active: 'a', remaining: 2, card: null, inspectionBlocked: null }; + game.bribeOptions = { available: 8, incoming: 0, blocked: null, + targets: [{ id: 'r', maximum: 6, limitation: null }, { id: 'a', maximum: 8, limitation: null }] }; + game.bribePromises = [{ event: 'controlled-bribe-event', player: 'p', asker: 'a', turn: 2, + recipient: 'r', minimum: 3, answer: true, progress: 1 }]; + game.bribePromiseGuide = { action: { type: 'bribe', target: 'r', amount: 2, 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[] = []; + const recipients = [{ id: 'r', name: 'Recipient' }, { id: 'a', name: 'Asker' }]; + const props = { value, recipients, onChange(next: BribeClaim) { changes.push(next); } }; + const html = renderToStaticMarkup(createElement(BribeClaimFields, props)); + 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/); + 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]) { + assert.ok(bribeClaimInputError({ ...value, minimum: next }, recipients)); minimum(props, next); + } + recipient(props, 'missing'); recipient(props, ''); assert.equal(changes.length, 3); + minimum({ ...props, disabled: true }, 2); recipient({ ...props, disabled: true }, 'a'); + minimum({ ...props, recipients: [] }, 2); assert.equal(changes.length, 3); + assert.ok(bribeClaimInputError(value, [])); + assert.match(renderToStaticMarkup(createElement(BribeClaimFields, { ...props, recipients: [] })), /role="alert"/); +}); + +void test('own bribe promises persist with named cumulative progress, delayed spending and complete/released status', () => { + const game = projected(); + game.bribePromises.push( + { event: 'no-event', player: 'p', asker: 'a', turn: 2, recipient: 'r', minimum: 5, answer: false, progress: 1 }, + { event: 'fulfilled-event', player: 'p', asker: 'a', turn: 2, recipient: 'r', minimum: 1, answer: true, progress: 1, fulfilled: true }, + { event: 'released-event', player: 'p', asker: 'a', turn: 2, recipient: 'a', minimum: 9, answer: true, progress: 0, released: true }, + { event: 'foreign-event', player: 'a', asker: 'p', turn: 2, recipient: 'r', minimum: 99, answer: true, progress: 0 }, + ); + const before = structuredClone(game); + for (let render = 0; render < 2; render++) { + const html = markup(game); + assert.match(html, /Your bribe promises/); assert.match(html, /Recorded future ordinary payments to Recipient: 1 spice/); + 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, /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/); + } + assert.deepEqual(game, before); + assert.equal(BribePromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null); +}); + +void test('actual guide callbacks forward exact ordinary bribes out of turn throughout their supported phases', () => { + for (const phase of [0, 3, 4, 7]) { + const game = projected(phase), sent: Action[] = []; + if (phase === 4) { + game.advanced = true; + game.ready = ['p']; + game.revivalOrder = { version: 1, turn: 2, original: ['a', 'p', 'r'], remaining: ['a', 'r'], completed: ['p'], started: false }; + } + const props = { game, busy: false, send(action: Action) { sent.push(action); } }; + const button = guide(props); assert.equal(button.props.disabled, false); press(button); + assert.deepEqual(sent, [game.bribePromiseGuide!.action]); assert.equal(sent[0], game.bribePromiseGuide!.action); + assert.equal(game.active, 'a'); assert.equal(game.auction!.active, 'a'); + press(guide({ ...props, busy: true })); assert.equal(sent.length, 1); + } +}); + +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.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'; }, + game => { game.bribePromises[0].fulfilled = true; }, game => { game.bribePromises[0].released = true; }, + game => { game.bribePromises = []; }, game => { game.players = game.players.filter(player => player.id !== 'r'); }, + game => { game.players.find(player => player.id === 'p')!.ally = 'r'; }, + game => { game.bribeOptions.blocked = 'Finish a decision first.'; }, game => { game.bribeOptions.blocked = ''; }, + game => { game.bribeOptions.available = 1; }, game => { game.bribeOptions.available = NaN; }, + game => { game.bribeOptions.targets[0].maximum = 1; }, game => { game.bribeOptions.targets[0].maximum = NaN; }, + game => { game.bribeOptions.targets = game.bribeOptions.targets.filter(target => target.id !== 'r'); }, + game => { game.phaseOpening = { passed: [] }; }, + game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; }, + game => { game.decision = { kind: 'auctionPayment', player: 'p' }; }, + game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; }, + game => { game.automaticContinuationPending = true; }, + game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; }, + game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; }, + game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; }, + game => { game.techTokens = createTechTokens(); }, + ]; + for (const change of changes) { + const game = projected(), sent: Action[] = []; + const props = { game, busy: false, send(action: Action) { sent.push(action); } }; + const button = guide(props); change(game); press(button); assert.deepEqual(sent, []); + } + for (const amount of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + const game = projected(); game.bribePromiseGuide!.action.amount = amount; + const sent: Action[] = []; press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.deepEqual(sent, []); + } + for (const target of ['p', 'missing']) { + const game = projected(); game.bribePromiseGuide!.action.target = target; + game.bribeOptions.targets.push({ id: target, maximum: 8, limitation: null }); + const sent: Action[] = []; press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.deepEqual(sent, []); + } + const props = { game: projected(), busy: false, send() { assert.fail('A now-busy bribe guide cannot dispatch'); } }; + const button = guide(props); props.busy = true; press(button); +}); + +void test('promise guidance uses projected payment bounds without inspecting private supplies, hands or auction faces', () => { + const game = projected(), sent: Action[] = []; + for (const seat of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'bribes', 'tanks', 'reserves']) + Object.defineProperty(seat, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true }); + Object.defineProperty(game.auction!, 'card', { get() { throw Error('No hidden lot inspection'); } }); + assert.match(markup(game), /Pay 2 spice to Recipient while honoring your answers/); + press(guide({ game, busy: false, send(action) { sent.push(action); } })); + assert.deepEqual(sent, [game.bribePromiseGuide!.action]); +}); + +void test('genuine Basic and Advanced out-of-turn bribe callbacks record future payment before the recipient can spend it', () => { + for (const advanced of [false, true]) { + const f = createTruthBribeFixture({ advanced, window: 'waiting' }), bound = bindTruthBribe(f, 'yes', 3); + const respondent = viewGame(bound.asked, f.target); + assert.ok(respondent.truthBribeAnswers?.includes('yes')); + assert.equal(viewGame(bound.asked, f.asker).truthBribeAnswers, null); + const answers = renderToStaticMarkup(createElement(Truthtrance, { game: respondent, busy: false, act() {} })); + assert.match(answers, /Answer Yes/); assert.match(answers, /Answer No/); + const game = viewGame(bound.game, f.target), sent: Action[] = [], next = game.bribePromiseGuide; + assert.ok(next); assert.equal(game.auction?.active, f.asker); assert.notEqual(game.auction?.active, f.target); + assert.match(markup(game), /Pay 3 spice to/); + press(guide({ game, busy: false, send(action) { sent.push(action); } })); + assert.deepEqual(sent, [next.action]); + assert.ok(next.action.type === 'bribe' && typeof next.action.amount === 'number'); + const amount = next.action.amount; + const sender = bound.game.players.find(player => player.id === f.target); assert.ok(sender); + const recipient = bound.game.players.find(player => player.id === next.action.target); assert.ok(recipient); + const after = settleTruthBribe(actTruthBribe(bound.game, f.target, sent[0])); + assert.equal(after.players.find(player => player.id === sender.id)!.spice, sender.spice - amount); + assert.equal(after.players.find(player => player.id === recipient.id)!.spice, recipient.spice); + assert.equal(after.players.find(player => player.id === recipient.id)!.bribes, recipient.bribes + amount); + const promise = after.bribePromises!.find(promise => promise.event === next.action.bribePromiseEvent); + assert.ok(promise); assert.equal(promise.progress, 3); assert.equal(promise.fulfilled, true); + assert.deepEqual(truthBribeInventory(after), f.originalCards); + assert.match(markup(viewGame(after, f.target)), /Fulfilled: this commitment is complete/); + assert.equal(BribePromises({ game: viewGame(after, f.asker), busy: false, send() {} }), null); + } +}); diff --git a/tests/truth-bribe-runtime.test.ts b/tests/truth-bribe-runtime.test.ts new file mode 100644 index 00000000..d4b32f0a --- /dev/null +++ b/tests/truth-bribe-runtime.test.ts @@ -0,0 +1,229 @@ +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 { + 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, + truthBribeBalances as balances, truthBribeClean as clean, truthBribeInventory as inventory, truthBribePlayer as player, +} from './fixture-truth-bribe'; +import { createTruthRevivalFixture } from './fixture-truth-revival'; + +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)!; + +for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} Truth bribe Yes debits now and credits its exact nonally at actual Mentat`, () => { + 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.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), f.truths); + const before = structuredClone(f.game), bound = bind(f); assert.deepEqual(f.game, before); + assert.equal(bound.game.auction!.active, f.asker); assert.notEqual(bound.game.auction!.active, f.target); + assert.equal(bound.game.discard.filter(card => card.id === f.truth).length, 1); + assert.equal(promise(bound.game, f.target, f.recipient).progress, 0); + const guide = viewGame(bound.game, f.target).bribePromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'bribe'); assert.equal(guide.action.target, f.recipient); assert.equal(guide.action.amount, 3); + const paid = settle(act(bound.game, f.target, guide.action)); + assert.equal(player(paid, f.target).spice, 7); assert.equal(player(paid, f.recipient).spice, player(before, f.recipient).spice); + assert.equal(player(paid, f.recipient).bribes, 3); + assert.equal(promise(paid, f.target, f.recipient).progress, 3); assert.equal(promise(paid, f.target, f.recipient).fulfilled, true); + const result = complete(f, paid); + assert.equal(player(result.mentat.before, f.recipient).bribes, 3); + assert.equal(player(result.mentat.after, f.recipient).bribes, 0); + assert.equal(player(result.mentat.after, f.recipient).spice - player(result.mentat.before, f.recipient).spice, 3); + assert.equal(player(result.collection.after, f.recipient).spice - player(paid, f.recipient).spice, advanced ? 2 : 0); + assert.equal(result.game.turn, 2); assert.equal(result.game.phase, 0); + assert.deepEqual(inventory(result.game), f.originalCards); +}); + +void test('native split bribes fulfill Yes cumulatively only through future matching payments', () => { + const f = fixture(); let game = bind(f).game; + game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); + assert.equal(promise(game, f.target, f.recipient).progress, 1); assert.ok(!promise(game, f.target, f.recipient).fulfilled); + game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 2 }); + assert.equal(promise(game, f.target, f.recipient).progress, 3); assert.equal(promise(game, f.target, f.recipient).fulfilled, true); + assert.equal(player(game, f.target).spice, 7); assert.equal(player(game, f.recipient).bribes, 3); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native No aggregates matching bribes below its cap until the actual next turn', () => { + const f = fixture(); let game = bind(f, 'no').game; + for (let index = 0; index < 2; index++) game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); + assert.equal(promise(game, f.target, f.recipient).progress, 2); + rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); + game = complete(f, game).game; + assert.equal(game.turn, 2); assert.equal(player(game, f.recipient).bribes, 0); + game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 3 }); + assert.equal(player(game, f.target).spice, 5); assert.equal(player(game, f.recipient).bribes, 3); + assert.equal(promise(game, f.target, f.recipient).progress, 2); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native prior bribes never advance a later Truth promise for the same recipient', () => { + const f = fixture(); let game = act(f.game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); + game = bind(f, 'yes', 3, f.recipient, game).game; + assert.equal(promise(game, f.target, f.recipient).progress, 0); + game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 }); + assert.equal(promise(game, f.target, f.recipient).progress, 1); + game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 2 }); + assert.equal(promise(game, f.target, f.recipient).progress, 3); assert.equal(player(game, f.recipient).bribes, 4); + assert.equal(player(game, f.target).spice, 6); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native other-recipient payment does not count and cannot spend the last matching Yes funding', () => { + const f = fixture(); let game = bind(f).game; + game = act(game, f.target, { type: 'bribe', target: f.opponent, amount: 7 }); + assert.equal(promise(game, f.target, f.recipient).progress, 0); assert.equal(player(game, f.target).spice, 3); + assert.equal(player(game, f.opponent).bribes, 7); assert.equal(player(game, f.recipient).bribes, 0); + rejected(game, f.target, { type: 'bribe', target: f.opponent, amount: 1 }); + const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); + game = act(game, f.target, guide.action); assert.equal(player(game, f.target).spice, 0); + assert.equal(promise(game, f.target, f.recipient).progress, 3); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('two original Truth copies bind compatible Yes and No payments to the exact same recipient', () => { + const f = fixture(), declared = declare(f, f.game, { copies: 2 }); + let game = ask(declared.game, f.asker, f.target, f.recipient, 3); game = answer(game, f.target, 'yes'); + game = ask(game, f.asker, f.target, f.recipient, 5); game = answer(game, f.target, 'no'); + const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); game = act(game, f.target, guide.action); + assert.deepEqual(game.bribePromises!.map(promise => promise.progress), [3, 3]); + rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 2 }); + for (const card of f.truths) assert.equal(game.discard.filter(held => held.id === card).length, 1); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('two original Truth copies cannot answer Yes inside an existing matching No cap', () => { + const f = fixture(), declared = declare(f, f.game, { copies: 2 }); + let game = ask(declared.game, f.asker, f.target, f.recipient, 3); game = answer(game, f.target, 'no'); + game = ask(game, f.asker, f.target, f.recipient, 3); + assert.deepEqual(viewGame(game, f.target).truthBribeAnswers, ['no']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no'); + assert.equal(game.bribePromises!.length, 2); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('two native recipient Yes promises reserve their joint actual purse rather than the same spice twice', () => { + const f = fixture(), declared = declare(f, f.game, { copies: 2 }); + let game = ask(declared.game, f.asker, f.target, f.recipient, 3); game = answer(game, f.target, 'yes'); + game = ask(game, f.asker, f.target, f.opponent, 3); game = answer(game, f.target, 'yes'); + rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 8 }); + game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 7 }); + const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); assert.equal(guide.action.target, f.opponent); + game = act(game, f.target, guide.action); + assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 7); + assert.equal(player(game, f.opponent).bribes, 3); assert.ok(game.bribePromises!.every(promise => promise.fulfilled)); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native current winning-bid liability limits a bribe witness and the original invoice remains payable', () => { + const f = fixture({ window: 'active' }); let game = act(f.game, f.target, { type: 'bid', amount: 7 }); + game = bind(f, 'yes', 3, f.recipient, game).game; + const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 3); + game = act(game, f.target, guide.action); assert.equal(player(game, f.target).spice, 7); + game = advance(game, state => clean(state) && state.auction?.index === 1); + assert.equal(player(game, f.target).spice, 0); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard)); + assert.equal(player(game, f.recipient).bribes, 3); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native already committed auction spice cannot supply an unfunded three-spice bribe answer', () => { + const f = fixture({ window: 'active' }); let game = act(f.game, f.target, { type: 'bid', amount: 8 }); + game = declare(f, game).game; game = ask(game, f.asker, f.target, f.recipient, 3); + assert.deepEqual(viewGame(game, f.target).truthBribeAnswers, ['no']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + 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', () => { + 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; + 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); +}); + +void test('a real already-ready Collection payer reopens readiness when accepting a new Bribe Yes', () => { + 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' }); + const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); + game = act(game, f.target, guide.action); + 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('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); + rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } }); + rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, recipient: f.target } }); + let game = ask(declared.game, f.asker, f.target, f.recipient); + assert.deepEqual(viewGame(game, f.target).truthBribeAnswers, ['yes', 'no']); + assert.equal(viewGame(game, f.asker).truthBribeAnswers, null); + rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes'); + const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); assert.ok(guide.action.bribePromiseEvent); + rejected(game, f.asker, guide.action); const before = balances(game); + game = act(game, f.target, guide.action); + assert.equal(player(game, f.target).spice, before[f.target] - 3); + rejected(game, f.target, guide.action); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('two genuine Truth answers can fund an actual bid and exact-recipient bribe together', () => { + const f = fixture({ window: 'active' }), declared = declare(f, f.game, { copies: 2 }); + let game = act(declared.game, f.asker, { type: 'truthAsk', question: { kind: 'bid', target: f.target, + turn: f.game.turn, lot: f.game.auction!.index + 1, minimum: 2 } }); + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + game = ask(game, f.asker, f.target, f.recipient, 3); game = answer(game, f.target, 'yes'); + const bribe = viewGame(game, f.target).bribePromiseGuide; assert.ok(bribe); game = act(game, f.target, bribe.action); + const bid = viewGame(game, f.target).bidPromiseGuide; assert.ok(bid); game = act(game, f.target, bid.action); + game = advance(game, state => clean(state) && state.auction?.index === 1); + assert.equal(player(game, f.target).spice, 5); assert.equal(player(game, f.recipient).bribes, 3); + assert.equal(promise(game, f.target, f.recipient).fulfilled, true); + assert.ok(game.bidPromises!.every(promise => promise.fulfilled || promise.released)); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('an existing native Bid Yes cannot reuse its reserved purse for an incompatible Bribe Yes', () => { + const f = fixture({ window: 'active' }), declared = declare(f, f.game, { copies: 2 }); + let game = act(declared.game, f.asker, { type: 'truthAsk', question: { kind: 'bid', target: f.target, + turn: f.game.turn, lot: f.game.auction!.index + 1, minimum: 8 } }); + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = ask(game, f.asker, f.target, f.recipient, 3); + assert.deepEqual(viewGame(game, f.target).truthBribeAnswers, ['no']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); +}); + +void test('an existing native Shipment Yes cannot reuse its paid route funding for a Bribe Yes', () => { + const f = fixture(), declared = declare(f, f.game, { copies: 2 }); + let game = act(declared.game, f.asker, { type: 'truthAsk', question: { kind: 'shipment', target: f.target, + territory: 'arrakeen', minimum: 8 } }); + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = ask(game, f.asker, f.target, f.recipient, 3); + assert.deepEqual(viewGame(game, f.target).truthBribeAnswers, ['no']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); +}); + +void test('an existing earned ordinary Revival Yes cannot reuse its paid return funding for a Bribe Yes', () => { + const f = createTruthRevivalFixture(), declared = declare(f, f.game, { copies: 2 }); + let game = act(declared.game, f.asker, { type: 'truthAsk', question: f.question }); + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = ask(game, f.asker, f.target, f.asker, 1); + assert.deepEqual(viewGame(game, f.target).truthBribeAnswers, ['no']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('all four minimal profiles submit a legal exact-recipient Bribe Yes payment out of turn', () => { + const f = fixture(), game = bind(f).game; + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, f.target); view.players.find(seat => seat.id === f.target)!.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.target, action); + assert.equal(player(paid, f.target).spice, 7); assert.equal(player(paid, f.recipient).bribes, 3); + assert.equal(promise(paid, f.target, f.recipient).fulfilled, true); assert.deepEqual(inventory(paid), f.originalCards); + } +});