diff --git a/components/bribe-expression-fields.tsx b/components/bribe-expression-fields.tsx new file mode 100644 index 00000000..5ca3595a --- /dev/null +++ b/components/bribe-expression-fields.tsx @@ -0,0 +1,117 @@ +'use client'; + +import type { ReactElement } from 'react'; +import { + parseBribeExpression, BRIBE_EXPRESSION_MAX_DEPTH, BRIBE_EXPRESSION_MAX_LEAVES, + type BribeExpression, +} from '@/game/bribe-expression-promises'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; + +type Recipient = { id: string; name: string }; +// Every new condition requires the author to choose its public recipient. +const newLeaf = (): BribeExpression => ({ kind: 'payment', recipient: '', minimum: 1 }); +export function bribeExpressionInputError(value: unknown): string | null { + try { parseBribeExpression(value); return null; } + catch (error) { return error instanceof Error ? error.message : 'Choose a valid ordinary bribe expression.'; } +} + +// Detached placeholders validate only the structure. They allow empty recipient +// choices and invalid numeric drafts to remain visible while the author repairs +// them; the real tree is always passed intact to the parent. +const structuralDraft = (node: BribeExpression): BribeExpression => node.kind === 'payment' + ? { ...node, recipient: 'recipient', minimum: 1 } + : { ...node, terms: node.terms.map(structuralDraft) }; +const inputSize = (node: BribeExpression): { leaves: number; depth: number } => { + if (node.kind !== 'and' && node.kind !== 'or') return { leaves: 1, depth: 0 }; + const sizes = node.terms.map(inputSize); + return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) }; +}; + +/** Hook-free controlled editor. The server owns recipient eligibility, funding + * and compliance with the whole answer. */ +export function BribeExpressionFields({ value, recipients, disabled, onChange }: { + value: BribeExpression; + recipients: readonly Recipient[]; + disabled: boolean; + onChange: (expression: BribeExpression) => void; +}) { + const size = inputSize(value), error = bribeExpressionInputError(value); + const commit = (next: BribeExpression) => { + if (!disabled && !bribeExpressionInputError(structuralDraft(next))) onChange(next); + }; + const render = (node: BribeExpression, path: string, replace: (next: BribeExpression) => BribeExpression, + remove?: () => BribeExpression): ReactElement => { + const group = node.kind !== 'payment'; + const id = `truth-bribe-expression-${path}`; + const allowed = (next: BribeExpression) => !disabled && !bribeExpressionInputError(structuralDraft(replace(next))); + const change = (next: BribeExpression) => commit(replace(next)); + const wrap = (kind: 'and' | 'or'): BribeExpression => ({ kind, terms: [node, newLeaf()] }); + return
+ {group ? `Bribe group ${path} · ${node.kind.toUpperCase()}` : `Bribe condition ${path}`} + {group ? <> + + + {node.terms.map((term, index) => render(term, `${path}.${index + 1}`, + next => replace({ ...node, terms: node.terms.map((old, i) => i === index ? next : old) }), + () => { + const terms = node.terms.filter((_, i) => i !== index); + return replace(terms.length === 1 ? terms[0] : { ...node, terms }); + }))} +
+ + {(['and', 'or'] as const).map(kind => { + const next: BribeExpression = { ...node, terms: [...node.terms, { kind, terms: [newLeaf(), newLeaf()] }] }; + return ; + })} +
+ : <> + + + + change({ ...node, minimum: event.currentTarget.valueAsNumber })} /> + } +
+ {(['and', 'or'] as const).map(kind => )} + {remove && } +
+
; + }; + return
+ Ordinary bribe expression +

+ AND requires every condition; OR requires at least one. No makes the whole expression false, + not necessarily every condition. Legal alternatives remain available; the question does not pin a fixed branch. + Count cumulative future ordinary bribe payments after the answer this turn; smaller payments add together. + Earlier payments, gifts and ally aid do not count. +

+

The sender pays immediately; the recipient collects at the next Mentat opening. + Payments made during Mentat Pause wait until the start of next turn's Mentat Pause.

+

App input limits: {BRIBE_EXPRESSION_MAX_LEAVES} conditions and {BRIBE_EXPRESSION_MAX_DEPTH} levels of grouping. + Groups do not count as conditions. These are input limits, not additional printed rules.

+

