Support whole AND/OR ordinary bribe commitments
This commit is contained in:
1 parent
c81e00e6dd
commit
0e3c155876
21 files changed
+1778
-111
No files matched your search
@@ -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 <fieldset key={path} data-bribe-expression-path={path} className="grid min-w-0 gap-2 p-2">
|
||||
<legend>{group ? `Bribe group ${path} · ${node.kind.toUpperCase()}` : `Bribe condition ${path}`}</legend>
|
||||
{group ? <>
|
||||
<label htmlFor={`${id}-meaning`}>Bribe group {path} meaning</label>
|
||||
<select id={`${id}-meaning`} value={node.kind} disabled={disabled} onChange={event => {
|
||||
const kind = event.currentTarget.value;
|
||||
if (kind === 'and' || kind === 'or') change({ ...node, kind });
|
||||
}}>
|
||||
<option value="and">All conditions · AND</option>
|
||||
<option value="or">At least one condition · OR</option>
|
||||
</select>
|
||||
{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 });
|
||||
}))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" data-bribe-expression-control={`add-${path}`}
|
||||
disabled={!allowed({ ...node, terms: [...node.terms, newLeaf()] })}
|
||||
onClick={() => change({ ...node, terms: [...node.terms, newLeaf()] })}>Add bribe condition</Button>
|
||||
{(['and', 'or'] as const).map(kind => {
|
||||
const next: BribeExpression = { ...node, terms: [...node.terms, { kind, terms: [newLeaf(), newLeaf()] }] };
|
||||
return <Button key={kind} type="button" data-bribe-expression-control={`nested-${kind}-${path}`}
|
||||
disabled={!allowed(next)} onClick={() => change(next)}>Add nested {kind.toUpperCase()} group</Button>;
|
||||
})}
|
||||
</div>
|
||||
</> : <>
|
||||
<label htmlFor={`${id}-recipient`}>Bribe recipient</label>
|
||||
<select id={`${id}-recipient`} value={recipients.some(recipient => recipient.id === node.recipient) ? node.recipient : ''}
|
||||
disabled={disabled} aria-invalid={!recipients.some(recipient => recipient.id === node.recipient)} onChange={event => {
|
||||
const recipient = event.currentTarget.value;
|
||||
if (recipients.some(choice => choice.id === recipient)) change({ ...node, recipient });
|
||||
}}>
|
||||
<option value="">Choose a recipient</option>
|
||||
{recipients.map(recipient => <option key={recipient.id} value={recipient.id}>{recipient.name}</option>)}
|
||||
</select>
|
||||
<label htmlFor={`${id}-minimum`}>Minimum future ordinary bribe payments in spice</label>
|
||||
<Input id={`${id}-minimum`} type="number" min={1} max={Number.MAX_SAFE_INTEGER} step={1} required disabled={disabled}
|
||||
value={Number.isFinite(node.minimum) ? node.minimum : ''}
|
||||
aria-invalid={!!bribeExpressionInputError(node)} onChange={event => change({ ...node, minimum: event.currentTarget.valueAsNumber })} />
|
||||
</>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['and', 'or'] as const).map(kind => <Button key={kind} type="button" data-bribe-expression-control={`${kind}-${path}`}
|
||||
disabled={!allowed(wrap(kind))} onClick={() => change(wrap(kind))}>Group with {kind.toUpperCase()}</Button>)}
|
||||
{remove && <Button type="button" data-bribe-expression-control={`remove-${path}`} disabled={disabled}
|
||||
onClick={() => commit(remove())}>Remove {group ? 'group' : 'condition'}</Button>}
|
||||
</div>
|
||||
</fieldset>;
|
||||
};
|
||||
return <fieldset disabled={disabled} data-testid="bribe-expression"
|
||||
className="grid min-w-0 gap-2 [&_select]:min-w-0 [&_select]:max-w-full [&_button]:max-w-full [&_button]:min-h-11 [&_button]:h-auto [&_button]:py-2 [&_button]:whitespace-normal">
|
||||
<legend>Ordinary bribe expression</legend>
|
||||
<p className="fine">
|
||||
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.
|
||||
</p>
|
||||
<p className="fine">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.</p>
|
||||
<p className="fine">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.</p>
|
||||
<p className="fine">{size.leaves} / {BRIBE_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {BRIBE_EXPRESSION_MAX_DEPTH} levels of grouping</p>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{render(value, '1', next => next)}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -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 <section className="notice space-y-3" aria-label="Your Truthtrance bribe expression promises" id="bribe-expression-promise-guidance">
|
||||
<h3>Your bribe expression promises</h3>
|
||||
<ul>{own.map(promise => <li key={promise.event}>
|
||||
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {bribeExpressionText(promise.claim, name)}.
|
||||
{' '}Recorded future ordinary payments: {bribeExpressionRecipients(promise.claim).map(recipient =>
|
||||
`${name(recipient)}: ${promise.progress.find(progress => progress.recipient === recipient)?.amount ?? 0} spice`).join('; ')}.
|
||||
{promise.released ? ' Released: this commitment is no longer possible.'
|
||||
: promise.fulfilled ? ' Fulfilled: this commitment is complete.' : ' This whole answer is binding for this turn.'}
|
||||
</li>)}</ul>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p className="fine">
|
||||
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.
|
||||
{' '}<a href="/rules#truthtrance-bribe-expression">Bribe expression rules</a>.
|
||||
</p>
|
||||
{live && <p>{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.</p>}
|
||||
{!game.bribePromises.some(promise => promise.player === game.me) && <BribePromiseGuideButton {...props} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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({
|
||||
<ShipmentPromises game={g} act={act} busy={busy} />
|
||||
)}
|
||||
{g.status === 'playing' && <BribePromises game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <BribeExpressionPromises game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <AlliancePromises game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <RevivalExpressionPromises game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <LeaderRevivalPromises game={g} send={act} busy={busy} />}
|
||||
|
||||
@@ -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<BribeExpression>({ 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<Pick<RevivalClaim, 'counter' | 'minimum'>>({ 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 })}
|
||||
</div>)}
|
||||
</>
|
||||
) : w.question!.kind === 'bribeExpression' ? (
|
||||
<>
|
||||
<p className="notice">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.</p>
|
||||
{(g.truthBribeExpressionAnswers ?? []).map(answer => <div key={answer}>
|
||||
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
|
||||
</div>)}
|
||||
</>
|
||||
) : w.question!.kind === 'bribe' ? (
|
||||
<>
|
||||
<p className="notice">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.</p>
|
||||
@@ -404,6 +421,7 @@ export function Truthtrance({
|
||||
<option value="movement">Bind ground movement this turn</option>
|
||||
<option value="alliance">Bind alliance membership at this Nexus close</option>
|
||||
<option value="bribe">Bind ordinary bribe payments this turn</option>
|
||||
<option value="bribeExpression">Combine ordinary bribe payments with AND/OR</option>
|
||||
<option value="revivalExpression">Bind combined force and leader revivals</option>
|
||||
<option value="leaderRevival">Bind a named leader revival this turn</option>
|
||||
<option value="revival">Bind ordinary force revivals this turn</option>
|
||||
@@ -448,6 +466,16 @@ export function Truthtrance({
|
||||
{!g.truthAllianceTargets.includes(target) && <p className="notice">Choose a supported participant in a quiet open ordinary Nexus. Optional modules and broader alliance questions remain unfinished.</p>}
|
||||
<p className="notice" aria-label="Public alliance question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
) : kind === 'bribeExpression' ? (
|
||||
<>
|
||||
<BribeExpressionFields value={bribeExpression} recipients={bribeRecipients}
|
||||
disabled={busy || !g.truthBribeExpressionTargets.includes(target)} onChange={setBribeExpression} />
|
||||
<p className="fine">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.</p>
|
||||
{!g.truthBribeExpressionTargets.includes(target) && <p className="notice">Choose a supported participant before this turn ends and finish pending decisions.</p>}
|
||||
<p className="notice" aria-label="Public combined bribe question preview">{bribeExpressionError
|
||||
? bribeExpressionError : !validBribeExpressionRecipients ? 'Choose a non-allied recipient for every condition.'
|
||||
: truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
) : kind === 'bribe' ? (
|
||||
<>
|
||||
<BribeClaimFields value={bribeClaim} recipients={bribeRecipients} disabled={busy || !g.truthBribeTargets.includes(target)}
|
||||
@@ -573,6 +601,7 @@ export function Truthtrance({
|
||||
(kind === 'revivalExpression' && (!!revivalExpressionInputError(revivalExpression) || !validRevivalExpressionNames || !g.truthRevivalExpressionTargets.includes(target))) ||
|
||||
(kind === 'leaderRevival' && (!!leaderRevivalClaimInputError(leaderRevivalClaim, leaderRevivalChoices) || !g.truthLeaderRevivalTargets.includes(target))) ||
|
||||
(kind === 'alliance' && (!!allianceClaimInputError(allianceClaim, alliancePartners) || !g.truthAllianceTargets.includes(target))) ||
|
||||
(kind === 'bribeExpression' && (!!bribeExpressionError || !validBribeExpressionRecipients || !g.truthBribeExpressionTargets.includes(target))) ||
|
||||
(kind === 'bribe' && (!!bribeClaimInputError(bribeClaim, bribeRecipients) || !g.truthBribeTargets.includes(target))) ||
|
||||
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
|
||||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
|
||||
|
||||
@@ -16,6 +16,38 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Truthtrance / whole AND/OR ordinary payments — 26 October. Prototyped / Partial.**
|
||||
Full grouped questions now combine named cumulative future ordinary payments.
|
||||
Whole No uses De Morgan; legal routes remain dynamic after actual payments.
|
||||
Scalar/whole answers share the real purse and accepted order, retain prior
|
||||
bid/revival/shipment/movement/battle funding, and use original deferred receipts.
|
||||
The full editor, private answers, own progress, one exact native guide and four
|
||||
minimal policies are connected. Current-funded interval/release composition
|
||||
remains provisional; optional modules and complete-mode gates remain open.
|
||||
|
||||
Root integration with disjoint pure/control/native lanes passes **19 files,
|
||||
188/188**, types/lint/build and **eight native paths**,31 GameTable SSR renders/
|
||||
12 actual guide callbacks plus one real editor callback followed by native Ask.
|
||||
Original H/A/E base33 Basic20/Advanced23-action sources add22 actions: actual
|
||||
H3 and A2 payments spend E10→5 and credit each exact share at the opening.
|
||||
Edited OR permits the manual alternate route; No AND allows one qualifying
|
||||
recipient and blocks both; No OR blocks either. A32-action real Mentat source
|
||||
adds37: new Yes reopens only the payer and late receipts collect at the actual
|
||||
next opening. Original H/A/T Ix47 102-action sources add16 in either answer
|
||||
order: H1/A1 then native leader2 fulfills both promises at T0. Cards stay physical.
|
||||
|
||||
Fourteen pure,eight controlled,17 native and two native funding cases cover
|
||||
whole-tree validation, dynamic branches, intervals/caps, scalar overlap, exact
|
||||
ownership/events and four minimal profiles. Type feedback repairs editor leaf
|
||||
narrowing; the wider union corrects one stale early-Advanced movement-profile
|
||||
expectation to the already delivered canonical scope. Frozen v3 proof/manifest:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-bribe-expression-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261026-truth-bribe-expression-runtime/`.
|
||||
No saved-game/database/server/reset/restart/deployment operation is performed.
|
||||
Cross-family expressions, owned future preparation, broader compositions,
|
||||
browser/mobile/deployed and final assurance remain required.
|
||||
[Authority, provisional scope and evidence](TRUTHTRANCE_BRIBE_EXPRESSIONS.md).
|
||||
|
||||
**Ordinary Mentat bribes / turn-end commitments — 26 October. Prototyped / Partial.**
|
||||
Quiet Mentat payments now debit the payer and wait for next turn's actual
|
||||
Mentat opening. The original opening-only receipt mechanism stays. Bribe No
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 October 2026 — Whole AND/OR ordinary-bribe commitments
|
||||
|
||||
**Prototyped / Partial.** A full grouped question binds actual cumulative future
|
||||
ordinary payments to named non-allied recipients. Whole No negates the complete
|
||||
expression, and signed lazy alternatives are rebuilt after real payer events.
|
||||
Same-recipient thresholds share progress; separate questions retain their own
|
||||
future intervals. Scalar and whole answers share actual funds and accepted
|
||||
order while preserving other native commitments. Quotes record the payment
|
||||
before costing the remaining purse, preventing duplicate reservation. Original
|
||||
native debit, deferred receipt and actual turn-end deadlines remain.
|
||||
|
||||
Full16/depth4 authoring, guarded public preview/Ask, target-only answers,
|
||||
persistent own progress and one exact native payment guide connect to all four
|
||||
minimal policies. New Mentat Yes reopens only its payer; known automatic Double
|
||||
retains the earlier Collection boundary. Own spending/alliance/Ready cannot erase
|
||||
every completion; no OR branch is saved. General FAQ/Advanced authority and
|
||||
provisional cumulative/funding/release composition remain explicit.
|
||||
|
||||
Root integration and disjoint pure/control/native lanes pass **19 files,
|
||||
188/188**, types/lint/build and **eight native paths**,31 GameTable SSR renders/
|
||||
12 actual guide callbacks plus one editor callback followed by native Ask.
|
||||
Genuine H/A/E base33 Basic20/Advanced23 prefixes add22 actions: H3/A2 spends
|
||||
E10→5, remains deferred and credits exact recipients at real opening collection.
|
||||
Edited OR permits manual alternate payment; No AND permits one qualifying
|
||||
recipient while No OR blocks either. A32-action original Mentat prefix adds37,
|
||||
reopens only the ready payer and earns late receipts at the next actual opening.
|
||||
H/A/T Ix47 102-action sources add16 in both accepted orders: real H1/A1 then
|
||||
native leader2 completes both ledgers at T0. Original33/47 inventories remain.
|
||||
Four minimal policies actually answer and pay on both Basic/Advanced sources.
|
||||
|
||||
Fourteen pure,eight controlled,17 native and two native funding cases retain
|
||||
invalid decisive-subtree rejection, lazy branches, prior exclusion, overflow,
|
||||
scalar caps/shared thresholds, exact events and later turn boundaries. Initial
|
||||
type feedback fixes positive leaf narrowing in the editor. A focused case also
|
||||
corrects the fixture expectation: an earlier H7 Yes makes H7 OR A4 necessarily
|
||||
Yes. The wider union exposes a stale early-Advanced movement exclusion; its
|
||||
existing canonical26October feature admits both modes, and the test now follows
|
||||
that scope without changing movement runtime. Frozen v3 report/manifest:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-bribe-expression-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261026-truth-bribe-expression-runtime/`.
|
||||
No saved-game/database/server/reset/restart/deployment operation is performed.
|
||||
Optional compositions, future owned preparation, cross-family expressions,
|
||||
browser/mobile/deployed and final rules/assurance acceptance remain required.
|
||||
[Contract](TRUTHTRANCE_BRIBE_EXPRESSIONS.md).
|
||||
|
||||
## 26 October 2026 — Ordinary Mentat bribes and actual turn-end promises
|
||||
|
||||
**Prototyped / Partial.** Remove the inherited blanket phase8 payment ban.
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Rule decision index
|
||||
|
||||
## Whole AND/OR ordinary bribe payments — 26 October 2026
|
||||
|
||||
Apply publisher November2020 FAQp8 current-turn/AND/OR authority and supplied
|
||||
Advanced physical39 general compliance/release authority to future ordinary
|
||||
payer payments. Keep the existing cumulative after-answer interval and
|
||||
opening-only receipt source; late Mentat payments wait for next turn's opening.
|
||||
Whole No uses De Morgan, not one negative promise per leaf. Dynamic funded
|
||||
alternatives share the purse with scalar bribes and existing obligations;
|
||||
actual accepted order governs forced release. Current-funding, interval and
|
||||
release composition remain explicit software interpretation under standing
|
||||
14October priority, not a dedicated publisher payment-expression algorithm.
|
||||
No mode, save, server or publication permission changes.
|
||||
[Contract and evidence](TRUTHTRANCE_BRIBE_EXPRESSIONS.md).
|
||||
|
||||
## Late Mentat bribes and actual turn-end commitments — 26 October 2026
|
||||
|
||||
Remove the inherited whole-Mentat bribe ban. Publisher base printed12's
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Truthtrance combined ordinary bribe payments
|
||||
|
||||
## Whole AND/OR prototype — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** Questions combine cumulative future ordinary payment
|
||||
minima to named non-allied recipients with full AND/OR groups. The packet is
|
||||
`{kind:'bribeExpression',target,turn,claim}`. Quiet phases0–8 in registered bare
|
||||
native Basic/Advanced use the same native payments, purse and actual turn end
|
||||
as [scalar bribes](TRUTHTRANCE_BRIBE_PROMISES.md) and
|
||||
[late Mentat bribes](MENTAT_BRIBES.md). Mode/publication gates remain.
|
||||
|
||||
## Authority and provisional composition
|
||||
|
||||
The official [November2020 FAQ, printed8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
|
||||
indexed passage was checked for this batch: current-turn action commitments
|
||||
and AND/OR questions are allowed; compliance remains binding until impossible.
|
||||
The supplied Advanced physical39/printed37 Truthtrance errata was read for
|
||||
current-turn public answer, compliance, later impossibility and unknown/save.
|
||||
Neither prescribes an online payment solver. The existing ordinary-payment
|
||||
interval, current-funded witness, shared budgets and accepted-order release
|
||||
remain explicit provisional composition under standing14October priority.
|
||||
|
||||
A leaf means cumulative matching ordinary spice paid by this respondent after
|
||||
this answer. Smaller payments add together. Repeated same-recipient conditions
|
||||
share actual progress; separate questions each retain their own future interval.
|
||||
Prior payments, gifts, allied aid and later recipient collection do not count.
|
||||
A positive safe whole minimum has no extra spice cap. Full-tree validation uses
|
||||
sixteen leaves/depth4 as software limits, not printed question restrictions.
|
||||
|
||||
AND requires every child; OR at least one. **No negates the whole expression.**
|
||||
No to A AND B can permit paying either recipient first while forbidding both
|
||||
qualifying totals. No to A OR B forbids either qualifying total. Fulfilled Yes
|
||||
stays terminal; No remains live until actual turn advance. No route is stored.
|
||||
|
||||
## Connected implementation and controls
|
||||
|
||||
`game/bribe-expression-promises.ts` parses every subtree before evaluation,
|
||||
records immutable actual payer progress and lazily yields signed whole-answer
|
||||
alternatives composed with existing scalar bribe requirements/caps. The engine
|
||||
tries native-funded allocations and retains other battle, bid, revival,
|
||||
shipment and movement obligations. Different recipients spend separate spice;
|
||||
overlapping recipient thresholds share the same future payments.
|
||||
|
||||
The native debit/receipt producer records both ledgers before costing the
|
||||
remaining purse. Detached quotes do the same, so fulfilling a payment is not
|
||||
reserved for a second time. Voluntary spending, alliance offers or Ready cannot
|
||||
erase every legal completion. An OR may retain another route after one recipient
|
||||
becomes an ally. Opposing impossibility releases incompatible Yes in actual
|
||||
accepted order across scalar/whole ledgers; unstamped old records precede new
|
||||
answers. Future unknown income, card preparations and foreign consent are not
|
||||
invented. Existing terminal historical answers remain terminal.
|
||||
|
||||
The full controlled editor adds/wraps/nests/removes/collapses groups, changes
|
||||
AND/OR and public recipient/minimum fields, and preserves repairable invalid
|
||||
drafts. The public preview and native Ask retain the entire validated tree.
|
||||
Only the respondent receives feasible answers, own progress and the exact
|
||||
native payment guide. Its event may belong to a scalar or whole live answer;
|
||||
phase, ownership, options, room and busy guards remain. One shared guide is
|
||||
rendered when both ledgers exist. Four minimal profiles answer and pay legally;
|
||||
strategy and difficulty tuning remain deferred.
|
||||
|
||||
A new Yes reopens only its already-ready Mentat payer. Before a known automatic
|
||||
Inflation Double closure the earlier Collection deadline/reopening still
|
||||
applies. No spans opening collection until actual turn advance. Payer spice is
|
||||
spent now; recipient shares collect only at the next actual Mentat opening,
|
||||
including next turn for late Mentat payments. Collection never counts progress
|
||||
again and incoming deferred spice cannot fund another payment.
|
||||
|
||||
## Evidence and remaining gates
|
||||
|
||||
Focused source evidence is in `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` and
|
||||
`tests/truth-bribe-expression-funding.test.ts`. Fourteen pure,eight controlled,
|
||||
17 native and two native funding cases are included in the frozen **19-file,
|
||||
188/188** union. `npm run check:quick`, production build and eight native paths
|
||||
pass on the same unchanged tree. Thirty-one actual GameTable SSR renders and
|
||||
12 actual guide callbacks connect the rule paths; one editor meaning callback
|
||||
retains the full tree through native Ask. All four minimal policies actually
|
||||
answer and pay on both Basic/Advanced original sources.
|
||||
|
||||
| Original source | Prefix actions | Further actions | Result |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Basic H/A/E base33 AND | 20 | 22 | H3/A2 spends E10→5; both exact receipts collect once |
|
||||
| Advanced H/A/E base33 AND | 23 | 22 | Same native payment/receipt under Advanced |
|
||||
| Basic edited OR | 20 | 21 | Actual editor AND→OR then Ask; manual alternative fulfills |
|
||||
| Basic No AND | 20 | 21 | One qualifying recipient allowed, completing both rejects |
|
||||
| Basic No OR | 20 | 20 | Either qualifying payment rejects; actual turn closes No |
|
||||
| Basic late-ready AND | 32 | 37 | Only payer reopens; H3/A2 wait through next Storm/Collection |
|
||||
| Basic shared leader, bribe first | 102 | 16 | Real H1/A1, then native leader2 at T0; both fulfilled |
|
||||
| Basic shared leader, revival first | 102 | 16 | Same funded actual payments in opposite accepted order |
|
||||
|
||||
The source adapters add no installed clock, purse, hand, payment or promise to
|
||||
the inherited original setup/auction or projectile-death journeys. All33/47
|
||||
original cards remain physical. Controlled UI projections are labelled as such;
|
||||
SSR/editor/guide callbacks do not prove browser clicks, hydration or deployment.
|
||||
|
||||
Initial type feedback required explicit editor leaf narrowing. One focused
|
||||
fixture assertion wrongly allowed No to H7 OR A4 after an earlier H7 Yes;
|
||||
the corrected case rejects that contradiction and retains its shared budget.
|
||||
The wider union also found a stale early-Advanced movement-profile exclusion;
|
||||
its current canonical contract already admits native phases0–4, so the test
|
||||
now follows that existing scope. No movement runtime is changed.
|
||||
|
||||
The passing source snapshot is
|
||||
`cb79c3723ed995a214a883eb683d7bd8650882078addf77ca47db3dfd487b6ea`
|
||||
(3137 files). Final evidence prose is a separate three-document overlay.
|
||||
Frozen v3 proof/report/manifest:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-bribe-expression-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261026-truth-bribe-expression-runtime/`.
|
||||
|
||||
Wider optional-module/faction/skill timing, owned future preparation,
|
||||
cross-family expressions, final rules/recovery/privacy/custody review and
|
||||
browser/mobile/deployed acceptance remain required. No complete mode is
|
||||
certified. No saved-game/database/server/reset/restart/deployment operation is
|
||||
performed by this batch.
|
||||
@@ -1331,6 +1331,10 @@ function policyActions(g: GameView): Action[] {
|
||||
const answer = g.truthAllianceAnswers?.find(answer => 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 }] : [];
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>, 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<string>();
|
||||
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<string>();
|
||||
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<BribeExpressionPromise, BribeExpressionPromise>();
|
||||
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<BribeExpressionBranchLeaf[]> {
|
||||
const expression = readPromise(promise);
|
||||
if (promise.fulfilled || promise.released) { yield []; return; }
|
||||
function* alternatives(node: BribeExpression, answer: boolean): Generator<BribeExpressionBranchLeaf[]> {
|
||||
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<BribeExpressionBranchLeaf[]> {
|
||||
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<string, { minimum: number; maximum: number }>;
|
||||
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<BribeExpressionPayment[]> {
|
||||
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<string>();
|
||||
function* combine(index: number, bounds: Bounds): Generator<BribeExpressionPayment[]> {
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
+157
-107
@@ -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<T>(calculate: () => T): T {
|
||||
try { return calculate(); }
|
||||
catch (error) { if (error instanceof BribePromiseError) throw new RuleError(error.message); throw error; }
|
||||
function bribePromiseRule<T>(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<Partial<Game>, '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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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> = {}): BribeExpressionPromise =>
|
||||
({ event, turn: 2, claim, player: 'payer', asker: 'asker', answer: true, progress: [], ...extra });
|
||||
const atomic = (event: string, recipient = 'a', minimum = 3, extra: Partial<BribePromise> = {}): 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);
|
||||
});
|
||||
@@ -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<typeof createTruthBribeExpressionFixture>;
|
||||
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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'; },
|
||||
|
||||
@@ -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<ElementProps>;
|
||||
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<ElementProps>(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<typeof BribePromiseGuideButton>[0]) {
|
||||
const button = BribePromiseGuideButton(props); assert.ok(button); return button;
|
||||
}
|
||||
const press = (button: ReturnType<typeof guide>) => 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]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user