{size.leaves} / {BRIBE_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {BRIBE_EXPRESSION_MAX_DEPTH} levels of grouping

+ {error &&

{error}

} + {render(value, '1', next => next)} +
; +} diff --git a/components/bribe-expression-promises.tsx b/components/bribe-expression-promises.tsx new file mode 100644 index 00000000..b5a91ae3 --- /dev/null +++ b/components/bribe-expression-promises.tsx @@ -0,0 +1,42 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { bribeExpressionRecipients, bribeExpressionText, liveBribeExpressionPromises } from '@/game/bribe-expression-promises'; +import { BribePromiseGuideButton } from './bribe-promises'; + +type BribeExpressionPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void }; + +export function BribeExpressionPromises(props: BribeExpressionPromiseProps) { + const game = props.game, own = game.bribeExpressionPromises.filter(promise => promise.player === game.me); + if (!own.length) return null; + const name = (id: string) => game.players.find(player => player.id === id)?.name ?? 'the named recipient'; + const live = liveBribeExpressionPromises(own, game.me, game.turn).length > 0; + return
+

Your bribe expression promises

+ +

+ AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately. + Legal alternatives remain available; no fixed branch is selected for you. + Smaller payments to the same recipient add together after the answer. Prior payments, gifts and ally aid do not count. + Preserve the spendable spice needed for your Yes answers. +

+

+ The sender pays immediately. The recipient collects the payment at the next Mentat opening. + Payments made during Mentat Pause wait until the start of next turn's Mentat Pause. + Collection does not count the payment a second time. + Ordinary bribes can be paid out of turn when the current bribe controls permit them; original timing and payment rules still apply. + {' '}Bribe expression rules. +

+ {live &&

{game.phase === 8 + ? 'Late payments still honor this turn’s answers; their recipients collect at next turn’s Mentat opening.' + : 'Your next Mentat opening is still ahead. Accepted payments count now and their recipients collect at that opening.'} + {' '}No stays binding until the actual turn ends; opening collection does not end it.

} + {!game.bribePromises.some(promise => promise.player === game.me) && } +
; +} diff --git a/components/bribe-promises.tsx b/components/bribe-promises.tsx index 7b496d55..7b54775c 100644 --- a/components/bribe-promises.tsx +++ b/components/bribe-promises.tsx @@ -3,6 +3,7 @@ import type { Action, GameView } from '@/game/engine'; import { bribeAction } from '@/game/bribe-options'; import { bribeClaimText, bribePromiseModeSupported, liveBribePromises } from '@/game/bribe-promises'; +import { liveBribeExpressionPromises } from '@/game/bribe-expression-promises'; import { Button } from './ui/button'; type BribePromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void }; @@ -23,7 +24,8 @@ export function BribePromiseGuideButton(props: BribePromiseProps) { 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) && + (liveBribePromises(current.bribePromises, actor, turn).some(promise => promise.event === event) || + liveBribeExpressionPromises(current.bribeExpressionPromises, 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 && diff --git a/components/game-table.tsx b/components/game-table.tsx index 249bd96b..530af8cd 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -26,6 +26,7 @@ import { import { ShipmentPromises } from './shipment-promises'; import { AlliancePromises } from './alliance-promises'; import { BribePromises } from './bribe-promises'; +import { BribeExpressionPromises } from './bribe-expression-promises'; import { RevivalPromises } from './revival-promises'; import { RevivalExpressionPromises } from './revival-expression-promises'; import { LeaderRevivalPromises } from './leader-revival-promises'; @@ -2037,6 +2038,7 @@ export function GameTable({ )} {g.status === 'playing' && } + {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 151ca3e5..e9ac84a1 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -1,4 +1,7 @@ 'use client'; + +import { BribeExpressionFields, bribeExpressionInputError } from './bribe-expression-fields'; +import { bribeExpressionRecipients, type BribeExpression } from '@/game/bribe-expression-promises'; import { RevivalExpressionFields, revivalExpressionInputError } from './revival-expression-fields'; import type { RevivalExpression } from '@/game/revival-expression-promises'; import { LeaderRevivalClaimFields, leaderRevivalClaimInputError } from './leader-revival-claim-fields'; @@ -65,7 +68,7 @@ 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' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' + 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' >('fact'); const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader => leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? ''); @@ -87,6 +90,12 @@ export function Truthtrance({ 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 [bribeExpression, setBribeExpression] = useState({ kind: 'and', terms: [ + { kind: 'payment', recipient: g.me, minimum: 3 }, { kind: 'payment', recipient: '', minimum: 2 }, + ] }); + const bribeExpressionError = bribeExpressionInputError(bribeExpression); + const validBribeExpressionRecipients = !bribeExpressionError && bribeExpressionRecipients(bribeExpression) + .every(recipient => bribeRecipients.some(player => player.id === recipient)); const [revivalSelection, setRevivalSelection] = useState>({ counter: 'total', minimum: 1 }); const revivalClaim: RevivalClaim = { turn: g.turn, ...revivalSelection }; const [bidMinimum, setBidMinimum] = useState(1); @@ -133,6 +142,7 @@ export function Truthtrance({ : kind === 'revivalExpression' ? { kind, target, turn: g.turn, claim: revivalExpression } : kind === 'leaderRevival' ? { kind, target, ...leaderRevivalClaim } : kind === 'alliance' ? { kind, target, ...allianceClaim } + : kind === 'bribeExpression' ? { kind, target, turn: g.turn, claim: bribeExpression } : kind === 'bribe' ? { kind, target, ...bribeClaim } : kind === 'revival' ? { kind, target, ...revivalClaim } : kind === 'bid' ? { kind, target, ...bidClaim } @@ -264,6 +274,13 @@ export function Truthtrance({ {button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })} )} + ) : w.question!.kind === 'bribeExpression' ? ( + <> +

Yes requires the whole AND/OR question to become true through your future ordinary payments. No keeps the whole expression false, and may permit some payments. Earlier payments and later recipient collection do not count. Keep the funds needed by every answer; late Mentat receipts wait for next turn's opening.

+ {(g.truthBribeExpressionAnswers ?? []).map(answer =>
+ {button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })} +
)} + ) : 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 collects at the next Mentat opening. Payments during Mentat wait until next turn's opening.

@@ -404,6 +421,7 @@ export function Truthtrance({ + @@ -448,6 +466,16 @@ export function Truthtrance({ {!g.truthAllianceTargets.includes(target) &&

Choose a supported participant in a quiet open ordinary Nexus. Optional modules and broader alliance questions remain unfinished.

}

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

+ ) : kind === 'bribeExpression' ? ( + <> + +

Count only ordinary payer payments after this answer until this turn ends. AND requires all conditions; OR permits any full alternative. No negates the whole expression. Original payment costs, non-allied recipients, earlier obligations and deferred receipts remain.

+ {!g.truthBribeExpressionTargets.includes(target) &&

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

} +

{bribeExpressionError + ? bribeExpressionError : !validBribeExpressionRecipients ? 'Choose a non-allied recipient for every condition.' + : truthQuestionText(question, leaderName, locationName, name)}

+ ) : kind === 'bribe' ? ( <> answer !== 'unknown') ?? g.truthAllianceAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } + if (w.question.kind === 'bribeExpression') { + const answer = g.truthBribeExpressionAnswers?.[0]; + return answer ? [{ type: 'truthAnswer', answer }] : []; + } if (w.question.kind === 'bribe') { const answer = g.truthBribeAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; diff --git a/game/bribe-expression-promises.ts b/game/bribe-expression-promises.ts new file mode 100644 index 00000000..50909a8e --- /dev/null +++ b/game/bribe-expression-promises.ts @@ -0,0 +1,274 @@ +import { bribePromiseRequirements, liveBribePromises, type BribePromise } from './bribe-promises'; + +export { bribePromiseModeSupported as bribeExpressionPromiseModeSupported } from './bribe-promises'; + +export type BribeExpression = { kind: 'payment'; recipient: string; minimum: number } | + { kind: 'and' | 'or'; terms: BribeExpression[] }; +export type BribeExpressionClaim = { turn: number; claim: BribeExpression }; +export type BribeExpressionPayment = { recipient: string; amount: number }; +export type BribeExpressionProgress = BribeExpressionPayment[]; +export type BribeExpressionPromise = BribeExpressionClaim & { + event: string; player: string; asker: string; answer: boolean; + /** Matching actual ordinary payments after this answer, excluding their later receipt. */ + progress: BribeExpressionProgress; + fulfilled?: boolean; released?: boolean; order?: number; +}; +export type BribeExpressionBranchLeaf = { + recipient: string; minimum: number; progress: number; answer: boolean; +}; + +export const BRIBE_EXPRESSION_MAX_DEPTH = 4; +export const BRIBE_EXPRESSION_MAX_LEAVES = 16; + +export class BribeExpressionPromiseError extends Error { + constructor(message: string) { + super(message); + this.name = 'BribeExpressionPromiseError'; + } +} + +function check(value: unknown, message: string): asserts value { + if (!value) throw new BribeExpressionPromiseError(message); +} +function object(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} +const identifier = (value: unknown): value is string => + typeof value === 'string' && value.length > 0 && value === value.trim(); +const whole = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +const positive = (value: unknown): value is number => whole(value) && value > 0; +const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean'; +const keys = (value: Record, expected: string) => Object.keys(value).sort().join(',') === expected; + +/** Software limits match revival expressions; spice minima have no artificial cap. + * Every subtree is parsed before an answer can short-circuit evaluation. */ +export function parseBribeExpression(value: unknown): BribeExpression { + let leaves = 0; + const parse = (input: unknown, depth: number): BribeExpression => { + check(object(input) && depth <= BRIBE_EXPRESSION_MAX_DEPTH, + 'Use an ordinary bribe expression within four nested groups.'); + if (input.kind === 'and' || input.kind === 'or') { + check(keys(input, 'kind,terms') && Array.isArray(input.terms) && input.terms.length >= 2 && + input.terms.length <= BRIBE_EXPRESSION_MAX_LEAVES, + 'Join two or more ordinary bribe statements with AND or OR.'); + return { kind: input.kind, terms: input.terms.map(term => parse(term, depth + 1)) }; + } + check(++leaves <= BRIBE_EXPRESSION_MAX_LEAVES, + 'Use at most sixteen ordinary bribe statements.'); + check(input.kind === 'payment' && keys(input, 'kind,minimum,recipient') && + identifier(input.recipient) && positive(input.minimum), + 'Choose an exact ordinary bribe recipient and a positive safe whole spice minimum.'); + return { kind: 'payment', recipient: input.recipient, minimum: input.minimum }; + }; + return parse(value, 0); +} + +export function parseBribeExpressionClaim(value: unknown): BribeExpressionClaim { + check(object(value) && keys(value, 'claim,turn') && positive(value.turn), + 'Choose the current turn and an ordinary bribe expression.'); + return { turn: value.turn, claim: parseBribeExpression(value.claim) }; +} + +export function bribeExpressionText(expression: BribeExpression, + recipientName: (id: string) => string = id => id): string { + const text = (node: BribeExpression): string => 'terms' in node ? + `(${node.terms.map(text).join(node.kind === 'and' ? ' AND ' : ' OR ')})` : + `pay at least ${node.minimum} spice to ${recipientName(node.recipient)} in ordinary bribes`; + return text(parseBribeExpression(expression)); +} + +export function bribeExpressionRecipients(expression: BribeExpression): string[] { + const recipients = new Set(); + const visit = (node: BribeExpression): void => { + if ('terms' in node) node.terms.forEach(visit); + else recipients.add(node.recipient); + }; + visit(parseBribeExpression(expression)); + return [...recipients]; +} + +function validateProgress(value: unknown): asserts value is BribeExpressionProgress { + check(Array.isArray(value), 'Retain cumulative ordinary bribe payments by exact recipient.'); + const recipients = new Set(); + for (const payment of value) { + check(object(payment) && keys(payment, 'amount,recipient') && identifier(payment.recipient) && + whole(payment.amount) && !recipients.has(payment.recipient), + 'Retain unique recipients and safe whole cumulative ordinary bribe amounts.'); + recipients.add(payment.recipient); + } +} + +function evaluate(expression: BribeExpression, progress: readonly BribeExpressionPayment[]): boolean { + if ('terms' in expression) return expression.kind === 'and' ? + expression.terms.every(term => evaluate(term, progress)) : expression.terms.some(term => evaluate(term, progress)); + return (progress.find(payment => payment.recipient === expression.recipient)?.amount ?? 0) >= expression.minimum; +} + +export function evaluateBribeExpression(expression: BribeExpression, progress: readonly BribeExpressionPayment[]): boolean { + const parsed = parseBribeExpression(expression); + validateProgress(progress); + return evaluate(parsed, progress); +} + +export function liveBribeExpressionPromises(promises: readonly BribeExpressionPromise[], player: string, + turn: number): BribeExpressionPromise[] { + return promises.filter(promise => promise.player === player && promise.turn === turn && + !promise.fulfilled && !promise.released); +} + +function readPromise(promise: BribeExpressionPromise): BribeExpression { + check(object(promise), 'The ordinary bribe expression commitment record is invalid.'); + const { claim } = parseBribeExpressionClaim({ turn: promise.turn, claim: promise.claim }); + validateProgress(promise.progress); + const recipients = bribeExpressionRecipients(claim); + check(typeof promise.answer === 'boolean' && optionalBoolean(promise.fulfilled) && optionalBoolean(promise.released) && + !(promise.fulfilled && promise.released) && (promise.order === undefined || positive(promise.order)) && + promise.progress.every(payment => recipients.includes(payment.recipient)), + 'Retain a definite whole bribe answer and its own matching future payment progress.'); + return claim; +} + +/** Only the completed ordinary payer action calls this function. Past payments, + * gifts, special transfers and the later Mentat receipt never call it again. */ +export function recordBribeExpressionPayment(promises: readonly BribeExpressionPromise[], player: string, + turn: number, payment: BribeExpressionPayment): BribeExpressionPromise[] { + check(object(payment) && keys(payment, 'amount,recipient') && identifier(payment.recipient) && positive(payment.amount), + 'Retain the exact recipient and positive safe whole ordinary bribe payment.'); + const updates = new Map(); + for (const promise of liveBribeExpressionPromises(promises, player, turn)) { + const claim = readPromise(promise); + if (!bribeExpressionRecipients(claim).includes(payment.recipient)) continue; + const old = promise.progress.find(entry => entry.recipient === payment.recipient)?.amount ?? 0; + const next = old + payment.amount; + check(Number.isSafeInteger(next), 'The cumulative ordinary bribe amount exceeds the safe integer range.'); + const progress = promise.progress.map(entry => ({ ...entry })); + const entry = progress.find(entry => entry.recipient === payment.recipient); + if (entry) entry.amount = next; + else progress.push({ recipient: payment.recipient, amount: next }); + const satisfied = evaluate(claim, progress); + check(promise.answer || !satisfied, + 'This payment conflicts with your whole Truthtrance ordinary bribe No answer.'); + updates.set(promise, { ...promise, progress, ...(promise.answer && satisfied ? { fulfilled: true } : {}) }); + } + return promises.map(promise => updates.get(promise) ?? promise); +} + +function mergeBranches(left: BribeExpressionBranchLeaf[], right: BribeExpressionBranchLeaf[]): BribeExpressionBranchLeaf[] | null { + const merged = left.map(leaf => ({ ...leaf })); + for (const leaf of right) { + const same = merged.find(other => other.recipient === leaf.recipient && other.answer === leaf.answer); + if (same) same.minimum = leaf.answer ? Math.max(same.minimum, leaf.minimum) : Math.min(same.minimum, leaf.minimum); + else merged.push({ ...leaf }); + const yes = merged.find(other => other.recipient === leaf.recipient && other.answer); + const no = merged.find(other => other.recipient === leaf.recipient && !other.answer); + if (yes && no && yes.minimum >= no.minimum) return null; + } + return merged; +} + +/** Whole No uses De Morgan; a No to AND leaves alternatives. No route is stored, + * and each leaf retains its own answer interval for later joint composition. */ +export function* bribeExpressionBranches(promise: BribeExpressionPromise): Generator { + const expression = readPromise(promise); + if (promise.fulfilled || promise.released) { yield []; return; } + function* alternatives(node: BribeExpression, answer: boolean): Generator { + if (node.kind === 'payment') { + if (evaluate(node, promise.progress)) { if (answer) yield []; return; } + yield [{ recipient: node.recipient, minimum: node.minimum, + progress: promise.progress.find(entry => entry.recipient === node.recipient)?.amount ?? 0, answer }]; + return; + } + const conjunction = node.kind === 'and' ? answer : !answer; + if (!conjunction) { + for (const term of node.terms) yield* alternatives(term, answer); + return; + } + const terms = node.terms; + function* combine(index: number, prefix: BribeExpressionBranchLeaf[]): Generator { + if (index === terms.length) { yield prefix; return; } + for (const suffix of alternatives(terms[index], answer)) { + const merged = mergeBranches(prefix, suffix); + if (merged) yield* combine(index + 1, merged); + } + } + yield* combine(0, []); + } + if (promise.answer && evaluate(expression, promise.progress)) { yield []; return; } + yield* alternatives(expression, promise.answer); +} + +type Bounds = Map; +function addBound(bounds: Bounds, recipient: string, minimum: number, maximum: number): Bounds | null { + const old = bounds.get(recipient) ?? { minimum: 0, maximum: Number.MAX_SAFE_INTEGER }; + const next = { minimum: Math.max(old.minimum, minimum), maximum: Math.min(old.maximum, maximum) }; + if (next.minimum > next.maximum) return null; + const merged = new Map(bounds); + merged.set(recipient, next); + return merged; +} +function addLeaf(bounds: Bounds, leaf: BribeExpressionBranchLeaf): Bounds | null { + return addBound(bounds, leaf.recipient, leaf.answer ? Math.max(0, leaf.minimum - leaf.progress) : 0, + leaf.answer ? Number.MAX_SAFE_INTEGER - leaf.progress : leaf.minimum - leaf.progress - 1); +} + +/** Minimal additional payments per legal route across scalar and whole answers. + * Same-recipient requirements share payments; distinct recipients spend separate + * spice. The caller owns purse availability and actual ordinary payment timing. */ +export function* bribeCompletionCandidates(atomics: readonly BribePromise[], expressions: readonly BribeExpressionPromise[], + player: string, turn: number): Generator { + if (bribePromiseRequirements(atomics, player, turn) === null) return; + let initial: Bounds | null = new Map(); + for (const promise of liveBribePromises(atomics, player, turn)) { + initial = addLeaf(initial, promise); + if (!initial) return; + } + const live = liveBribeExpressionPromises(expressions, player, turn); + for (const promise of live) { + readPromise(promise); + // An allocation to another answer must also fit nonselected-branch ledgers. + for (const payment of promise.progress) { + initial = addBound(initial, payment.recipient, 0, Number.MAX_SAFE_INTEGER - payment.amount); + if (!initial) return; + } + } + const seen = new Set(); + function* combine(index: number, bounds: Bounds): Generator { + if (index === live.length) { + const payments: BribeExpressionPayment[] = []; + let total = 0; + for (const [recipient, { minimum }] of bounds) { + total += minimum; + if (!Number.isSafeInteger(total)) return; + if (minimum > 0) payments.push({ recipient, amount: minimum }); + } + const key = JSON.stringify([...payments].sort((left, right) => left.recipient.localeCompare(right.recipient))); + if (!seen.has(key)) { seen.add(key); yield payments; } + return; + } + for (const branch of bribeExpressionBranches(live[index])) { + let next: Bounds | null = bounds; + for (const leaf of branch) { + next = addLeaf(next, leaf); + if (!next) break; + } + if (next) yield* combine(index + 1, next); + } + } + yield* combine(0, initial); +} + +export function validateBribeExpressionPromises(promises: readonly BribeExpressionPromise[], players: readonly string[]): void { + check(Array.isArray(promises), 'The ordinary bribe expression commitment record is invalid.'); + const events = new Set(); + for (const promise of promises) { + const claim = readPromise(promise); + const satisfied = evaluate(claim, promise.progress); + check(identifier(promise.event) && !events.has(promise.event) && identifier(promise.player) && identifier(promise.asker) && + players.includes(promise.player) && players.includes(promise.asker) && promise.player !== promise.asker && + bribeExpressionRecipients(claim).every(recipient => players.includes(recipient) && recipient !== promise.player) && + (promise.answer || !satisfied) && (!promise.answer || promise.released || !!promise.fulfilled === satisfied), + 'The ordinary bribe expression lost its event, participants or whole-answer progress.'); + events.add(promise.event); + } +} diff --git a/game/engine.ts b/game/engine.ts index b5208f63..176f7607 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,6 @@ +import { BribeExpressionPromiseError, bribeExpressionRecipients, bribeCompletionCandidates, + liveBribeExpressionPromises, recordBribeExpressionPayment, validateBribeExpressionPromises, + type BribeExpressionClaim, type BribeExpressionPromise } from './bribe-expression-promises'; import { RevivalExpressionPromiseError, liveRevivalExpressionPromises, revivalExpressionBranches, evaluateRevivalExpression, recordRevivalExpressionReturn, validateRevivalExpressionPromises, type RevivalExpression, type RevivalExpressionClaim, type RevivalExpressionPromise, type RevivalExpressionReturn } from './revival-expression-promises'; @@ -7,7 +10,7 @@ import { LeaderRevivalPromiseError, liveLeaderRevivalPromises, leaderRevivalRequ import { AlliancePromiseError, alliancePromiseModeSupported, liveAlliancePromises, alliancePromiseRequirements, alliancePromisesMatch, findAlliancePromisePlan, validateAlliancePromises, type AllianceClaim, type AllianceOpportunity, type AlliancePromise } from './alliance-promises'; import { BribePromiseError, bribePromiseModeSupported, liveBribePromises, recordPromisedBribe, - bribePromiseRequirements, validateBribePromises, type BribeClaim, type BribePromise } from './bribe-promises'; + validateBribePromises, type BribeClaim, type BribePromise } from './bribe-promises'; import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income'; import { createRevivalOrder, validateRevivalOrder, revivalOrderBlock, startRevivalOrder, saphoRevivalOrderEvent, reorderSaphoRevivalOrder, @@ -2154,6 +2157,8 @@ export type Game = { /** Shared accepted order for typed force and named leader answers. */ revivalPromiseSequence?: string[]; bribePromises?: BribePromise[]; + bribeExpressionPromises?: BribeExpressionPromise[]; + bribePromiseSequence?: string[]; alliancePromises?: AlliancePromise[]; movementPromises?: MovementPromise[]; /** Server-only certainty of movement-relevant cards still in the discard. */ @@ -14932,6 +14937,12 @@ function normalNexusAllianceQuote(g: Game, p: Player, target: string | null) { 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.'); + if (liveBribeExpressionPromises(g.bribeExpressionPromises ?? [], p.id, g.turn).some(promise => promise.answer)) { + const future = structuredClone(g), actor = getPlayer(future, p.id); + actor.ally = other.id; + requireRule(!!bribePromiseCompletion(future, actor), + 'Pay the necessary ordinary bribe before offering an alliance that would prevent your whole answer.'); + } const blocked = homeworldAllianceReason(g, p.id, other.id); requireRule(!blocked, blocked ?? 'This alliance is unavailable.'); } @@ -14953,15 +14964,15 @@ function alliancePromisePlan(g: Game, p: Player, promises = g.alliancePromises ? // An already owed, currently payable bribe may precede accepting its named // recipient. Keep that payment on its existing guide and native action. const accepts = actions.find(action => action.target !== null)?.target; - if (accepts && liveBribePromises(trial.bribePromises ?? [], p.id, g.turn) - .some(promise => promise.answer && promise.recipient === accepts)) { + const future = structuredClone(trial); + if (accepts) getPlayer(future, p.id).ally = accepts; + if (accepts && hasBribePromises(trial, p.id, true) && !bribePromiseCompletion(future, getPlayer(future, p.id))) { const payment = bribePromiseCompletion(trial, actor)?.find(action => action.target === accepts); if (!payment) return null; const amount = payment.amount as number; actor.spice -= amount; getPlayer(trial, accepts).bribes += amount; - trial.bribePromises = bribePromiseRule(() => recordPromisedBribe(trial.bribePromises ?? [], p.id, - g.turn, { recipient: accepts, amount })); + recordBribePayment(trial, p.id, accepts, amount); bribePreparation = true; } for (const action of actions) { @@ -15059,60 +15070,80 @@ function projectedAlliancePromiseGuide(g: Game, player: string): { action: Actio const action: Action = plan.actions[0] ?? { type: 'ready' }; return { action: { ...action, alliancePromiseEvent: promise.event } }; } -function bribePromiseRule(calculate: () => T): T { - try { return calculate(); } - catch (error) { if (error instanceof BribePromiseError) throw new RuleError(error.message); throw error; } +function bribePromiseRule(run: () => T): T { + try { return run(); } + catch (error) { + if (error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError) throw new RuleError(error.message); + throw error; + } } function bribePromiseIntegrity(g: Game) { - bribePromiseRule(() => validateBribePromises(g.bribePromises ?? [], g.players.map(player => player.id))); + bribePromiseRule(() => { + const players = g.players.map(player => player.id); + validateBribePromises(g.bribePromises ?? [], players); + validateBribeExpressionPromises(g.bribeExpressionPromises ?? [], players); + }); + const events = [...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? [])].map(promise => promise.event); + requireRule(new Set(events).size === events.length, 'Keep each bribe answer bound to its original event.'); +} +function hasBribePromises(g: Game, player: string, positive = false): boolean { + return [...liveBribePromises(g.bribePromises ?? [], player, g.turn), + ...liveBribeExpressionPromises(g.bribeExpressionPromises ?? [], player, g.turn)] + .some(promise => !positive || promise.answer); +} +function recordBribePayment(g: Game, player: string, recipient: string, amount: number) { + if (amount === 0) return; + bribePromiseRule(() => { + if (g.bribePromises?.length) g.bribePromises = recordPromisedBribe(g.bribePromises, player, g.turn, { recipient, amount }); + if (g.bribeExpressionPromises?.length) g.bribeExpressionPromises = recordBribeExpressionPayment( + g.bribeExpressionPromises, player, g.turn, { recipient, amount }); + }); } 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 { +/** Shared funded completion; alternatives are quotations and never become a + * stored route. Record its future payments before checking the remaining purse + * so a satisfied bribe is not reserved for a second time by another family. */ +function bribePromiseCompletion(g: Game, p: Player, promises = g.bribePromises ?? [], + expressions = g.bribeExpressionPromises ?? []): 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)); + for (const required of bribePromiseRule(() => bribeCompletionCandidates(promises, expressions, p.id, g.turn))) { + if (!required.length) return []; + const trial = structuredClone(g), actor = getPlayer(trial, p.id); + trial.truthtrance = null; + if (bribeTimingBlock(trial)) return null; + const total = required.reduce((sum, payment) => sum + payment.amount, 0); + if (!Number.isSafeInteger(total) || total > uncommittedSpice(trial, actor)) continue; + trial.bribePromises = structuredClone(promises); + trial.bribeExpressionPromises = structuredClone(expressions); + try { + for (const payment of required) { + if (payment.recipient === p.id || payment.recipient === p.ally || + !g.players.some(player => player.id === payment.recipient)) throw new RuleError('Choose a non-allied bribe recipient.'); + recordBribePayment(trial, p.id, payment.recipient, payment.amount); + } + actor.spice -= total; + const 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)) throw error; } } - 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; } + return null; } function bribeCompatibleLaterPromises(g: Game, p: Player, later: Pick, 'shipmentPromises' | 'movementPromises' | 'revivalPromises' | 'leaderRevivalPromises' | 'revivalExpressionPromises' | 'battle'>): boolean { - if (!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) return true; - return !!bribePromiseCompletion({ ...g, ...later }, p); + return !hasBribePromises(g, p.id, true) || !!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 || @@ -15121,37 +15152,65 @@ function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no' { ...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 }); +function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionClaim): ('yes' | 'no' | 'unknown')[] { + if (!bribeQuestionWindow(g) || claim.turn !== g.turn || bribeExpressionRecipients(claim.claim) + .some(recipient => recipient === p.id || recipient === p.ally || !g.players.some(player => player.id === recipient))) return []; + return (['yes', 'no'] as const).filter(answer => !!bribePromiseCompletion(g, p, g.bribePromises ?? [], + [...(g.bribeExpressionPromises ?? []), { ...claim, event: 'candidate', player: p.id, + asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: [] }])); +} +function reopenBribePayer(g: Game, p: Player, answer: boolean) { if (answer && (g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g)) && g.ready.includes(p.id)) { g.ready = g.ready.filter(player => player !== p.id); log(g, `${p.name} has a new Truthtrance bribe commitment and must finish ${g.phase === 8 ? 'Mentat Pause' : 'Collection'} again after paying it.`); } } +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.'); + const promise: BribePromise = { ...claim, event: crypto.randomUUID(), player: p.id, asker, answer, progress: 0 }; + (g.bribePromises ??= []).push(promise); + (g.bribePromiseSequence ??= []).push(promise.event); + reopenBribePayer(g, p, answer); +} +function bindBribeExpressionTruth(g: Game, p: Player, claim: BribeExpressionClaim, answer: boolean, asker: string) { + requireRule(bribeExpressionTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'), + 'Choose a whole bribe answer compatible with current funds and earlier answers.'); + const promise: BribeExpressionPromise = { ...structuredClone(claim), event: crypto.randomUUID(), player: p.id, asker, answer, progress: [] }; + (g.bribeExpressionPromises ??= []).push(promise); + (g.bribePromiseSequence ??= []).push(promise.event); + reopenBribePayer(g, p, answer); +} function reconcileBribePromises(g: Game, voluntary?: { actor: string; action: Action }) { - if (!g.bribePromises?.some(promise => !promise.fulfilled && !promise.released)) return; + type Promise = BribePromise | BribeExpressionPromise; + const pendingPromises: Promise[] = [...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? [])] + .filter(promise => !promise.fulfilled && !promise.released); + if (!pendingPromises.length) return; bribePromiseIntegrity(g); if (g.truthtrance) return; - const kept: BribePromise[] = []; - const no = g.bribePromises.filter(promise => !promise.answer && !promise.fulfilled && !promise.released); + const sequence = g.bribePromiseSequence ?? []; + pendingPromises.sort((a, b) => sequence.indexOf(a.event) - sequence.indexOf(b.event)); + const no = pendingPromises.filter(promise => !promise.answer), kept: Promise[] = []; + const isExpression = (promise: Promise): promise is BribeExpressionPromise => 'claim' in promise; 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))), + const available = Math.max(0, uncommittedSpice(g, getPlayer(g, voluntary.actor))); + let affordable = false; + for (const required of bribeCompletionCandidates(g.bribePromises ?? [], g.bribeExpressionPromises ?? [], voluntary.actor, g.turn)) { + if (required.reduce((sum, payment) => sum + payment.amount, 0) <= available) { affordable = true; break; } + } + requireRule(affordable, '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; + for (const promise of pendingPromises) { 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.'); + const considered = [...no, ...kept, promise]; + if (!same || !bribePromiseCompletion(g, getPlayer(g, promise.player), + considered.filter((p): p is BribePromise => !isExpression(p)), considered.filter(isExpression))) { + 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); @@ -15161,56 +15220,42 @@ function projectedBribePromiseGuide(g: Game, player: string): { action: Action } 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; + const candidates = [...liveBribePromises(g.bribePromises ?? [], player, g.turn), + ...liveBribeExpressionPromises(g.bribeExpressionPromises ?? [], player, g.turn)] + .filter(promise => promise.answer && ('claim' in promise ? bribeExpressionRecipients(promise.claim).includes(action.target as string) + : promise.recipient === action.target)); + const sequence = g.bribePromiseSequence ?? []; + candidates.sort((a, b) => sequence.indexOf(a.event) - sequence.indexOf(b.event)); + return candidates[0] ? { action: { ...action, bribePromiseEvent: candidates[0].event } } : null; } 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 }); + recordBribePayment(trial, p.id, recipient, amount); actor.spice -= amount; - return !!bribePromiseCompletion(trial, actor); - } catch (error) { if (error instanceof BribePromiseError || error instanceof RuleError) return false; throw error; } + const voluntary = { actor: p.id, action: { type: 'bribe' } }; + reconcileBattlePromises(trial, voluntary); + reconcileShipmentPromises(trial, voluntary); + if (liveMovementPromises(trial.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer) && + !groundMovementCompletion(trial, p.id)) return false; + reconcileRevivalPromises(trial, voluntary); + return !hasBribePromises(trial, p.id) || !!bribePromiseCompletion(trial, actor); + } catch (error) { if (error instanceof BribeExpressionPromiseError || 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); - const battlePromises = g.battle?.truthPromises?.some(promise => - 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 available = Math.max(0, uncommittedSpice(g, p)), blocked = bribeTimingBlock(g); + const constrained = hasBribePromises(g, p.id) || + g.battle?.truthPromises?.some(promise => promise.player === p.id && !promise.released && !g.battle?.revealed && !g.battle?.plans[p.id]) || + liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length || + liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer) || + liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) || liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) || liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], 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 || revivalPromises) - ? maximumBribe(available, amount => { - const probe = structuredClone(g); - getPlayer(probe, p.id).spice -= amount; - try { - 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; - throw error; - } - }) - : available; - return { - available, incoming: p.bribes, blocked, + return { 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.'; + let limit = !blocked && constrained ? maximumBribe(available, + amount => bribePaymentPreservesPromises(g, p, target.id, amount)) : available; + let limitation: string | null = limit < available ? 'Your Truthtrance answers limit this recipient’s payment.' : null; if (!blocked && limit >= 5) { try { checkBureaucratContributors(g, target.id, [{ payer: p.id, amount: limit }]); } catch (error) { @@ -15417,7 +15462,7 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? [] liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length || liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length || liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length || - liveBribePromises(g.bribePromises ?? [], p.id, g.turn).length) { + hasBribePromises(g, p.id)) { const trial = structuredClone(g); trial.truthtrance = null; trial.auction!.bid = action.amount as number; @@ -30517,7 +30562,7 @@ function guardPromisedLeaderRevival(g: Game, p: Player, action: Action, !liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length && !liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length && !liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length && - !liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) return; + !hasBribePromises(g, p.id, true)) return; if (!quote) { requireRule(leaderRevivalPromisesSatisfied(g.leaderRevivalPromises ?? [], p.id, g.turn, []), 'Complete the named leader returns required by your Truthtrance Yes before finishing Revival.'); @@ -30614,7 +30659,7 @@ function guardPromisedRevival(g: Game, p: Player, action: Action, revival?: Pend 'This revival commitment guide is stale.'); if (!live.length && !liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length && !liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length && - !liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) return; + !hasBribePromises(g, p.id, true)) return; if (!revival) { requireRule(revivalPromisesSatisfied(g.revivalPromises ?? [], p.id, g.turn, { amount: 0, elite: 0 }), 'Complete the force returns required by your Truthtrance Yes before finishing Revival.'); @@ -36259,6 +36304,8 @@ function applyActionInner( bindAlliance: bindAllianceTruth, bribeAnswers: bribeTruthAnswers, bindBribe: bindBribeTruth, + bribeExpressionAnswers: bribeExpressionTruthAnswers, + bindBribeExpression: bindBribeExpressionTruth, revivalExpressionAnswers: revivalExpressionTruthAnswers, bindRevivalExpression: bindRevivalExpressionTruth, leaderRevivalAnswers: leaderRevivalTruthAnswers, @@ -36276,7 +36323,7 @@ function applyActionInner( ) return g; } catch (error) { - if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError) + if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError) throw new RuleError(error.message); throw error; } @@ -38576,8 +38623,7 @@ 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 })); + recordBribePayment(g, p.id, target.id, n); p.spice -= n; log( g, @@ -38749,7 +38795,7 @@ function applyActionInner( if (t === 'bid') { const quotedBid = normalBidQuote(g, p, action.amount, action.allyPayment); if (liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) || - liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) { + hasBribePromises(g, p.id, true)) { const trial = structuredClone(g); trial.auction!.bid = quotedBid.amount; trial.auction!.bidder = p.id; @@ -39901,7 +39947,7 @@ function applyActionInner( if (t === 'ready') { if (allianceQuestionWindow(g)) guardAlliancePromiseReady(g, p); const bribeWindowCloses = g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g); - if (bribeWindowCloses) requireRule(!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer), + if (bribeWindowCloses) requireRule(!hasBribePromises(g, p.id, true), g.phase === 8 ? 'Complete your Truthtrance bribe payments before finishing Mentat Pause.' : 'Pay your Truthtrance bribes before the known Inflation Double opening prevents them.'); if (g.phase === 4) { guardPromisedRevival(g, p, action); guardPromisedLeaderRevival(g, p, action); guardRevivalExpressionReady(g, p); } @@ -40112,6 +40158,10 @@ export function viewGame(state: Game, id: string) { 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), + truthBribeExpressionTargets: bribeQuestionWindow(g) ? g.players.map(player => player.id) : [], + truthBribeExpressionAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && + g.truthtrance.question.kind === 'bribeExpression' ? bribeExpressionTruthAnswers(g, me, g.truthtrance.question) : null, + bribeExpressionPromises: (g.bribeExpressionPromises ?? []).filter(promise => promise.player === id).map(promise => structuredClone(promise)), 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 4415d725..146a80f9 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3258,6 +3258,31 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Verification', status: 'Partial', detail: 'Focused pure, original deal/payment/Mentat/next-turn runtime and controlled/native UI evidence. Native alliance campaigns, optional compositions, future preparations and final browser/deployed acceptance remain open.', evidence: ['tests/fixture-mentat-bribes.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/fixture-truth-bribe.ts', 'tests/truth-bribe-runtime.test.ts', 'tests/truth-bribe-controls.test.tsx'] }, ], }, + { + id: 'truthtrance-bribe-expression', + title: 'Truthtrance: combined ordinary bribe payments', + category: 'Cards', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Combine cumulative future ordinary payments with full AND/OR while keeping whole No, dynamic alternatives and the original shared purse.', + steps: [ + 'Choose Combine ordinary bribe payments with AND/OR. Add, wrap, remove or nest named non-allied recipient/minimum conditions. AND requires every child; OR at least one. The complete editor supports sixteen leaves and depth four; these are software input limits.', + 'Each leaf counts actual matching ordinary payer payments after this answer until the current turn ends. Split payments add together. Earlier payments, gifts, allied aid and later recipient collection do not count. Repeated conditions for one recipient share that answer’s cumulative progress.', + 'No negates the whole expression. No to paying A AND B may permit either partial route, while forbidding completion of both; No to A OR B forbids either qualifying route. A Yes becomes fulfilled only when actual payments make the whole expression true.', + 'The server rebuilds currently funded alternatives after each action; no suggested OR route is saved. Several questions and earlier scalar bribe, bid, revival, shipment, movement and battle commitments keep their actual budgets and accepted order. Unknown future income or foreign agreement is not assumed.', + 'Persistent own progress and the exact ordinary-payment guide connect to native payments and all four minimal policies. Another legal route may be paid manually. Stale, modified or foreign guide packets reject; rejected actions leave the original state intact.', + 'Quiet phases0–8 in registered bare native Basic/Advanced support this prototype. Yes blocks finishing Mentat; a new Yes reopens only its already-ready payer. Known automatic Inflation Double closure retains the earlier Collection deadline. No lasts until actual turn advance. Late Mentat receipts wait for next turn’s opening.', + 'This cumulative funded interval and accepted-order composition is provisional application of general Truthtrance authority. Optional modules, future preparations, cross-family expressions and final browser/deployed acceptance remain unfinished.', + ], + related: ['card-truthtrance', 'truthtrance-bribe', 'bribes', 'truthtrance-revival-expression', 'implementation-checklist'], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Strict full-tree parsing, whole-answer progress and lazy scalar/expression allocations use actual native payments and one shared purse.', evidence: ['game/bribe-expression-promises.ts', 'game/truthtrance.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Full controlled expression editor, public preview, private answers, persistent own progress and exact native guide are connected.', evidence: ['components/bribe-expression-fields.tsx', 'components/bribe-expression-promises.tsx', 'components/bribe-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer the whole question and execute native legal payments. Strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-bribe-expression-runtime.test.ts'] }, + { area: 'Documentation', status: 'Implemented', detail: 'Source authority, cumulative interpretation, funding, original receipt timing and remaining scope are explicit.', evidence: ['docs/TRUTHTRANCE_BRIBE_EXPRESSIONS.md', 'docs/RULE_DECISIONS.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Focused pure, controlled editor/guide and original native payment cases; broader compositions and full mode acceptance remain open.', evidence: ['tests/bribe-expression-promises.test.ts', 'tests/truth-bribe-expression-controls.test.tsx', 'tests/fixture-truth-bribe-expression.ts', 'tests/truth-bribe-expression-runtime.test.ts', 'tests/truth-bribe-expression-funding.test.ts'] }, + ], + }, { id: 'truthtrance-revival-expression', title: 'Truthtrance: grouped force and leader revivals', @@ -3520,6 +3545,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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 current-Nexus alliance question binds actual final membership with a named partner at that Nexus close. Yes uses existing partnership or an actual invitation; offers alone do not form alliances. No may require a later break before Ready. Unknown can save the card until consent exists. Other players retain their original withdrawal/break rights. See the alliance-promise guide for its provisional interval and scope.', '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.', + 'A grouped ordinary-bribe question combines named future payment minima with full AND/OR. No negates the whole question; real payments rebuild legal funded alternatives, and next-opening recipient collection adds no new progress. See the combined bribe guide for its provisional interval and scope.', 'A grouped Revival question combines future ordinary force minima and named native leader/Kwisatz returns with full AND/OR. No negates the whole expression, not every leaf. Legal alternatives are rebuilt after actual returns without fixing one branch. See the grouped Revival guide for input limits, source and provisional scope.', 'A named leader-revival question binds an actual future personal native return before your Revival opportunity ends, including Kwisatz where eligible. Force/leader promises share costs and slots; prior/card/special/foreign-ghola returns do not count. See the named-leader guide for provisional scope and preparation.', '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.', @@ -3544,6 +3570,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'truthtrance-leader-revival', 'truthtrance-revival-expression', 'truthtrance-bribe', + 'truthtrance-bribe-expression', 'truthtrance-alliance', 'richese-no-field', 'card-reinforcements', diff --git a/game/truthtrance.ts b/game/truthtrance.ts index f3587f2f..8daecc63 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1,3 +1,5 @@ +import { parseBribeExpressionClaim, bribeExpressionRecipients, bribeExpressionText, + type BribeExpressionClaim } from './bribe-expression-promises'; import { parseRevivalExpressionClaim, revivalExpressionText, revivalExpressionPromiseModeSupported, type RevivalExpressionClaim } from './revival-expression-promises'; import { parseLeaderRevivalClaim, leaderRevivalClaimText, leaderRevivalPromiseModeSupported, type LeaderRevivalClaim } from './leader-revival-promises'; import { parseAllianceClaim, allianceClaimText, alliancePromiseModeSupported, type AllianceClaim } from './alliance-promises'; @@ -65,6 +67,7 @@ export type TruthFact = | { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number } | { kind: 'and' | 'or'; terms: TruthFact[] }; export type TruthQuestion = + | ({ kind: 'bribeExpression'; target: string } & BribeExpressionClaim) | ({ kind: 'revivalExpression'; target: string } & RevivalExpressionClaim) | ({ kind: 'leaderRevival'; target: string } & LeaderRevivalClaim) | ({ kind: 'alliance'; target: string } & AllianceClaim) @@ -252,6 +255,18 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { 'Choose the current ordinary Nexus and another named partner in a supported table.'); return { kind: 'alliance', target: v.target, ...claim }; } + if (v.kind === 'bribeExpression') { + check(Object.keys(v).sort().join(',') === 'claim,kind,target,turn', + 'Supply the current turn, complete bribe expression and respondent.'); + const claim = parseBribeExpressionClaim({ turn: v.turn, claim: v.claim }); + const 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 && bribeExpressionRecipients(claim.claim).every(recipient => + recipient !== respondent.id && recipient !== respondent.ally && g.players.some(player => player.id === recipient)) && + !g.response && !g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama, + 'Choose current-turn ordinary bribe statements to non-allied participants in a supported table.'); + return { kind: 'bribeExpression', target: v.target, ...claim }; + } 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.'); @@ -514,6 +529,7 @@ export function truthQuestionText( if (q.kind === 'revivalExpression') return `Will you ${revivalExpressionText(q.claim, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))} from now until your Revival opportunity ends this turn?`; if (q.kind === 'leaderRevival') return `Will you ${leaderRevivalClaimText(q, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))}?`; if (q.kind === 'alliance') return `Will you ${allianceClaimText(q, playerName)}?`; + if (q.kind === 'bribeExpression') return `Will you ${bribeExpressionText(q.claim, playerName)} from now until this turn ends?`; 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)}?`; @@ -564,6 +580,8 @@ export function resolveTruthAction( bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void; allianceAnswers?: (g: Game, p: Player, claim: AllianceClaim) => TruthAnswer[]; bindAlliance?: (g: Game, p: Player, claim: AllianceClaim, answer: boolean, asker: string) => void; + bribeExpressionAnswers?: (g: Game, p: Player, claim: BribeExpressionClaim) => TruthAnswer[]; + bindBribeExpression?: (g: Game, p: Player, claim: BribeExpressionClaim, 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; revivalExpressionAnswers?: (g: Game, p: Player, claim: RevivalExpressionClaim) => TruthAnswer[]; @@ -701,6 +719,9 @@ export function resolveTruthAction( if (w.question.kind === 'alliance') check(effects.allianceAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, 'Choose a current Nexus question with a compatible answer.'); + if (w.question.kind === 'bribeExpression') + check(effects.bribeExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, + 'Choose a whole bribe expression compatible with earlier answers.'); 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.'); @@ -775,6 +796,11 @@ export function resolveTruthAction( 'Choose an answer consistent with actual alliance consent and earlier commitments.'); if (a.answer !== 'unknown') effects.bindAlliance(g, p, q, a.answer === 'yes', current.player); } + if (q.kind === 'bribeExpression') { + check(effects.bribeExpressionAnswers && effects.bindBribeExpression && effects.bribeExpressionAnswers(g, p, q).includes(a.answer), + 'Choose a whole bribe answer consistent with actual funding and earlier promises.'); + if (a.answer !== 'unknown') effects.bindBribeExpression(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.'); diff --git a/tests/bribe-expression-promises.test.ts b/tests/bribe-expression-promises.test.ts new file mode 100644 index 00000000..0114ea7f --- /dev/null +++ b/tests/bribe-expression-promises.test.ts @@ -0,0 +1,235 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + BRIBE_EXPRESSION_MAX_DEPTH, + BRIBE_EXPRESSION_MAX_LEAVES, + BribeExpressionPromiseError, + bribeCompletionCandidates, + bribeExpressionBranches, + bribeExpressionRecipients, + bribeExpressionText, + evaluateBribeExpression, + liveBribeExpressionPromises, + parseBribeExpression, + parseBribeExpressionClaim, + recordBribeExpressionPayment, + validateBribeExpressionPromises, + type BribeExpression, + type BribeExpressionProgress, + type BribeExpressionPromise, +} from '../game/bribe-expression-promises'; +import { type BribePromise } from '../game/bribe-promises'; + +const seats = ['payer', 'asker', 'a', 'b', 'c', 'other-payer']; +const pay = (recipient = 'a', minimum = 3): BribeExpression => ({ kind: 'payment', recipient, minimum }); +const group = (kind: 'and' | 'or', ...terms: BribeExpression[]): BribeExpression => ({ kind, terms }); +const progress = (recipient: string, amount: number): BribeExpressionProgress => [{ recipient, amount }]; +const promise = (event: string, claim: BribeExpression = pay(), extra: Partial = {}): BribeExpressionPromise => + ({ event, turn: 2, claim, player: 'payer', asker: 'asker', answer: true, progress: [], ...extra }); +const atomic = (event: string, recipient = 'a', minimum = 3, extra: Partial = {}): BribePromise => + ({ event, turn: 2, recipient, minimum, player: 'payer', asker: 'asker', answer: true, progress: 0, ...extra }); +const candidates = (atomics: BribePromise[], expressions: BribeExpressionPromise[]) => + [...bribeCompletionCandidates(atomics, expressions, 'payer', 2)]; + +void test('bribe expressions accept sixteen leaves, four group levels and safe spice minima without a gameplay cap', () => { + assert.equal(BRIBE_EXPRESSION_MAX_DEPTH, 4); assert.equal(BRIBE_EXPRESSION_MAX_LEAVES, 16); + const sixteen = group('and', ...Array.from({ length: 16 }, (_, index) => pay(`recipient-${index}`, index + 1))); + const parsed = parseBribeExpression(sixteen); + assert.deepEqual(parsed, sixteen); assert.notEqual(parsed, sixteen); + if ('terms' in parsed && 'terms' in sixteen) { + assert.notEqual(parsed.terms, sixteen.terms); assert.notEqual(parsed.terms[0], sixteen.terms[0]); + } + const nested = (depth: number): BribeExpression => depth === 0 ? pay('a', 1) : group('or', pay('b'), nested(depth - 1)); + assert.deepEqual(parseBribeExpression(nested(4)), nested(4)); + assert.throws(() => parseBribeExpression(nested(5)), BribeExpressionPromiseError); + assert.throws(() => parseBribeExpression(group('or', ...Array.from({ length: 17 }, () => pay()))), BribeExpressionPromiseError); + assert.throws(() => parseBribeExpression(group('and', group('or', ...Array.from({ length: 9 }, () => pay())), + group('or', ...Array.from({ length: 8 }, () => pay('b'))))), BribeExpressionPromiseError); + assert.deepEqual(parseBribeExpressionClaim({ turn: Number.MAX_SAFE_INTEGER, claim: pay('a', Number.MAX_SAFE_INTEGER) }), + { turn: Number.MAX_SAFE_INTEGER, claim: pay('a', Number.MAX_SAFE_INTEGER) }); + assert.deepEqual(parseBribeExpression(pay('a', 1001)), pay('a', 1001)); +}); + +void test('strict parsing validates decisive later subtrees, exact node fields and all safe positive minima', () => { + const invalid: unknown[] = [null, [], {}, { kind: 'and', terms: [] }, { kind: 'or', terms: [pay()] }, + { kind: 'and', terms: [pay(), pay('b')], extra: true }, + { kind: 'payment', recipient: ' a', minimum: 1 }, { kind: 'payment', recipient: '', minimum: 1 }, + { kind: 'payment', recipient: 'a', minimum: 1, progress: 0 }, { kind: 'not', terms: [pay(), pay()] }, + ...[0, -1, 0.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '3'].map(minimum => + ({ kind: 'payment', recipient: 'a', minimum }))]; + for (const input of invalid) assert.throws(() => parseBribeExpression(input), BribeExpressionPromiseError); + const decisive = group('or', pay('a', 1), { kind: 'payment', recipient: 'b', minimum: 0 }); + assert.throws(() => evaluateBribeExpression(decisive, progress('a', 1)), BribeExpressionPromiseError); + assert.throws(() => evaluateBribeExpression(group('and', pay('a', 1), decisive), []), BribeExpressionPromiseError); + assert.throws(() => [...bribeExpressionBranches(promise('bad-later', decisive, { progress: progress('a', 1) }))], + BribeExpressionPromiseError); + for (const input of [{ turn: 0, claim: pay() }, { turn: '2', claim: pay() }, { turn: 2, claim: pay(), privateSpice: 9 }, + { turn: 2 }, { claim: pay() }]) assert.throws(() => parseBribeExpressionClaim(input), BribeExpressionPromiseError); +}); + +void test('evaluation, public text and recipients retain nested whole meaning with exact recipient identities', () => { + const claim = group('and', pay('a', 3), group('or', pay('b', 2), pay('a', 6))); + assert.deepEqual(bribeExpressionRecipients(claim), ['a', 'b']); + assert.equal(evaluateBribeExpression(claim, progress('a', 3)), false); + assert.equal(evaluateBribeExpression(claim, progress('a', 6)), true); + assert.equal(evaluateBribeExpression(claim, [...progress('a', 3), ...progress('b', 2)]), true); + assert.equal(bribeExpressionText(claim, id => id.toUpperCase()), + '(pay at least 3 spice to A in ordinary bribes AND (pay at least 2 spice to B in ordinary bribes OR pay at least 6 spice to A in ordinary bribes))'); +}); + +void test('whole Yes keeps dynamic OR alternatives after partial payment and allows either actual route', () => { + const original = promise('choice', group('or', pay('a', 3), pay('b', 2))); + assert.deepEqual(candidates([], [original]), [[{ recipient: 'a', amount: 3 }], [{ recipient: 'b', amount: 2 }]]); + const partial = recordBribeExpressionPayment([original], 'payer', 2, { recipient: 'a', amount: 1 }); + assert.deepEqual(partial[0].progress, progress('a', 1)); assert.equal(partial[0].fulfilled, undefined); + assert.deepEqual(candidates([], partial), [[{ recipient: 'a', amount: 2 }], [{ recipient: 'b', amount: 2 }]]); + assert.equal(recordBribeExpressionPayment(partial, 'payer', 2, { recipient: 'a', amount: 2 })[0].fulfilled, true); + const byB = recordBribeExpressionPayment(partial, 'payer', 2, { recipient: 'b', amount: 2 }); + assert.equal(byB[0].fulfilled, true); assert.deepEqual([...bribeExpressionBranches(byB[0])], [[]]); + assert.deepEqual(original.progress, []); assert.deepEqual(partial[0].progress, progress('a', 1)); +}); + +void test('whole No to nested AND permits a completed conjunct and rejects completing either remaining alternative', () => { + const original = promise('not-all', group('and', pay('a', 2), group('or', pay('b', 3), pay('c', 4))), { answer: false }); + assert.deepEqual([...bribeExpressionBranches(original)], [ + [{ recipient: 'a', minimum: 2, progress: 0, answer: false }], + [{ recipient: 'b', minimum: 3, progress: 0, answer: false }, { recipient: 'c', minimum: 4, progress: 0, answer: false }], + ]); + const partial = recordBribeExpressionPayment([original], 'payer', 2, { recipient: 'a', amount: 2 }); + assert.equal(partial[0].fulfilled, undefined); + assert.deepEqual([...bribeExpressionBranches(partial[0])], [[ + { recipient: 'b', minimum: 3, progress: 0, answer: false }, { recipient: 'c', minimum: 4, progress: 0, answer: false }, + ]]); + assert.throws(() => recordBribeExpressionPayment(partial, 'payer', 2, { recipient: 'b', amount: 3 }), BribeExpressionPromiseError); + assert.throws(() => recordBribeExpressionPayment(partial, 'payer', 2, { recipient: 'c', amount: 4 }), BribeExpressionPromiseError); + const opposite = recordBribeExpressionPayment([original], 'payer', 2, { recipient: 'b', amount: 3 }); + assert.throws(() => recordBribeExpressionPayment(opposite, 'payer', 2, { recipient: 'a', amount: 2 }), BribeExpressionPromiseError); +}); + +void test('whole No to nested OR forbids its first leaf but allows one child of the remaining AND', () => { + const original = promise('not-either', group('or', pay('a', 2), group('and', pay('b', 3), pay('c', 4))), { answer: false }); + assert.throws(() => recordBribeExpressionPayment([original], 'payer', 2, { recipient: 'a', amount: 2 }), BribeExpressionPromiseError); + const partial = recordBribeExpressionPayment([original], 'payer', 2, { recipient: 'b', amount: 3 }); + assert.doesNotThrow(() => recordBribeExpressionPayment(partial, 'payer', 2, { recipient: 'a', amount: 1 })); + assert.throws(() => recordBribeExpressionPayment(partial, 'payer', 2, { recipient: 'c', amount: 4 }), BribeExpressionPromiseError); + assert.deepEqual(candidates([atomic('needs-c', 'c', 4)], partial), []); + const noOr = promise('neither', group('or', pay('a', 2), pay('b', 3)), { answer: false }); + assert.deepEqual(candidates([atomic('needs-a', 'a', 2)], [noOr]), []); + assert.deepEqual(candidates([], [noOr]), [[]]); +}); + +void test('same-recipient conjuncts share cumulative payments while different recipients require separate spice', () => { + const original = promise('share', group('and', pay('a', 3), pay('a', 5), pay('b', 2))); + assert.deepEqual(candidates([], [original]), [[{ recipient: 'a', amount: 5 }, { recipient: 'b', amount: 2 }]]); + const partial = recordBribeExpressionPayment([original], 'payer', 2, { recipient: 'a', amount: 3 }); + assert.deepEqual(candidates([], partial), [[{ recipient: 'a', amount: 2 }, { recipient: 'b', amount: 2 }]]); + assert.deepEqual([...bribeExpressionBranches(promise('same-cap', group('or', pay('a', 3), pay('a', 5)), { answer: false }))], + [[{ recipient: 'a', minimum: 3, progress: 0, answer: false }]]); +}); + +void test('branches simplify already true leaves and keep whole No alternatives without a stored route', () => { + const partial = promise('partly-true', group('and', pay('a', 3), pay('b', 2)), { progress: progress('a', 3) }); + assert.deepEqual([...bribeExpressionBranches(partial)], [[{ recipient: 'b', minimum: 2, progress: 0, answer: true }]]); + assert.deepEqual([...bribeExpressionBranches({ ...partial, answer: false })], + [[{ recipient: 'b', minimum: 2, progress: 0, answer: false }]]); + const trueOr = promise('already-true', group('or', pay('a', 3), pay('b', 2)), { progress: progress('a', 3) }); + assert.deepEqual([...bribeExpressionBranches(trueOr)], [[]]); + assert.deepEqual([...bribeExpressionBranches({ ...trueOr, answer: false })], []); + const alternatives = promise('still-lazy', group('or', pay('a', 3), pay('b', 2))); + const first = bribeExpressionBranches(alternatives); + assert.deepEqual(first.next().value, [{ recipient: 'a', minimum: 3, progress: 0, answer: true }]); + assert.equal('branch' in alternatives, false); + const next = recordBribeExpressionPayment([alternatives], 'payer', 2, { recipient: 'b', amount: 2 }); + assert.equal(next[0].fulfilled, true); assert.equal('branch' in next[0], false); +}); + +void test('joint scalar and expression routes preserve each answer interval, caps and distinct recipient budgets', () => { + const older = atomic('old-a', 'a', 8, { progress: 3 }); + const later = promise('later-route', group('or', pay('a', 5), pay('b', 3))); + assert.deepEqual(candidates([older], [later]), [ + [{ recipient: 'a', amount: 5 }], [{ recipient: 'a', amount: 5 }, { recipient: 'b', amount: 3 }], + ]); + const cap = atomic('a-cap', 'a', 9, { progress: 4, answer: false }); + assert.deepEqual(candidates([older, cap], [later]), []); + assert.deepEqual(candidates([cap], [later]), [[{ recipient: 'b', amount: 3 }]]); + const notBoth = promise('not-both', group('and', pay('a', 5), pay('b', 3)), { answer: false }); + assert.deepEqual(candidates([older], [notBoth]), [[{ recipient: 'a', amount: 5 }]]); + assert.deepEqual(candidates([older, atomic('also-b', 'b', 3)], [notBoth]), []); + const second = promise('needs-c', group('and', pay('a', 2), pay('c', 4))); + assert.deepEqual(candidates([older], [later, second]), [ + [{ recipient: 'a', amount: 5 }, { recipient: 'c', amount: 4 }], + [{ recipient: 'a', amount: 5 }, { recipient: 'b', amount: 3 }, { recipient: 'c', amount: 4 }], + ]); + assert.deepEqual(candidates([], [promise('repeat-route', group('or', pay('a'), pay('a')))]), [[{ recipient: 'a', amount: 3 }]]); +}); + +void test('new answers exclude prior payments and terminal, other-payer, turn and recipient entries retain references', () => { + const fulfilled = recordBribeExpressionPayment([promise('earlier', pay('a', 2))], 'payer', 2, { recipient: 'a', amount: 2 })[0]; + const later = promise('new-answer', pay('a', 2)); + const records = [fulfilled, promise('released', pay(), { released: true }), promise('old-turn', pay(), { turn: 1 }), + promise('other', pay(), { player: 'other-payer' }), promise('other-recipient', pay('b')), later]; + assert.deepEqual(liveBribeExpressionPromises(records, 'payer', 2), [records[4], later]); + assert.deepEqual(candidates([], [fulfilled, later]), [[{ recipient: 'a', amount: 2 }]]); + const paid = recordBribeExpressionPayment(records, 'payer', 2, { recipient: 'a', amount: 1 }); + for (let index = 0; index < 5; index++) assert.equal(paid[index], records[index]); + assert.deepEqual(paid[5].progress, progress('a', 1)); assert.deepEqual(later.progress, []); + const unrelated = recordBribeExpressionPayment(records, 'absent', 2, { recipient: 'a', amount: 1 }); + records.forEach((record, index) => assert.equal(unrelated[index], record)); +}); + +void test('rejected ordinary payments leave every whole-answer ledger unchanged and do not partly fulfill another promise', () => { + const records = [promise('yes', pay('a', 3)), promise('no', group('or', pay('a', 3), pay('b', 2)), { answer: false })]; + const partial = recordBribeExpressionPayment(records, 'payer', 2, { recipient: 'a', amount: 2 }); + const before = structuredClone(partial); + partial.forEach(entry => { entry.progress.forEach(Object.freeze); Object.freeze(entry.progress); Object.freeze(entry); }); + Object.freeze(partial); + assert.throws(() => recordBribeExpressionPayment(partial, 'payer', 2, { recipient: 'a', amount: 1 }), BribeExpressionPromiseError); + assert.deepEqual(partial, before); assert.equal(partial[0].fulfilled, undefined); + assert.deepEqual(candidates([], partial), []); assert.deepEqual(partial, before); +}); + +void test('safe arithmetic rejects payment and total-route overflow including progress on nonselected leaves', () => { + const maximum = Number.MAX_SAFE_INTEGER; + const near = promise('near', pay('a', maximum), { progress: progress('a', maximum - 1) }); + assert.deepEqual(candidates([], [near]), [[{ recipient: 'a', amount: 1 }]]); + assert.equal(recordBribeExpressionPayment([near], 'payer', 2, { recipient: 'a', amount: 1 })[0].progress[0].amount, maximum); + assert.throws(() => recordBribeExpressionPayment([near], 'payer', 2, { recipient: 'a', amount: 2 }), BribeExpressionPromiseError); + assert.deepEqual(candidates([], [promise('too-large', group('and', pay('a', maximum), pay('b', 1)))]), []); + assert.deepEqual(candidates([], [promise('exact', group('and', pay('a', maximum - 1), pay('b', 1)))]), + [[{ recipient: 'a', amount: maximum - 1 }, { recipient: 'b', amount: 1 }]]); + const partial = promise('nonselected', group('and', pay('a', 1), pay('b', 2)), { progress: progress('a', maximum) }); + assert.deepEqual(candidates([atomic('requires-one-more', 'a', 1)], [partial]), []); + assert.deepEqual(candidates([], [partial]), [[{ recipient: 'b', amount: 2 }]]); +}); + +void test('progress and event inputs require exact unique recipients, safe amounts and matching expression leaves', () => { + const invalidProgress: unknown[] = [null, {}, progress('a', -1), progress('a', 0.5), progress('a', Number.MAX_SAFE_INTEGER + 1), + [...progress('a', 1), ...progress('a', 2)], progress(' a', 1), [{ recipient: 'a', amount: 1, receipt: true }]]; + for (const input of invalidProgress) + assert.throws(() => evaluateBribeExpression(pay(), input as BribeExpressionProgress), BribeExpressionPromiseError); + for (const payment of [{ recipient: 'a', amount: 0 }, { recipient: 'a', amount: -1 }, { recipient: 'a', amount: 0.5 }, + { recipient: 'a', amount: Infinity }, { recipient: 'a', amount: Number.MAX_SAFE_INTEGER + 1 }, + { recipient: 'a ', amount: 1 }, { recipient: 'a', amount: 1, receipt: true }]) + assert.throws(() => recordBribeExpressionPayment([], 'payer', 2, payment), BribeExpressionPromiseError); + assert.throws(() => candidates([], [promise('unmatched-progress', pay(), { progress: progress('b', 1) })]), BribeExpressionPromiseError); +}); + +void test('ledger validation binds unique events, participants, order and whole-answer terminal consistency', () => { + const valid = [promise('live', pay(), { progress: progress('a', 2), order: 1 }), + promise('fulfilled', pay(), { progress: progress('a', 3), fulfilled: true }), + promise('closed-no', pay(), { answer: false, fulfilled: true }), promise('released', pay(), { released: true }), + promise('asker-recipient', pay('asker'))]; + const before = structuredClone(valid); + assert.doesNotThrow(() => validateBribeExpressionPromises(valid, seats)); + assert.doesNotThrow(() => validateBribeExpressionPromises(JSON.parse(JSON.stringify(valid)) as BribeExpressionPromise[], seats)); + const base = promise('event'); + const invalid: unknown[] = [null, {}, [null], [base, { ...base }], + ...[{ event: '' }, { player: 'absent' }, { asker: 'absent' }, { asker: 'payer' }, { claim: pay('absent') }, { claim: pay('payer') }, + { answer: 'yes' }, { fulfilled: 1 }, { released: 1 }, { fulfilled: true }, { fulfilled: true, released: true }, + { progress: progress('a', 3) }, { answer: false, progress: progress('a', 3) }, + { answer: false, progress: progress('a', 3), released: true }, { turn: 0 }, { order: 0 }, { order: 1.5 }] + .map(extra => [{ ...base, ...extra }])]; + for (const records of invalid) + assert.throws(() => validateBribeExpressionPromises(records as BribeExpressionPromise[], seats), BribeExpressionPromiseError); + assert.deepEqual(valid, before); +}); diff --git a/tests/fixture-truth-bribe-expression.ts b/tests/fixture-truth-bribe-expression.ts new file mode 100644 index 00000000..6b8a96cd --- /dev/null +++ b/tests/fixture-truth-bribe-expression.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import type { Action, Game } from '../game/engine'; +import { parseBribeExpression, type BribeExpression } from '../game/bribe-expression-promises'; +import { + actTruthBribe, advanceTruthBribe, answerTruthBribe, declareTruthBribe, finishTruthBribe, + nextTruthBribe, settleTruthBribe, truthBribeBalances, truthBribeClean, truthBribeDeferred, + truthBribeInventory, truthBribePlayer, createTruthBribeFixture, + type TruthBribeStep, type TruthBribeTransition, +} from './fixture-truth-bribe'; +import { createMentatBribeFixture } from './fixture-mentat-bribes'; + +export type TruthBribeExpressionStep = TruthBribeStep; +export type TruthBribeExpressionTransition = TruthBribeTransition; +export const actTruthBribeExpression = actTruthBribe; +export const nextTruthBribeExpression = nextTruthBribe; +export const advanceTruthBribeExpression = advanceTruthBribe; +export const settleTruthBribeExpression = settleTruthBribe; +export const finishTruthBribeExpression = finishTruthBribe; +export const truthBribeExpressionClean = truthBribeClean; +export const truthBribeExpressionPlayer = truthBribePlayer; +export const truthBribeExpressionInventory = truthBribeInventory; +export const truthBribeExpressionBalances = truthBribeBalances; +export const truthBribeExpressionDeferred = truthBribeDeferred; + +/** The original public H/A/E base33 source supplies the exact unopened deals, + * ordinary auctions and ten spendable Emperor spice. Phase7/8 follows its real + * Collection/Mentat controls. Only the question changes: no played clock, + * purse, hand, receipt, answer or promise is installed by this adapter. */ +export function createTruthBribeExpressionFixture(options: { + advanced?: boolean; window?: 'waiting' | 'active'; phase?: 3 | 7 | 8; claim?: BribeExpression; +} = {}) { + const phase = options.phase ?? 3; + const base = phase === 8 ? createMentatBribeFixture({ advanced: options.advanced }) : + createTruthBribeFixture({ advanced: options.advanced, window: options.window, phase }); + const claim = parseBribeExpression(options.claim ?? { kind: 'and', terms: [ + { kind: 'payment', recipient: base.asker, minimum: 3 }, + { kind: 'payment', recipient: base.opponent, minimum: 2 }, + ] }); + const question = { kind: 'bribeExpression' as const, target: base.target, turn: base.game.turn, claim }; + return { ...base, base, phase, claim, question, questionAction: { type: 'truthAsk', question } as Action, + sourceDisclosure: `${base.sourceDisclosure} This thin adapter changes only the grouped ordinary-payment question; each recipient starts at zero future payment progress after its real answer.` }; +} +export type TruthBribeExpressionFixture = ReturnType; +export function declareTruthBribeExpression(f: TruthBribeExpressionFixture, state = f.game, + options: { copies?: 1 | 2 } = {}) { + return declareTruthBribe(f, state, options); +} +export function askTruthBribeExpression(game: Game, asker: string, target: string, claim: BribeExpression, + actions: TruthBribeExpressionStep[] = []): Game { + return actTruthBribeExpression(game, asker, { type: 'truthAsk', question: { + kind: 'bribeExpression', target, turn: game.turn, claim: parseBribeExpression(claim), + } }, actions); +} +export const answerTruthBribeExpression = answerTruthBribe; +export function bindTruthBribeExpression(f: TruthBribeExpressionFixture, answer: 'yes' | 'no' = 'yes', + claim = f.claim, state = f.game) { + const declared = declareTruthBribeExpression(f, state), actions = [...declared.actions]; + const asked = askTruthBribeExpression(declared.game, f.asker, f.target, claim, actions); + const game = answerTruthBribeExpression(asked, f.target, answer, actions); + return { game, declared, asked, actions, actionCount: actions.length }; +} + +/** Preserve the actual opening boundary. A question asked after opening waits + * through the next real Storm and Collection; an earlier payment is credited + * at this turn's opening. The adapter never collects receipts itself. */ +export function completeTruthBribeExpression(f: TruthBribeExpressionFixture, state = f.game) { + let game = state; const turn = game.turn, late = game.phase === 8; + const actions: TruthBribeExpressionStep[] = []; + const transition = (until: (state: Game) => boolean): TruthBribeExpressionTransition => { + const before = structuredClone(game), from = actions.length; + game = advanceTruthBribeExpression(game, until, actions); + return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) }; + }; + const boundary = () => transition(state => state.turn === turn + 1 && state.phase === 0 && truthBribeExpressionClean(state)); + const nextTurnBefore = late ? boundary() : null; + const openingTurn = turn + Number(late); + const collection = transition(state => state.turn === openingTurn && state.phase === 7 && truthBribeExpressionClean(state)); + const mentat = transition(state => state.turn === openingTurn && state.phase === 8 && truthBribeExpressionClean(state)); + const nextTurn = nextTurnBefore ?? boundary(); + assert.deepEqual(truthBribeExpressionInventory(game), f.originalCards); + return { game, collection, mentat, nextTurn, actions, actionCount: actions.length }; +} diff --git a/tests/movement-promises.test.ts b/tests/movement-promises.test.ts index d3473972..a1810a99 100644 --- a/tests/movement-promises.test.ts +++ b/tests/movement-promises.test.ts @@ -130,13 +130,14 @@ void test('Basic native selected families qualify, while optional modules and un } }); -void test('earlier current-round movement question profiles include declared Basic native families', () => { +void test('earlier current-round movement question profiles include declared Basic and Advanced native families', () => { for (const phase of [0, 1, 2, 3, 4]) { const classic = movementBoundary(); classic.phase = phase; classic.movementRemaining = []; assert.equal(movementPromiseTargetAvailable(classic, 'target'), true); const native = structuredClone(classic); native.players[0].faction = 'ixians'; native.expansions = ['ix']; assert.equal(movementPromiseTargetAvailable(native, 'target'), true); - classic.advanced = true; assert.equal(movementPromiseTargetAvailable(classic, 'target'), false); + classic.advanced = true; assert.equal(movementPromiseTargetAvailable(classic, 'target'), true); + native.advanced = true; assert.equal(movementPromiseTargetAvailable(native, 'target'), true); } for (const phase of [-1, 6, 8]) { const g = movementBoundary(); g.phase = phase; diff --git a/tests/truth-bribe-controls.test.tsx b/tests/truth-bribe-controls.test.tsx index a41d0a76..40d552f0 100644 --- a/tests/truth-bribe-controls.test.tsx +++ b/tests/truth-bribe-controls.test.tsx @@ -159,6 +159,26 @@ void test('controlled bribe callbacks expire when their original phase changes b } }); +void test('the shared bribe guide honors its exact live scalar or whole-expression event when both ledgers exist', () => { + for (const source of ['scalar', 'expression'] as const) { + const game = projected(), sent: Action[] = []; + game.bribeExpressionPromises = [{ event: 'controlled-whole-bribe-event', player: 'p', asker: 'a', turn: 2, + claim: { kind: 'or', terms: [{ kind: 'payment', recipient: 'r', minimum: 3 }, { kind: 'payment', recipient: 'a', minimum: 2 }] }, + answer: true, progress: [] }]; + game.bribePromiseGuide!.action.bribePromiseEvent = source === 'scalar' ? game.bribePromises[0].event : game.bribeExpressionPromises[0].event; + const button = guide({ game, busy: false, send(action) { sent.push(action); } }); + assert.equal(button.props.disabled, false); press(button); + assert.equal(sent[0], game.bribePromiseGuide!.action); + if (source === 'scalar') game.bribePromises[0].fulfilled = true; + else game.bribeExpressionPromises[0].released = true; + press(button); assert.equal(sent.length, 1, 'A different live event does not preserve the old callback.'); + game.bribePromiseGuide = { action: { ...game.bribePromiseGuide!.action, + bribePromiseEvent: source === 'scalar' ? game.bribeExpressionPromises[0].event : game.bribePromises[0].event } }; + const current = guide({ game, busy: false, send(action) { sent.push(action); } }); + assert.equal(current.props.disabled, false); press(current); assert.equal(sent.length, 2); + } +}); + 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'; }, diff --git a/tests/truth-bribe-expression-controls.test.tsx b/tests/truth-bribe-expression-controls.test.tsx new file mode 100644 index 00000000..47784f13 --- /dev/null +++ b/tests/truth-bribe-expression-controls.test.tsx @@ -0,0 +1,232 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { BribeExpressionFields, bribeExpressionInputError } from '../components/bribe-expression-fields'; +import { BribeExpressionPromises } from '../components/bribe-expression-promises'; +import { BribePromises, BribePromiseGuideButton } from '../components/bribe-promises'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; +import { parseBribeExpression, type BribeExpression } from '../game/bribe-expression-promises'; +import { createTechTokens } from '../game/tech-tokens'; + +type ElementProps = { children?: ReactNode; [key: string]: unknown }; +type Element = ReactElement; +const recipients = [{ id: 'r', name: 'Recipient' }, { id: 'a', name: 'Asker' }]; +const payment = (minimum = 1, recipient = 'r'): BribeExpression => ({ kind: 'payment', recipient, minimum }); +function elements(node: ReactNode): Element[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!isValidElement(node)) return []; + return [node, ...elements(node.props.children)]; +} +function find(tree: ReactNode, key: string, value: string): Element { + const found = elements(tree).find(element => element.props[key] === value); assert.ok(found, `${key}=${value}`); return found; +} +const control = (tree: ReactNode, key: string) => find(tree, 'data-bribe-expression-control', key); +const click = (button: Element) => (button.props.onClick as () => void)(); +function select(tree: ReactNode, path: string, field: string, value: string) { + const input = find(tree, 'id', `truth-bribe-expression-${path}-${field}`); + (input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } }); +} +function minimum(tree: ReactNode, path: string, valueAsNumber: number) { + const input = find(tree, 'id', `truth-bribe-expression-${path}-minimum`); + (input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } }); +} +function editor(initial: BribeExpression, disabled = false, choices = recipients) { + let value = initial, changes = 0; + return { + get value() { return value; }, get changes() { return changes; }, + tree: () => BribeExpressionFields({ value, recipients: choices, disabled, onChange(next) { value = next; changes++; } }), + html: () => renderToStaticMarkup(createElement(BribeExpressionFields, { value, recipients: choices, disabled, onChange() {} })), + }; +} +function nested(depth: number): BribeExpression { + let value = payment(); + for (let i = 0; i < depth; i++) value = { kind: 'and', terms: [value, payment(2, 'a')] }; + return 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(BribeExpressionPromises, { game, busy, send() {} })); + +/** These personalized projections exercise controlled UI behavior. They do not + * install authoritative promises, cards, funding or ordinary payments. */ +function projected(phase = 3, advanced = false): GameView { + const source = createGame('TRUTHBRIBEEXPRESSIONCONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced); + 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.bribeExpressionPromises = [{ event: 'controlled-bribe-expression-event', player: 'p', asker: 'a', turn: 2, + claim: { kind: 'or', terms: [payment(3), { kind: 'and', terms: [payment(2, 'a'), payment(5)] }] }, + answer: true, progress: [{ recipient: 'r', amount: 1 }] }]; + game.bribePromiseGuide = { action: { type: 'bribe', target: 'r', amount: 2, bribePromiseEvent: 'controlled-bribe-expression-event' } }; + return game; +} + +void test('whole-tree callbacks author all sixteen conditions, nested AND or OR, exact recipients and singleton collapse', () => { + const e = editor(payment(2)); click(control(e.tree(), 'or-1')); + assert.ok(e.value.kind === 'or'); assert.deepEqual(e.value.terms[1], payment(1, '')); + assert.ok(bribeExpressionInputError(e.value)); select(e.tree(), '1.2', 'recipient', 'a'); + for (let count = 3; count <= 16; count++) { + click(control(e.tree(), 'add-1')); minimum(e.tree(), `1.${count}`, count); + select(e.tree(), `1.${count}`, 'recipient', count % 2 ? 'r' : 'a'); + } + assert.ok(e.value.kind === 'or'); assert.equal(e.value.terms.length, 16); + assert.deepEqual(parseBribeExpression(e.value), e.value); assert.match(e.html(), /16 \/ 16 conditions/); + assert.equal((e.html().match(/Minimum future ordinary bribe payments in spice/g) ?? []).length, 16); + assert.match(e.html(), /max="9007199254740991"/); + const before = structuredClone(e.value), changes = e.changes; + for (const key of ['add-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1']) { + assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key)); + } + assert.equal(e.changes, changes); assert.deepEqual(e.value, before); + const grouped = editor({ kind: 'and', terms: [payment(3), payment(2, 'a')] }); + click(control(grouped.tree(), 'or-1.2')); select(grouped.tree(), '1.2', 'meaning', 'and'); + click(control(grouped.tree(), 'nested-or-1')); click(control(grouped.tree(), 'nested-and-1')); + assert.ok(grouped.value.kind === 'and'); assert.equal(grouped.value.terms.length, 4); + click(control(grouped.tree(), 'remove-1.2.2')); assert.deepEqual(grouped.value.terms[1], payment(2, 'a')); + click(control(grouped.tree(), 'remove-1.4')); click(control(grouped.tree(), 'remove-1.3')); + click(control(grouped.tree(), 'remove-1.1')); assert.deepEqual(grouped.value, payment(2, 'a')); +}); + +void test('four-group depth limits hold while numeric drafts and new empty recipient choices remain repairable', () => { + const deep = editor(nested(4)); assert.equal(bribeExpressionInputError(deep.value), null); + assert.match(deep.html(), /4 \/ 4 levels of grouping/); + for (const key of ['and-1', 'or-1.1.1.1.1', 'nested-and-1.1.1.1']) { + assert.equal(control(deep.tree(), key).props.disabled, true); click(control(deep.tree(), key)); + } + assert.equal(deep.changes, 0); minimum(deep.tree(), '1.1.1.1.1', Number.MAX_SAFE_INTEGER); assert.equal(deep.changes, 1); + for (const invalid of [NaN, -1, 0, 0.5, Number.MAX_SAFE_INTEGER + 1, Infinity]) { + const e = editor(payment()); minimum(e.tree(), '1', invalid); + const draft = e.value; assert.ok(draft.kind === 'payment'); assert.ok(Object.is(draft.minimum, invalid)); + assert.ok(bribeExpressionInputError(e.value)); assert.match(e.html(), /role="alert"/); + click(control(e.tree(), 'or-1')); const wrapped = e.value; assert.ok(wrapped.kind === 'or'); + assert.deepEqual(wrapped.terms[0], payment(invalid)); + minimum(e.tree(), '1.1', 2); select(e.tree(), '1.2', 'recipient', 'a'); + assert.equal(bribeExpressionInputError(e.value), null); + } + for (const invalid of [nested(5), { kind: 'and', terms: [payment()] }, { kind: 'or', terms: Array.from({ length: 17 }, () => payment()) }, + { kind: 'payment', recipient: '', minimum: 1 }, { kind: 'payment', recipient: 'r', minimum: 0 }, + { kind: 'gift', recipient: 'r', minimum: 1 }]) assert.ok(bribeExpressionInputError(invalid)); + const empty = editor(payment(1, ''), false, []); minimum(empty.tree(), '1', 2); + assert.deepEqual(empty.value, payment(2, '')); assert.ok(bribeExpressionInputError(empty.value)); + select(empty.tree(), '1', 'recipient', 'r'); assert.equal(empty.changes, 1); +}); + +void test('disabled editor callbacks and unknown recipients cannot overwrite the controlled tree', () => { + const frozen = editor({ kind: 'and', terms: [payment(), payment(2, 'a')] }, true), before = structuredClone(frozen.value); + for (const key of ['add-1', 'nested-and-1', 'or-1', 'remove-1.2']) click(control(frozen.tree(), key)); + minimum(frozen.tree(), '1.1', 4); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'recipient', 'r'); + assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before); + const e = editor(payment()); select(e.tree(), '1', 'recipient', 'unknown'); select(e.tree(), '1', 'recipient', ''); + assert.equal(e.changes, 0); select(e.tree(), '1', 'recipient', 'a'); assert.deepEqual(e.value, payment(1, 'a')); +}); + +void test('own panels preserve the complete unpinned expression, per-recipient progress, whole No and terminal status', () => { + const game = projected(); game.bribePromiseGuide = null; + game.bribeExpressionPromises.push( + { ...game.bribeExpressionPromises[0], event: 'no-expression', answer: false, claim: { kind: 'and', terms: [payment(3), payment(2, 'a')] } }, + { ...game.bribeExpressionPromises[0], event: 'fulfilled-expression', fulfilled: true }, + { ...game.bribeExpressionPromises[0], event: 'released-expression', released: true }, + { ...game.bribeExpressionPromises[0], event: 'foreign-expression', player: 'a', claim: payment(999, 'private-foreign-recipient') }, + ); + const before = structuredClone(game), html = markup(game); + assert.match(html, /Your bribe expression promises/); assert.match(html, / OR /); assert.match(html, / AND /); + assert.match(html, /Recorded future ordinary payments: Recipient: 1 spice; Asker: 0 spice/); + assert.match(html, /No negates the whole expression, not each condition separately/); + assert.match(html, /Legal alternatives remain available; no fixed branch is selected for you/); + assert.match(html, /Smaller payments to the same recipient add together after the answer/); + assert.match(html, /Prior payments, gifts and ally aid do not count/); + 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-expression"/); + assert.doesNotMatch(html, /999|private-foreign-recipient|controlled-bribe-expression-event/); assert.deepEqual(game, before); + assert.equal(BribeExpressionPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null); +}); + +void test('panels explain next-opening receipt, late next-turn collection and No lasting beyond opening collection', () => { + const game = projected(); + for (const phase of [0, 1, 2, 3, 4, 5, 6, 7]) { + game.phase = phase; const html = markup(game); + assert.match(html, /Your next Mentat opening is still ahead/); + assert.match(html, /Accepted payments count now and their recipients collect at that opening/); + assert.match(html, /No stays binding until the actual turn ends; opening collection does not end it/); + } + game.phase = 8; const html = markup(game); + assert.match(html, /Late payments still honor this turn’s answers; their recipients collect at next turn’s Mentat opening/); + assert.match(html, /The sender pays immediately/); assert.match(html, /Collection does not count the payment a second time/); + assert.match(html, /Payments made during Mentat Pause wait until the start of next turn's Mentat Pause/); + game.bribeExpressionPromises[0].fulfilled = true; + assert.doesNotMatch(markup(game), /No stays binding until the actual turn ends/); +}); + +void test('one shared guide handles scalar and whole promises without duplicating the payment action', () => { + const game = projected(); assert.match(markup(game), /data-testid="bribe-promise-guide"/); + game.bribePromises = [{ event: 'scalar-history', player: 'p', asker: 'a', turn: 1, recipient: 'r', minimum: 1, + answer: true, progress: 1, fulfilled: true }]; + assert.doesNotMatch(markup(game), /data-testid="bribe-promise-guide"/); + const both = renderToStaticMarkup(createElement('div', null, + createElement(BribePromises, { game, busy: false, send() {} }), + createElement(BribeExpressionPromises, { game, busy: false, send() {} }))); + assert.equal((both.match(/data-testid="bribe-promise-guide"/g) ?? []).length, 1); + assert.match(both, /Pay 2 spice to Recipient while honoring your answers/); + game.bribePromises[0].player = 'a'; assert.match(markup(game), /data-testid="bribe-promise-guide"/); +}); + +void test('actual shared guide callbacks send the exact whole-expression native payment in every supported phase', () => { + for (const advanced of [false, true]) for (let phase = 0; phase <= 8; phase++) { + const game = projected(phase, advanced), sent: Action[] = []; + game.ready = ['p']; + 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); + press(guide({ ...props, busy: true })); assert.equal(sent.length, 1); + } +}); + +void test('stale aggregate-event callbacks retain current ownership, exact phase, funding, room and quiet-window guards', () => { + const changes: ((game: GameView) => void)[] = [ + game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 8; }, game => { game.status = 'setup'; }, + game => { game.bribePromiseGuide = { action: { ...game.bribePromiseGuide!.action } }; }, + game => { game.bribePromiseGuide = null; }, game => { game.bribePromiseGuide!.action.amount = 3; }, + game => { game.bribeExpressionPromises[0].event = 'expired'; }, game => { game.bribeExpressionPromises[0].player = 'a'; }, + game => { game.bribeExpressionPromises[0].turn = 1; }, game => { game.bribeExpressionPromises[0].fulfilled = true; }, + game => { game.bribeExpressionPromises[0].released = true; }, game => { game.bribeExpressionPromises = []; }, + 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 => { 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 button = guide({ game, busy: false, send(action) { sent.push(action); } }); + assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []); + } + const props = { game: projected(), busy: false, send() { assert.fail('A now-busy expression guide cannot dispatch.'); } }; + const button = guide(props); props.busy = true; press(button); + for (const event of ['', 'unrelated-event']) { + const game = projected(), sent: Action[] = []; + game.bribePromiseGuide!.action.bribePromiseEvent = event; + const current = guide({ game, busy: false, send(action) { sent.push(action); } }); + assert.equal(current.props.disabled, true); press(current); assert.deepEqual(sent, []); + } + const game = projected(), sent: Action[] = []; + for (const seat of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'bribes', 'reserves', 'tanks']) + 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]); +}); diff --git a/tests/truth-bribe-expression-funding.test.ts b/tests/truth-bribe-expression-funding.test.ts new file mode 100644 index 00000000..83dd0157 --- /dev/null +++ b/tests/truth-bribe-expression-funding.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame } from '../game/engine'; +import type { BribeExpression } from '../game/bribe-expression-promises'; +import { + actTruthLeaderRevival as act, createTruthLeaderRevivalFixture as fixture, + declareTruthLeaderRevival as declare, finishTruthLeaderRevival as finish, + settleTruthLeaderRevival as settle, truthLeaderRevivalInventory as inventory, + truthLeaderRevivalPlayer as player, +} from './fixture-truth-leader-revival'; +import { askTruthBribeExpression as ask, answerTruthBribeExpression as answer } from './fixture-truth-bribe-expression'; + +for (const first of ['bribe', 'revival'] as const) void test(`native grouped payment progress retains shared ordinary leader funding with ${first} accepted first`, () => { + // Inherit the original H/A/T Ix47 shipment/projectile-death/next-Storm + // source. T owns four real spice and a dead native Hidar; no purse, disc, + // question or promise is installed by this funding supplement. + const f = fixture(); assert.equal(f.originalCards.length, 47); assert.deepEqual(f.staging, []); + assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(player(f.game, f.target).spice, 4); + const claim: BribeExpression = { kind: 'and', terms: [ + { kind: 'payment', recipient: f.asker, minimum: 1 }, + { kind: 'payment', recipient: f.opponent, minimum: 1 }, + ] }; + let game = declare(f, f.game, { copies: 2 }).game; + const bribe = () => { game = answer(ask(game, f.asker, f.target, claim), f.target, 'yes'); }; + const revival = () => { + game = act(game, f.asker, { type: 'truthAsk', question: f.question }); + assert.deepEqual(viewGame(game, f.target).truthLeaderRevivalAnswers, ['yes', 'no']); + game = answer(game, f.target, 'yes'); + }; + if (first === 'bribe') { bribe(); revival(); } else { revival(); bribe(); } + const before = structuredClone(game); + // T's actual free force return can prepare one owned spice; even that + // native income cannot rescue H3 while A1 and the leader's2 remain due. + assert.throws(() => act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 })); assert.deepEqual(game, before); + game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 1 }); + assert.equal(player(game, f.target).spice, 3); + assert.deepEqual(game.bribeExpressionPromises![0].progress, [{ recipient: f.asker, amount: 1 }]); + assert.ok(!game.bribeExpressionPromises![0].fulfilled); assert.ok(!game.leaderRevivalPromises![0].fulfilled); + game = act(game, f.target, { type: 'bribe', target: f.opponent, amount: 1 }); + assert.equal(player(game, f.target).spice, 2); assert.equal(game.bribeExpressionPromises![0].fulfilled, true); + const guide = viewGame(game, f.target).leaderRevivalPromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'reviveLeader'); assert.equal(guide.action.leader, f.leader); + game = settle(act(game, f.target, guide.action)); + assert.equal(player(game, f.target).spice, 0); assert.equal(game.leaderRevivalPromises![0].fulfilled, true); + assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, false); + assert.equal(player(game, f.asker).bribes, 1); assert.equal(player(game, f.opponent).bribes, 1); + for (const card of f.truths) assert.equal(game.discard.filter(held => held.id === card).length, 1); + assert.deepEqual(inventory(finish(game)), f.originalCards); +}); diff --git a/tests/truth-bribe-expression-runtime.test.ts b/tests/truth-bribe-expression-runtime.test.ts new file mode 100644 index 00000000..2c08973a --- /dev/null +++ b/tests/truth-bribe-expression-runtime.test.ts @@ -0,0 +1,267 @@ +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 type { BribeExpression } from '../game/bribe-expression-promises'; +import { enterMentatBribeWithUnpaidPromise } from './fixture-mentat-bribes'; +import { + actTruthBribeExpression as act, answerTruthBribeExpression as answer, + askTruthBribeExpression as ask, bindTruthBribeExpression as bind, + completeTruthBribeExpression as complete, createTruthBribeExpressionFixture as fixture, + declareTruthBribeExpression as declare, finishTruthBribeExpression as finish, + truthBribeExpressionBalances as balances, truthBribeExpressionInventory as inventory, + truthBribeExpressionPlayer as player, +} from './fixture-truth-bribe-expression'; + +const payment = (recipient: string, minimum: number): BribeExpression => ({ kind: 'payment', recipient, minimum }); +const group = (kind: 'and' | 'or', ...terms: BribeExpression[]): BribeExpression => ({ kind, terms }); +const promise = (game: Game, target: string, index = 0) => game.bribeExpressionPromises!.filter(row => row.player === target)[index]; +const progress = (game: Game, target: string, recipient: string, index = 0) => + promise(game, target, index).progress.find(row => row.recipient === recipient)?.amount ?? 0; +const pay = (game: Game, actor: string, target: string, amount: number) => act(game, actor, { type: 'bribe', target, amount }); +function rejected(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before); +} +function completePayments(game: Game, target: string): Game { + for (let count = 0; count < 8 && game.bribeExpressionPromises!.some(row => row.player === target && row.answer && !row.fulfilled); count++) { + const guide = viewGame(game, target).bribePromiseGuide; assert.ok(guide); + game = act(game, target, guide.action); + } + assert.ok(game.bribeExpressionPromises!.filter(row => row.player === target && row.answer).every(row => row.fulfilled)); + return game; +} + +for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} grouped bribe AND requires both actual cumulative recipient payments before its one opening receipt`, () => { + const f = fixture({ advanced }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); + assert.equal(f.originalCards.length, 33); assert.deepEqual(f.staging, []); + assert.equal(f.sourceActionCount, f.sourceActions.length); + assert.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), f.truths); + const source = structuredClone(f.game), before = balances(f.game), bound = bind(f); + assert.deepEqual(f.game, source); assert.deepEqual(promise(bound.game, f.target).progress, []); + assert.equal(bound.game.auction!.active, f.asker); assert.notEqual(bound.game.auction!.active, f.target); + let game = pay(bound.game, f.target, f.asker, 1); + assert.equal(progress(game, f.target, f.asker), 1); assert.ok(!promise(game, f.target).fulfilled); + game = pay(game, f.target, f.asker, 2); + assert.equal(progress(game, f.target, f.asker), 3); assert.equal(progress(game, f.target, f.opponent), 0); + assert.ok(!promise(game, f.target).fulfilled); assert.equal(player(game, f.target).spice, 7); + game = pay(game, f.target, f.opponent, 2); + assert.equal(progress(game, f.target, f.opponent), 2); assert.equal(promise(game, f.target).fulfilled, true); + assert.equal(player(game, f.target).spice, 5); + for (const recipient of [f.asker, f.opponent]) assert.equal(player(game, recipient).spice, before[recipient]); + assert.equal(player(game, f.asker).bribes, 3); assert.equal(player(game, f.opponent).bribes, 2); + const result = complete(f, game); + for (const [recipient, amount] of [[f.asker, 3], [f.opponent, 2]] as const) { + assert.equal(player(result.mentat.before, recipient).bribes, amount); + assert.equal(player(result.mentat.after, recipient).spice - player(result.mentat.before, recipient).spice, amount); + assert.equal(player(result.mentat.after, recipient).bribes, 0); + assert.equal(progress(result.game, f.target, recipient), amount); + } + assert.equal(result.game.turn, 2); assert.equal(result.game.phase, 0); + assert.equal(result.game.discard.filter(card => card.id === f.truth).length, 1); + assert.deepEqual(inventory(result.game), f.originalCards); +}); + +void test('native grouped bribe OR accepts the actual alternate recipient instead of saving its suggested route', () => { + const f = fixture(), claim = group('or', payment(f.asker, 3), payment(f.opponent, 2)); + let game = bind(f, 'yes', claim).game; + const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); + assert.ok(typeof guide.action.target === 'string'); + const recipient = guide.action.target === f.asker ? f.opponent : f.asker; + const amount = recipient === f.asker ? 3 : 2; + assert.notEqual(recipient, guide.action.target); game = pay(game, f.target, recipient, amount); + assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.target).spice, 10 - amount); + assert.equal(progress(game, f.target, recipient), amount); + assert.equal(progress(game, f.target, guide.action.target), 0); + rejected(game, f.target, guide.action); assert.deepEqual(inventory(game), f.originalCards); +}); + +for (const first of ['harkonnen', 'atreides']) void test(`native grouped bribe AND No permits ${first} first and rejects only completing both recipients`, () => { + const f = fixture(); let game = bind(f, 'no').game; + const other = first === f.asker ? f.opponent : f.asker; + const firstMinimum = first === f.asker ? 3 : 2, otherMinimum = other === f.asker ? 3 : 2; + game = pay(game, f.target, first, firstMinimum); + game = pay(game, f.target, other, otherMinimum - 1); + assert.equal(progress(game, f.target, first), firstMinimum); + assert.equal(progress(game, f.target, other), otherMinimum - 1); assert.ok(!promise(game, f.target).fulfilled); + rejected(game, f.target, { type: 'bribe', target: other, amount: 1 }); + game = finish(game); assert.equal(game.turn, 2); assert.equal(promise(game, f.target).fulfilled, true); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native grouped bribe OR No caps either qualifying recipient while allowing smaller cumulative payments', () => { + const f = fixture(); let game = bind(f, 'no', group('or', payment(f.asker, 3), payment(f.opponent, 2))).game; + rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 3 }); + rejected(game, f.target, { type: 'bribe', target: f.opponent, amount: 2 }); + game = pay(game, f.target, f.asker, 2); game = pay(game, f.target, f.opponent, 1); + for (const recipient of [f.asker, f.opponent]) rejected(game, f.target, { type: 'bribe', target: recipient, amount: 1 }); + game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); + assert.equal(player(game, f.target).spice, 7); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native repeated recipient leaves share one actual payment total rather than charging their thresholds twice', () => { + const f = fixture(), claim = group('and', payment(f.asker, 3), payment(f.asker, 5)); + let game = bind(f, 'yes', claim).game; + game = pay(game, f.target, f.asker, 3); + assert.equal(progress(game, f.target, f.asker), 3); assert.ok(!promise(game, f.target).fulfilled); + game = pay(game, f.target, f.asker, 2); + assert.equal(promise(game, f.target).fulfilled, true); assert.equal(promise(game, f.target).progress.length, 1); + assert.equal(progress(game, f.target, f.asker), 5); assert.equal(player(game, f.target).spice, 5); + assert.equal(player(game, f.asker).bribes, 5); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native prior bribes stay in the real receipt but are excluded from newly answered grouped progress', () => { + const f = fixture(); let game = pay(f.game, f.target, f.asker, 3); + game = bind(f, 'yes', f.claim, game).game; + assert.deepEqual(promise(game, f.target).progress, []); assert.equal(player(game, f.asker).bribes, 3); + game = pay(game, f.target, f.opponent, 2); assert.ok(!promise(game, f.target).fulfilled); + game = pay(game, f.target, f.asker, 3); + assert.equal(promise(game, f.target).fulfilled, true); assert.equal(progress(game, f.target, f.asker), 3); + assert.equal(player(game, f.asker).bribes, 6); assert.equal(player(game, f.target).spice, 2); + const result = complete(f, game); + assert.equal(player(result.mentat.after, f.asker).spice - player(result.mentat.before, f.asker).spice, 6); + assert.equal(progress(result.game, f.target, f.asker), 3); assert.deepEqual(inventory(result.game), f.originalCards); +}); + +void test('two original Truths share scalar H7 with grouped OR H7 A4 but spending A4 must retain the scalar H7', () => { + const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game; + game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'bribe', target: f.target, + turn: game.turn, recipient: f.asker, minimum: 7 } }); + game = answer(game, f.target, 'yes'); game = ask(game, f.asker, f.target, group('or', payment(f.asker, 7), payment(f.opponent, 4))); + // Earlier H7 Yes already makes H7 OR A4 true; No cannot evade it. + assert.deepEqual(viewGame(game, f.target).truthBribeExpressionAnswers, ['yes']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'no' }); + game = answer(game, f.target, 'yes'); + rejected(game, f.target, { type: 'bribe', target: f.opponent, amount: 4 }); + game = pay(game, f.target, f.opponent, 3); assert.ok(!promise(game, f.target).fulfilled); + game = pay(game, f.target, f.asker, 7); + assert.equal(game.bribePromises![0].progress, 7); assert.equal(game.bribePromises![0].fulfilled, true); + assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.target).spice, 0); + assert.equal(progress(game, f.target, f.opponent), 3); assert.equal(progress(game, f.target, f.asker), 7); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('two original Truths retain scalar No H5 while a grouped OR Yes uses actual A2', () => { + const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game; + game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'bribe', target: f.target, + turn: game.turn, recipient: f.asker, minimum: 5 } }); + game = answer(game, f.target, 'no'); game = ask(game, f.asker, f.target, group('or', payment(f.asker, 5), payment(f.opponent, 2))); + game = answer(game, f.target, 'yes'); + const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); + assert.ok(typeof guide.action.amount === 'number'); + assert.equal(guide.action.target, f.opponent); assert.equal(guide.action.amount, 2); + rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 5 }); + game = act(game, f.target, guide.action); + assert.equal(game.bribePromises![0].progress, 0); assert.ok(!game.bribePromises![0].fulfilled); + assert.equal(progress(game, f.target, f.opponent), 2); assert.equal(promise(game, f.target).fulfilled, true); + assert.equal(player(game, f.target).spice, 8); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('two whole questions retain one joint purse and a dynamically available OR completion', () => { + const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game; + game = answer(ask(game, f.asker, f.target, group('or', payment(f.asker, 8), payment(f.opponent, 6))), f.target, 'yes'); + game = ask(game, f.asker, f.target, group('and', payment(f.asker, 3), payment(f.opponent, 3))); + assert.deepEqual(viewGame(game, f.target).truthBribeExpressionAnswers, ['yes', 'no']); game = answer(game, f.target, 'yes'); + rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 8 }); + game = pay(game, f.target, f.asker, 3); + assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target, 1).fulfilled); + game = pay(game, f.target, f.opponent, 6); + assert.ok(game.bribeExpressionPromises!.every(row => row.fulfilled)); assert.equal(player(game, f.target).spice, 1); + for (const index of [0, 1]) { + assert.equal(progress(game, f.target, f.asker, index), 3); assert.equal(progress(game, f.target, f.opponent, index), 6); + } + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('two whole questions preserve every No OR cap while funding the compatible A2 Yes alternative', () => { + const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game; + game = answer(ask(game, f.asker, f.target, group('or', payment(f.asker, 5), payment(f.opponent, 3))), f.target, 'no'); + game = answer(ask(game, f.asker, f.target, group('or', payment(f.asker, 5), payment(f.opponent, 2))), f.target, 'yes'); + game = pay(game, f.target, f.asker, 4); game = pay(game, f.target, f.opponent, 2); + assert.ok(!promise(game, f.target).fulfilled); assert.equal(promise(game, f.target, 1).fulfilled, true); + for (const recipient of [f.asker, f.opponent]) rejected(game, f.target, { type: 'bribe', target: recipient, amount: 1 }); + game = finish(game); assert.ok(game.bribeExpressionPromises!.every(row => row.fulfilled)); + assert.equal(player(game, f.target).spice, 4); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native Mentat grouped Yes reopens only its ready payer and its late receipts wait for the actual next opening', () => { + const f = fixture({ phase: 8 }); let game = act(f.game, f.target, { type: 'ready' }); + game = act(game, f.asker, { type: 'ready' }); assert.equal(game.phase, 8); + assert.ok(game.ready.includes(f.target)); assert.ok(game.ready.includes(f.asker)); assert.ok(!game.ready.includes(f.opponent)); + game = bind(f, 'yes', f.claim, game).game; + assert.ok(!game.ready.includes(f.target)); assert.ok(game.ready.includes(f.asker)); assert.ok(!game.ready.includes(f.opponent)); + rejected(game, f.target, { type: 'ready' }); const before = balances(game); + game = pay(game, f.target, f.asker, 3); rejected(game, f.target, { type: 'ready' }); + game = pay(game, f.target, f.opponent, 2); game = act(game, f.target, { type: 'ready' }); + assert.equal(promise(game, f.target).fulfilled, true); assert.ok(game.ready.includes(f.target)); + for (const recipient of [f.asker, f.opponent]) assert.equal(player(game, recipient).spice, before[recipient]); + const result = complete(f, game); + assert.equal(result.nextTurn.after.turn, 2); assert.equal(result.nextTurn.after.phase, 0); + for (const [recipient, amount] of [[f.asker, 3], [f.opponent, 2]] as const) { + for (const state of [result.nextTurn.after, result.collection.after, result.mentat.before]) { + assert.equal(player(state, recipient).bribes, amount); assert.equal(player(state, f.target).spice, 5); + } + assert.equal(player(result.mentat.after, recipient).spice - player(result.mentat.before, recipient).spice, amount); + assert.equal(player(result.mentat.after, recipient).bribes, 0); + } + assert.deepEqual(inventory(result.game), f.originalCards); +}); + +void test('native whole No remains binding across opening receipts and late payments until the actual turn ends', () => { + const f = fixture({ phase: 7 }); let game = bind(f, 'no').game; + game = pay(game, f.target, f.asker, 3); const before = player(game, f.asker).spice; + game = enterMentatBribeWithUnpaidPromise(game); + assert.equal(game.phase, 8); assert.equal(player(game, f.asker).spice, before + 3); + assert.equal(player(game, f.asker).bribes, 0); assert.equal(progress(game, f.target, f.asker), 3); + game = pay(game, f.target, f.opponent, 1); + rejected(game, f.target, { type: 'bribe', target: f.opponent, amount: 1 }); + assert.ok(!promise(game, f.target).fulfilled); game = finish(game); + assert.equal(game.turn, 2); assert.equal(promise(game, f.target).fulfilled, true); + game = pay(game, f.target, f.opponent, 2); + assert.equal(progress(game, f.target, f.opponent), 1); assert.equal(player(game, f.opponent).bribes, 3); + assert.equal(player(game, f.target).spice, 4); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native grouped question and ordinary guide retain exact target ownership current turn and payment event', () => { + 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, claim: group('and', payment(f.asker, 3), payment(f.target, 1)) } }); + const claim = group('and', payment(f.asker, 3), payment(f.asker, 2)); + let game = ask(declared.game, f.asker, f.target, claim); + assert.equal(viewGame(game, f.asker).truthBribeExpressionAnswers, null); + assert.deepEqual(viewGame(game, f.target).truthBribeExpressionAnswers, ['yes', 'no']); + 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(typeof guide.action.amount === 'number'); + assert.equal(guide.action.bribePromiseEvent, promise(game, f.target).event); + rejected(game, f.opponent, guide.action); + rejected(game, f.target, { ...guide.action, bribePromiseEvent: 'stale-grouped-bribe-event' }); + rejected(game, f.target, { ...guide.action, amount: guide.action.amount + 1 }); + game = act(game, f.target, guide.action); rejected(game, f.target, guide.action); + assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('all four minimal policies submit the exact legal ordinary guide until the whole grouped AND is paid', () => { + const f = fixture({ phase: 8 }), bound = bind(f).game; + for (const difficulty of DIFFICULTIES) { + let game = structuredClone(bound); + for (let count = 0; count < 8 && !promise(game, f.target).fulfilled; count++) { + const view = viewGame(game, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty; + const guide = view.bribePromiseGuide; assert.ok(guide, difficulty); + const action = botActions(view).find(action => action.type === 'bribe' && action.bribePromiseEvent === guide.action.bribePromiseEvent && + action.target === guide.action.target && action.amount === guide.action.amount); + assert.ok(action, difficulty); game = act(game, f.target, action); + } + assert.equal(promise(game, f.target).fulfilled, true, difficulty); + assert.equal(player(game, f.target).spice, 5); assert.equal(player(game, f.asker).bribes, 3); + assert.equal(player(game, f.opponent).bribes, 2); assert.deepEqual(inventory(game), f.originalCards); + } +}); + +void test('native exact grouped guides complete both recipients without changing the original auction turn', () => { + const f = fixture(), game = completePayments(bind(f).game, f.target); + assert.equal(game.auction!.active, f.game.auction!.active); assert.equal(promise(game, f.target).fulfilled, true); + assert.equal(progress(game, f.target, f.asker), 3); assert.equal(progress(game, f.target, f.opponent), 2); + assert.equal(player(game, f.target).spice, 5); assert.deepEqual(inventory(game), f.originalCards); +});