Prototype Truthtrance ally funding commitments
Verify and publish checkpoint container / container (push) Successful in 10m48s
Verify and publish checkpoint container / container (push) Successful in 10m48s
This commit is contained in:
1 parent
68643925e7
commit
c414a03a19
14 files changed
+795
-4
No files matched your search
@@ -4280,6 +4280,16 @@ export function GameTable({
|
||||
purpose={g.phase === 3 ? 'bid' : 'shipment'}
|
||||
{...((shipmentAvailable || g.phase === 3 && g.auction?.active === me.id) ? {onContributionsChange:(shares:CoalitionFundingContribution[]) =>
|
||||
setCoalitionShares({key:coalitionFundingKey, shares})} : {})} />}
|
||||
{g.pledgePromises.some(promise => promise.turn === g.turn && promise.phase === g.phase &&
|
||||
!promise.fulfilled && !promise.released) && <section className="notice" aria-label="Truthtrance ally-funding commitment">
|
||||
<p>Your Truthtrance answer applies to future ally-funding updates this phase:</p>
|
||||
<ul>{g.pledgePromises.filter(promise => promise.turn === g.turn && promise.phase === g.phase &&
|
||||
!promise.fulfilled && !promise.released).map(promise => <li key={promise.event}>
|
||||
{promise.answer ? 'Update at least ' : 'Do not update to at least '}{promise.minimum} spice for{' '}
|
||||
{g.players.find(player => player.id === promise.ally)?.name ?? promise.ally}.
|
||||
</li>)}</ul>
|
||||
{g.pledgePromiseGuide && actionButton('Set the required ally funding', g.pledgePromiseGuide.action)}
|
||||
</section>}
|
||||
{me.ally && [3, 5].includes(g.phase) && (
|
||||
<details className="alliance-funding">
|
||||
<summary>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from './ui/input';
|
||||
|
||||
/** Validation is about the public question, never the respondent's private purse. */
|
||||
export function pledgeClaimInputError(minimum: unknown): string | null {
|
||||
return typeof minimum === 'number' && Number.isSafeInteger(minimum) && minimum > 0
|
||||
? null : 'Choose a positive whole spice amount within the safe integer range.';
|
||||
}
|
||||
|
||||
/** The recipient is the respondent's current public reciprocal ally. */
|
||||
export function PledgeClaimFields(props: {
|
||||
minimum: number;
|
||||
allyName: string;
|
||||
disabled: boolean;
|
||||
onChange: (minimum: number) => void;
|
||||
}) {
|
||||
const error = pledgeClaimInputError(props.minimum);
|
||||
return <fieldset className="grid gap-2" disabled={props.disabled} aria-label="Future ally funding condition">
|
||||
<legend>Future ally funding update</legend>
|
||||
<p>Current ally: {props.allyName}</p>
|
||||
<label htmlFor="truth-pledge-minimum">Minimum future ally funding in spice</label>
|
||||
<Input id="truth-pledge-minimum" type="number" min={1} max={Number.MAX_SAFE_INTEGER} step={1} required
|
||||
disabled={props.disabled} value={Number.isFinite(props.minimum) ? props.minimum : ''}
|
||||
aria-invalid={!!error} aria-describedby="truth-pledge-help"
|
||||
onChange={event => {
|
||||
const next = event.currentTarget.valueAsNumber;
|
||||
if (!props.disabled && !pledgeClaimInputError(next)) props.onChange(next);
|
||||
}} />
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<p className="fine" id="truth-pledge-help">
|
||||
Count an accepted funding update after the answer during this phase. Setting the same total again counts;
|
||||
an earlier pledge alone does not. Funding sets spice aside for your ally and is not a payment or a promise
|
||||
that your ally will spend it.
|
||||
</p>
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import type { AllianceExpression } from '@/game/alliance-expression-promises';
|
||||
import type { AllianceActionClaim } from '@/game/mixed-alliance-question';
|
||||
import type { AllianceClaim } from '@/game/alliance-promises';
|
||||
import { BribeClaimFields, bribeClaimInputError } from './bribe-claim-fields';
|
||||
import { PledgeClaimFields, pledgeClaimInputError } from './pledge-claim-fields';
|
||||
import type { BribeClaim } from '@/game/bribe-promises';
|
||||
import { RevivalClaimFields, revivalClaimInputError } from './revival-claim-fields';
|
||||
import type { RevivalClaim } from '@/game/revival-promises';
|
||||
@@ -129,7 +130,7 @@ export function Truthtrance({
|
||||
g.players.find((p) => p.id !== me.id)!.id,
|
||||
);
|
||||
const [kind, setKind] = useState<
|
||||
'fact' | 'freeform' | 'charity' | 'battlePlan' | 'futureBattleLeader' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | 'richeseBid' | 'earlyRicheseBid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
|
||||
'fact' | 'freeform' | 'charity' | 'battlePlan' | 'futureBattleLeader' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | 'richeseBid' | 'earlyRicheseBid' | 'revival' | 'bribe' | 'pledgeAid' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
|
||||
>('fact');
|
||||
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
|
||||
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
|
||||
@@ -157,6 +158,8 @@ export function Truthtrance({
|
||||
] });
|
||||
const mixedAllianceError = mixedAllianceInputError(mixedAlliance, g, target);
|
||||
const [bribeSelection, setBribeSelection] = useState<Pick<BribeClaim, 'recipient' | 'minimum'>>({ recipient: g.me, minimum: 3 });
|
||||
const [pledgeMinimum, setPledgeMinimum] = useState(1);
|
||||
const pledgeAlly = g.players.find(player => player.id === target)?.ally ?? '';
|
||||
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 &&
|
||||
@@ -239,7 +242,7 @@ export function Truthtrance({
|
||||
id === 'kwisatz' ? 'Kwisatz Haderach' : id === CHEAP_HERO_TRAITOR
|
||||
? 'Cheap Hero / Heroine'
|
||||
: (g.allLeaders.find((l) => l.id === id)?.name ?? id);
|
||||
const name = (id: string) => g.players.find((p) => p.id === id)!.name;
|
||||
const name = (id: string) => g.players.find((p) => p.id === id)?.name ?? id;
|
||||
const locationName = (id: string) => g.combatLocations.find(place => place.id === id)!.name;
|
||||
const question: TruthQuestion =
|
||||
kind === 'fact'
|
||||
@@ -273,6 +276,7 @@ export function Truthtrance({
|
||||
: kind === 'mixedBribe' ? { kind, target, turn: g.turn, mixed: mixedBribe }
|
||||
: kind === 'bribeExpression' ? { kind, target, turn: g.turn, claim: bribeExpression }
|
||||
: kind === 'bribe' ? { kind, target, ...bribeClaim }
|
||||
: kind === 'pledgeAid' ? { kind, target, turn: g.turn, phase: g.phase === 3 ? 3 : 5, ally: pledgeAlly, minimum: pledgeMinimum }
|
||||
: kind === 'revival' ? { kind, target, ...revivalClaim }
|
||||
: kind === 'mixedBid' ? { kind, target, turn: bidClaim.turn, lot: bidClaim.lot, mixed: mixedBid }
|
||||
: kind === 'bid' ? { kind, target, ...bidClaim }
|
||||
@@ -462,6 +466,13 @@ export function Truthtrance({
|
||||
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
|
||||
</div>)}
|
||||
</>
|
||||
) : w.question!.kind === 'pledgeAid' ? (
|
||||
<>
|
||||
<p className="notice">Yes requires a future funding update at or above the minimum while possible. No forbids such an update this phase. A previous pledge alone does not count, and the ally chooses whether to spend it.</p>
|
||||
{(g.truthPledgeAnswers ?? []).map(answer => <div key={answer}>
|
||||
{button(`Answer ${answer === 'yes' ? 'Yes' : 'No'}`, {type:'truthAnswer', answer})}
|
||||
</div>)}
|
||||
</>
|
||||
) : w.question!.kind === 'revival' ? (
|
||||
<>
|
||||
<p className="notice">Yes requires the chosen total of future ordinary force returns while possible. No forbids reaching that total, including split requests. Only completed returns after this answer count. Keep the original revival funding and finish all remaining Yes answers before ending Revival.</p>
|
||||
@@ -652,6 +663,7 @@ export function Truthtrance({
|
||||
<option value="allianceExpression">Combine final alliance partners with AND/OR</option>
|
||||
<option value="mixedAlliance">Combine current facts and final alliance partners</option>
|
||||
<option value="bribe">Bind ordinary bribe payments this turn</option>
|
||||
<option value="pledgeAid">Bind an ally-funding update this phase</option>
|
||||
<option value="bribeExpression">Combine ordinary bribe payments with AND/OR</option>
|
||||
<option value="mixedBribe">Combine current facts and ordinary payments</option>
|
||||
<option value="mixedRevival">Combine current facts and ordinary Revival</option>
|
||||
@@ -919,7 +931,7 @@ export function Truthtrance({
|
||||
<TruthBattlePlanQuestion game={g} question={question} busy={busy}
|
||||
act={act} onChange={setPlanClaim} />
|
||||
)
|
||||
) : kind === 'fact' ? (
|
||||
) : kind === 'pledgeAid' ? null : kind === 'fact' ? (
|
||||
question.kind === 'fact' && (
|
||||
<TruthFactQuestion game={g} question={question} busy={busy}
|
||||
act={act} onChange={setFact} />
|
||||
@@ -961,6 +973,11 @@ export function Truthtrance({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{kind === 'pledgeAid' && <>
|
||||
<PledgeClaimFields minimum={pledgeMinimum} allyName={pledgeAlly ? name(pledgeAlly) : 'their current ally'}
|
||||
disabled={busy || !g.truthPledgeTargets.includes(target)} onChange={setPledgeMinimum} />
|
||||
{!g.truthPledgeTargets.includes(target) && <p className="notice">Choose a player with a mutual ally in a supported quiet Basic Bidding or Movement phase. Earlier definite future-action questions can make this question unavailable.</p>}
|
||||
</>}
|
||||
{!['fact', 'battlePlan', 'futureBattleLeader', 'shipment'].includes(kind) && button(
|
||||
'Ask publicly',
|
||||
{ type: 'truthAsk', question },
|
||||
@@ -977,6 +994,7 @@ export function Truthtrance({
|
||||
(kind === 'mixedBribe' && (!!mixedBribeError || !g.truthMixedBribeTargets.includes(target))) ||
|
||||
(kind === 'bribeExpression' && (!!bribeExpressionError || !validBribeExpressionRecipients || !g.truthBribeExpressionTargets.includes(target))) ||
|
||||
(kind === 'bribe' && (!!bribeClaimInputError(bribeClaim, bribeRecipients) || !g.truthBribeTargets.includes(target))) ||
|
||||
(kind === 'pledgeAid' && (!!pledgeClaimInputError(pledgeMinimum) || !g.truthPledgeTargets.includes(target))) ||
|
||||
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
|
||||
(kind === 'mixedBid' && (!!mixedBidError || !g.truthMixedBidTargets.includes(target))) ||
|
||||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
|
||||
|
||||
@@ -17,6 +17,16 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Truthtrance ally funding — 29 October. Prototyped / Partial.**
|
||||
Supported quiet Basic Bidding and Shipment/Movement questions can bind a future
|
||||
funding update to the respondent's current mutual ally. Existing escrow alone
|
||||
does not count; a repeated total can. Native funding and private wallet rules
|
||||
remain, with public-history fences around other future questions. Forty-nine
|
||||
related cases, quick type/lint checks and the production build pass, including
|
||||
actual escrow, forced deal payment, both phases, legal bots and observer
|
||||
comparison. Other modules, joint commitments and deployed acceptance remain open.
|
||||
[Scope](TRUTHTRANCE_ALLY_FUNDING.md#current-phase-prototype--29-october-2026).
|
||||
|
||||
**Gitea marker-transfer failure — 29 October. Partial.** Run #82 failed
|
||||
because its marker-transfer fixture attempted a second all-reserve Caladan
|
||||
evacuation after the marker had arrived: eight forces cost more than the
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Rule decision index
|
||||
|
||||
## Truthtrance ally-funding updates — 29 October 2026
|
||||
|
||||
The first Basic current-phase ally-funding question counts a future accepted
|
||||
funding update, not earlier escrow or the ally's eventual expenditure. The
|
||||
publisher FAQ permits game-related questions and current-turn promises but does
|
||||
not prescribe this online question or deadline. The phase-bound procedure and
|
||||
public-history question boundary are provisional.
|
||||
[Scope](TRUTHTRANCE_ALLY_FUNDING.md#current-phase-prototype--29-october-2026).
|
||||
|
||||
|
||||
## Sapho at a shared arrival — 29 October 2026
|
||||
|
||||
The provisional Basic overlap procedure offers Ecaz and Moritani a public
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Truthtrance ally-funding questions
|
||||
|
||||
## Current-phase prototype — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** In a supported quiet Basic Bidding or Shipment and
|
||||
Movement phase, a player may ask whether another player will submit an
|
||||
ally-funding update at or above a chosen amount to their current mutual ally
|
||||
before that phase ends. The existing publisher FAQ permits game-related yes/no
|
||||
questions and current-turn promises; applying it to this particular action and
|
||||
its phase deadline is a provisional online interpretation, not a printed example.
|
||||
See the [source audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md).
|
||||
|
||||
A Yes requires an accepted future native `pledgeAid` update while possible.
|
||||
An earlier pledge alone does not fulfill it, although explicitly submitting the
|
||||
same total afterward does. A No prohibits a future update reaching the threshold.
|
||||
The original escrow amount, locked commitments, available spice and repayment at
|
||||
phase end use the ordinary native funding rules. Setting funds aside does not
|
||||
pay another player or promise that the ally will spend them. Only the target
|
||||
receives its private feasible answers and optional exact-action guide.
|
||||
|
||||
A voluntary action by the respondent may not make a still-possible Yes
|
||||
impossible. A different player's action, a natural phase change or loss of the
|
||||
reciprocal alliance can release an impossible Yes; No expires with the phase or
|
||||
alliance. Pre-consented immediate deal payments are attributed to the actual
|
||||
final accepter when determining whether a debit was voluntary. A definite
|
||||
ally-funding question occupies this phase's single question slot; other future-
|
||||
action questions remain unavailable for the rest of the phase. A prior definite
|
||||
future-action question in the turn also excludes a new funding question. These
|
||||
fences use public question history, avoiding disclosure of private fulfillment
|
||||
or mixed-question residuals. They avoid inventing joint funding or future unlocked
|
||||
escrow. Other modules,
|
||||
Advanced, combined questions and full-game/deployed acceptance remain open.
|
||||
|
||||
The [helper](../game/pledge-promises.ts), [native runtime journeys](../tests/truth-pledge-runtime.test.ts)
|
||||
and [controls](../components/pledge-claim-fields.tsx) record the physical and
|
||||
interactive scope. Existing saved games gain no retroactive promises.
|
||||
@@ -1335,6 +1335,7 @@ function policyActions(g: GameView): Action[] {
|
||||
}
|
||||
if (g.truthCharityGuide) return [g.truthCharityGuide.action];
|
||||
if (g.bribePromiseGuide) return [g.bribePromiseGuide.action];
|
||||
if (g.pledgePromiseGuide) return [g.pledgePromiseGuide.action];
|
||||
if (g.alliancePromiseGuide) return [g.alliancePromiseGuide.action];
|
||||
if (g.revivalExpressionPromiseGuide) return [g.revivalExpressionPromiseGuide.action];
|
||||
if (g.leaderRevivalPromiseGuide) return [g.leaderRevivalPromiseGuide.action];
|
||||
@@ -1392,6 +1393,10 @@ function policyActions(g: GameView): Action[] {
|
||||
const answer = g.truthBribeAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'pledgeAid') {
|
||||
const answer = g.truthPledgeAnswers?.[0];
|
||||
return answer ? [{type: 'truthAnswer', answer}] : [];
|
||||
}
|
||||
if (w.question.kind === 'revival') {
|
||||
const answer = g.truthRevivalAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
|
||||
+114
@@ -18,6 +18,10 @@ import { createVictoryContinuation, validateVictoryContinuation, voteVictoryCont
|
||||
import { SabotageGiftError, quoteSabotageGift, quoteSabotageGiftDiscard } from './sabotage-gift';
|
||||
import { mobileStrongholdCollectionCapacity } from './mobile-stronghold-collection';
|
||||
import { basicKullTechRoster, kullDefaultEligible } from './kull-default-profile';
|
||||
import { PledgePromiseError, livePledgePromises, pledgePromiseRequirements, recordPromisedPledge,
|
||||
reconcilePledgePromises as reconcilePledgeRows, validatePledgePromises, pledgeQuestionHistoryBlocked,
|
||||
type PledgeClaim, type PledgePromise } from './pledge-promises';
|
||||
import { nativePromiseScopeSupported } from './native-promise-scope';
|
||||
import { advancedKullLobbyEligible } from './kull-advanced-profile';
|
||||
import { SaphoTruthOrderError, quoteSaphoTruthOrder, reorderSaphoTruthOrder, validateSaphoTruthOrder } from './sapho-truth-order';
|
||||
import { createSaphoArrivalOrder, validateSaphoArrivalOrder, quoteSaphoArrivalOrder,
|
||||
@@ -2420,6 +2424,7 @@ export type Game = {
|
||||
/** Shared accepted order for typed force and named leader answers. */
|
||||
revivalPromiseSequence?: string[];
|
||||
bribePromises?: BribePromise[];
|
||||
pledgePromises?: PledgePromise[];
|
||||
bribeExpressionPromises?: BribeExpressionPromise[];
|
||||
/** Public original mixed questions; native residuals own physical payment rules. */
|
||||
mixedBribeQuestions?: (MixedBribeClaim & {
|
||||
@@ -16344,6 +16349,97 @@ function pledgeAid(g: Game, p: Player, requested: unknown) {
|
||||
g.aid[p.id] = { recipient: p.ally, amount };
|
||||
log(g, `${p.name} updated the spice available to their ally this phase.`);
|
||||
}
|
||||
function pledgeRule<T>(run: () => T): T {
|
||||
try { return run(); }
|
||||
catch (error) { if (error instanceof PledgePromiseError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function pledgeIntegrity(g: Game) {
|
||||
pledgeRule(() => validatePledgePromises(g.pledgePromises ?? [], g.players.map(player => player.id)));
|
||||
}
|
||||
/** The first version excludes simultaneous independently funded obligations.
|
||||
* These rows are public timing facts; no opponent wallet or card is inspected. */
|
||||
function otherPledgeCommitments(g: Game): boolean {
|
||||
const rows: unknown[] = [
|
||||
...(g.shipmentPromises ?? []), ...(g.bidPromises ?? []), ...(g.phaseBidPromises ?? []),
|
||||
...(g.richeseBidPromises ?? []), ...(g.earlyRicheseBidPromises ?? []),
|
||||
...(g.revivalPromises ?? []), ...(g.leaderRevivalPromises ?? []), ...(g.revivalExpressionPromises ?? []),
|
||||
...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? []),
|
||||
...(g.alliancePromises ?? []), ...(g.allianceExpressionPromises ?? []),
|
||||
...(g.movementPromises ?? []), ...(g.movementExpressionPromises ?? []),
|
||||
...(g.travelPromises ?? []), ...(g.turnActionPromises ?? []), ...(g.charityPromises ?? []),
|
||||
...(g.battle?.truthPromises ?? []), ...(g.futureBattleLeaderPromises ?? []),
|
||||
];
|
||||
return rows.some(value => {
|
||||
if (!value || typeof value !== 'object') return true;
|
||||
const row = value as {turn?: number; fulfilled?: boolean; released?: boolean; status?: string};
|
||||
return (row.turn === undefined || row.turn === g.turn) && !row.fulfilled && !row.released &&
|
||||
(row.status === undefined || row.status === 'waiting' || row.status === 'bound');
|
||||
});
|
||||
}
|
||||
function pledgePublicWindow(g: Game): boolean {
|
||||
return g.status === 'playing' && !g.advanced && (g.phase === 3 || g.phase === 5) &&
|
||||
nativePromiseScopeSupported(g) && !g.kullPreview && !g.semutaPreview && !g.nexusKullPreview &&
|
||||
!g.coalitionPreview && !g.coalitionState &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard &&
|
||||
!g.pendingKull && !g.pendingArrivalOverlap && !g.pendingArrivalReaction?.length;
|
||||
}
|
||||
function pledgeQuestionWindow(g: Game): boolean {
|
||||
return pledgePublicWindow(g) && !otherPledgeCommitments(g);
|
||||
}
|
||||
function pledgeMutualAlly(g: Game, player: string): string | null {
|
||||
const owner = g.players.find(seat => seat.id === player), ally = owner?.ally;
|
||||
return ally && g.players.some(seat => seat.id === ally && seat.ally === player) ? ally : null;
|
||||
}
|
||||
/** Ask the native pledge routine on a detached state, retaining its real floor,
|
||||
* locked contributions and funding rules. This is not an automatic payment. */
|
||||
function pledgeCompletion(g: Game, p: Player, promises = g.pledgePromises ?? []): Action | null {
|
||||
const ally = pledgeMutualAlly(g, p.id);
|
||||
if (!ally || (g.phase !== 3 && g.phase !== 5)) return null;
|
||||
const required = pledgeRule(() => pledgePromiseRequirements(promises, p.id, g.turn, g.phase, ally));
|
||||
if (!required) return null;
|
||||
if (!required.minimum) return {type: 'pledgeAid', amount: 0};
|
||||
const current = g.aid[p.id]?.amount ?? 0;
|
||||
const silent = g.richeseAuction?.method === 'silent' && !g.richeseAuction.outcome &&
|
||||
Object.hasOwn(g.richeseAuction.sealed, ally);
|
||||
const amount = Math.max(required.minimum, committedOutgoingAid(g, p),
|
||||
richeseAllyCommitment(g.richeseAuction, g.richeseFunding ?? {}, p.id), silent ? current : 0);
|
||||
if (required.maximum !== undefined && amount > required.maximum) return null;
|
||||
try {
|
||||
const trial = structuredClone(g);
|
||||
pledgeAid(trial, getPlayer(trial, p.id), amount);
|
||||
return {type: 'pledgeAid', amount};
|
||||
} catch (error) { if (error instanceof RuleError) return null; throw error; }
|
||||
}
|
||||
function pledgeTruthAnswers(g: Game, p: Player, claim: PledgeClaim): ('yes' | 'no')[] {
|
||||
if (!pledgeQuestionWindow(g) || pledgeQuestionHistoryBlocked(g.truthHistory ?? [], g.turn, g.phase) ||
|
||||
claim.turn !== g.turn || claim.phase !== g.phase ||
|
||||
pledgeMutualAlly(g, p.id) !== claim.ally ||
|
||||
(g.pledgePromises ?? []).some(row => row.turn === g.turn && row.phase === g.phase &&
|
||||
!row.fulfilled && !row.released)) return [];
|
||||
return (['yes', 'no'] as const).filter(answer => {
|
||||
const rows = [...(g.pledgePromises ?? []), {...claim, event: 'candidate', player: p.id,
|
||||
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes'}];
|
||||
return !!pledgeCompletion(g, p, rows);
|
||||
});
|
||||
}
|
||||
function bindPledgeTruth(g: Game, p: Player, claim: PledgeClaim, answer: boolean, asker: string) {
|
||||
(g.pledgePromises ??= []).push({...claim, event: crypto.randomUUID(), player: p.id, asker, answer});
|
||||
}
|
||||
function reconcilePledgeCommitments(g: Game, voluntary?: {actor: string; action: Action}) {
|
||||
if (!g.pledgePromises?.length) return;
|
||||
pledgeIntegrity(g);
|
||||
g.pledgePromises = pledgeRule(() => reconcilePledgeRows(g.pledgePromises!, {
|
||||
turn: g.turn, phase: g.phase, mutualAlly: player => pledgeMutualAlly(g, player),
|
||||
canFulfill: promise => !!pledgeCompletion(g, getPlayer(g, promise.player)),
|
||||
...(voluntary ? {voluntaryActor: voluntary.actor} : {}),
|
||||
}));
|
||||
}
|
||||
function projectedPledgeGuide(g: Game, player: Player): {action: Action} | null {
|
||||
if (!pledgeQuestionWindow(g) || g.truthtrance || !livePledgePromises(g.pledgePromises ?? [], player.id, g.turn, g.phase)
|
||||
.some(row => row.answer)) return null;
|
||||
const action = pledgeCompletion(g, player);
|
||||
return action ? {action} : null;
|
||||
}
|
||||
function alliancePromiseRule<T>(calculate: () => T): T {
|
||||
try { return calculate(); }
|
||||
catch (error) { if (error instanceof AlliancePromiseError || error instanceof AllianceExpressionPromiseError) throw new RuleError(error.message); throw error; }
|
||||
@@ -41782,6 +41878,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
'Finish or cancel the faction draft before changing the lobby.',
|
||||
);
|
||||
victoryContinuationIntegrity(state);
|
||||
pledgeIntegrity(state);
|
||||
bidPromiseIntegrity(state);
|
||||
phaseBidIntegrity(state);
|
||||
richeseBidIntegrity(state);
|
||||
@@ -42110,6 +42207,8 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
// A final counter pass settles the opposing claimant's effect; it does not
|
||||
// turn that forced debit into the passer's voluntary payment or expenditure.
|
||||
const voluntary = forcedThumperDraw || action.type === 'passResponse' ? undefined : { actor: id, action };
|
||||
const pledgeVoluntary = initiator === id ? voluntary : undefined;
|
||||
reconcilePledgeCommitments(g, pledgeVoluntary);
|
||||
reconcileTravelPromises(g, promiseActor, action, state.phase);
|
||||
reconcileBidPromises(g, voluntary);
|
||||
reconcilePhaseBids(g, voluntary);
|
||||
@@ -42248,6 +42347,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
reconcileCharityTruth(g, { actor: id, action });
|
||||
reconcileRevivalPromises(g, { actor: id, action });
|
||||
reconcileBribePromises(g, { actor: id, action });
|
||||
reconcilePledgeCommitments(g, pledgeVoluntary);
|
||||
reconcilePublicDealTruth(g, voluntary, options?.insolventDealPayment ? undefined : state);
|
||||
reconcileAlliancePromises(g, { actor: id, action });
|
||||
settleAutomaticContinuations(g);
|
||||
@@ -42487,6 +42587,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
reconcileCharityTruth(g);
|
||||
reconcileRevivalPromises(g);
|
||||
reconcileBribePromises(g);
|
||||
reconcilePledgeCommitments(g);
|
||||
reconcilePublicDealTruth(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
reconcileAlliancePromises(g);
|
||||
@@ -42507,6 +42608,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
factionSwapIntegrity(state);
|
||||
publicDealIntegrity(state);
|
||||
victoryContinuationIntegrity(state);
|
||||
pledgeIntegrity(state);
|
||||
if (state.victoryContinuation) return state;
|
||||
bidPromiseIntegrity(state);
|
||||
phaseBidIntegrity(state);
|
||||
@@ -42605,6 +42707,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
reconcileCharityTruth(g);
|
||||
reconcileRevivalPromises(g);
|
||||
reconcileBribePromises(g);
|
||||
reconcilePledgeCommitments(g);
|
||||
reconcilePublicDealTruth(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
reconcileAlliancePromises(g);
|
||||
@@ -42871,6 +42974,8 @@ function applyActionInner(
|
||||
bindAlliance: bindAllianceTruth,
|
||||
bribeAnswers: bribeTruthAnswers,
|
||||
bindBribe: bindBribeTruth,
|
||||
pledgeAnswers: pledgeTruthAnswers,
|
||||
bindPledge: bindPledgeTruth,
|
||||
bribeExpressionAnswers: bribeExpressionTruthAnswers,
|
||||
bindBribeExpression: bindBribeExpressionTruth,
|
||||
mixedBribeAnswers: mixedBribeTruthAnswers,
|
||||
@@ -45604,6 +45709,8 @@ function applyActionInner(
|
||||
'You may fund an ally during bidding/shipment, or as advanced CHOAM during Battle.',
|
||||
);
|
||||
pledgeAid(g, p, action.amount);
|
||||
if (g.pledgePromises?.length) g.pledgePromises = pledgeRule(() => recordPromisedPledge(
|
||||
g.pledgePromises!, p.id, g.turn, g.phase, {ally: p.ally!, amount: action.amount as number}));
|
||||
return g;
|
||||
}
|
||||
if (t === 'richeseBid') {
|
||||
@@ -47015,6 +47122,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
factionSwapIntegrity(state);
|
||||
publicDealIntegrity(state);
|
||||
victoryContinuationIntegrity(state);
|
||||
pledgeIntegrity(state);
|
||||
revivalOrderIntegrity(state);
|
||||
charityOrderIntegrity(state);
|
||||
state = migrateAtomicsTargets(state);
|
||||
@@ -47266,6 +47374,12 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
alliancePromises: projectedAlliancePromises(g, id),
|
||||
alliancePromiseGuide: projectedAlliancePromiseGuide(g, id),
|
||||
truthBribeTargets: bribeQuestionWindow(g) ? g.players.map(player => player.id) : [],
|
||||
truthPledgeTargets: pledgePublicWindow(g) && !pledgeQuestionHistoryBlocked(g.truthHistory ?? [], g.turn, g.phase)
|
||||
? g.players.filter(player => !!pledgeMutualAlly(g, player.id)).map(player => player.id) : [],
|
||||
truthPledgeAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'pledgeAid' ? pledgeTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
pledgePromises: (g.pledgePromises ?? []).filter(row => row.player === id).map(row => ({...row})),
|
||||
pledgePromiseGuide: projectedPledgeGuide(g, me),
|
||||
truthBribeAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'bribe' ? bribeTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
bribePromises: (g.bribePromises ?? []).filter(promise => promise.player === id).map(promise => ({ ...promise })),
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/** A future accepted update to the amount made available to an existing ally.
|
||||
* This is not an actual expenditure or a promise that the ally will use it. */
|
||||
export type PledgeClaim = { turn: number; phase: 3 | 5; ally: string; minimum: number };
|
||||
export type PledgePromise = PledgeClaim & {
|
||||
event: string;
|
||||
player: string;
|
||||
asker: string;
|
||||
answer: boolean;
|
||||
fulfilled?: boolean;
|
||||
released?: boolean;
|
||||
};
|
||||
export type PledgeRequirement = { ally: string; minimum: number; maximum?: number };
|
||||
export type PledgeUpdate = { ally: string; amount: number };
|
||||
|
||||
/** Only public question history determines whether a new question is offered.
|
||||
* Do not reveal whether a private compiled residual is still live. */
|
||||
export function pledgeQuestionHistoryBlocked(history: readonly {
|
||||
turn: number; phase: number; answer: string; question: { kind: string; scope?: string };
|
||||
}[], turn: number, phase: number): boolean {
|
||||
return history.some(record => record.turn === turn && record.answer !== 'unknown' &&
|
||||
(record.question.kind === 'pledgeAid' ? record.phase === phase :
|
||||
record.question.kind !== 'fact' && !(record.question.kind === 'freeform' && record.question.scope === 'fact')));
|
||||
}
|
||||
|
||||
export class PledgePromiseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'PledgePromiseError';
|
||||
}
|
||||
}
|
||||
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||
const positiveInteger = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||||
const nonnegativeInteger = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
const optionalBoolean = (value: unknown): boolean => value === undefined || typeof value === 'boolean';
|
||||
|
||||
export function parsePledgeClaim(value: unknown): PledgeClaim {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new PledgePromiseError('Choose a current-phase ally-funding question.');
|
||||
const input = value as Record<string, unknown>;
|
||||
if (!positiveInteger(input.turn) || (input.phase !== 3 && input.phase !== 5) ||
|
||||
!identifier(input.ally) || !positiveInteger(input.minimum))
|
||||
throw new PledgePromiseError('Choose the current bidding or movement phase, your ally, and a positive safe whole funding minimum.');
|
||||
return { turn: input.turn, phase: input.phase, ally: input.ally, minimum: input.minimum };
|
||||
}
|
||||
|
||||
/** Validates the saved public answer and participant identities without reading
|
||||
* another player's hand, wallet, pledged amount or private commitments. */
|
||||
export function validatePledgePromises(promises: readonly PledgePromise[], players: readonly string[]): void {
|
||||
if (!Array.isArray(promises) || !Array.isArray(players))
|
||||
throw new PledgePromiseError('The ally-funding commitment record is invalid.');
|
||||
const events = new Set<string>();
|
||||
for (const promise of promises) {
|
||||
parsePledgeClaim(promise);
|
||||
if (!identifier(promise.event) || events.has(promise.event) ||
|
||||
!identifier(promise.player) || !identifier(promise.asker) ||
|
||||
!players.includes(promise.player) || !players.includes(promise.asker) || !players.includes(promise.ally) ||
|
||||
promise.player === promise.asker || promise.player === promise.ally ||
|
||||
typeof promise.answer !== 'boolean' || !optionalBoolean(promise.fulfilled) ||
|
||||
!optionalBoolean(promise.released) || promise.fulfilled && promise.released ||
|
||||
!promise.answer && promise.released)
|
||||
throw new PledgePromiseError('The ally-funding commitment lost its event, participants or answer.');
|
||||
events.add(promise.event);
|
||||
}
|
||||
}
|
||||
|
||||
export function livePledgePromises(promises: readonly PledgePromise[], player: string,
|
||||
turn: number, phase: number): PledgePromise[] {
|
||||
return promises.filter(promise => promise.player === player && promise.turn === turn &&
|
||||
promise.phase === phase && !promise.fulfilled && !promise.released);
|
||||
}
|
||||
|
||||
/** Outstanding Yes answers can share one future update; each No supplies an
|
||||
* exclusive upper bound. Existing escrow never fulfills a newly asked question. */
|
||||
export function pledgePromiseRequirements(promises: readonly PledgePromise[], player: string,
|
||||
turn: number, phase: number, ally: string): PledgeRequirement | null {
|
||||
if (!identifier(ally)) throw new PledgePromiseError('Choose a current mutual ally.');
|
||||
let minimum = 0, maximum: number | undefined;
|
||||
for (const promise of livePledgePromises(promises, player, turn, phase)) {
|
||||
parsePledgeClaim(promise);
|
||||
if (promise.ally !== ally) return null;
|
||||
if (promise.answer) minimum = Math.max(minimum, promise.minimum);
|
||||
else maximum = Math.min(maximum ?? Number.MAX_SAFE_INTEGER, promise.minimum - 1);
|
||||
}
|
||||
if (maximum !== undefined && minimum > maximum) return null;
|
||||
return { ally, minimum, ...(maximum === undefined ? {} : { maximum }) };
|
||||
}
|
||||
|
||||
/** Apply only after the original native pledge action has passed its legal
|
||||
* quote. Same-value explicit updates count; unrelated earlier escrow does not.
|
||||
* A rejected No leaves all original records unchanged. */
|
||||
export function recordPromisedPledge(promises: readonly PledgePromise[], player: string,
|
||||
turn: number, phase: number, update: PledgeUpdate): PledgePromise[] {
|
||||
if (!update || typeof update !== 'object' || Array.isArray(update) ||
|
||||
!identifier(update.ally) || !nonnegativeInteger(update.amount))
|
||||
throw new PledgePromiseError('Retain the actual ally and nonnegative whole funding update.');
|
||||
const live = livePledgePromises(promises, player, turn, phase);
|
||||
for (const promise of live) {
|
||||
parsePledgeClaim(promise);
|
||||
if (promise.ally === update.ally && !promise.answer && update.amount >= promise.minimum)
|
||||
throw new PledgePromiseError('This funding update conflicts with your Truthtrance No answer.');
|
||||
}
|
||||
return promises.map(promise => live.includes(promise) && promise.ally === update.ally &&
|
||||
promise.answer && update.amount >= promise.minimum ? { ...promise, fulfilled: true } : promise);
|
||||
}
|
||||
|
||||
export type PledgeReconciliation = {
|
||||
turn: number;
|
||||
phase: number;
|
||||
/** Must return an ally only for a current reciprocal partnership. */
|
||||
mutualAlly: (player: string) => string | null;
|
||||
/** A conservative native future-completion quote, not a secret opponent
|
||||
* choice or a requirement that an interruptible action be legal right now. */
|
||||
canFulfill: (promise: PledgePromise, requirement: PledgeRequirement) => boolean;
|
||||
/** The player whose voluntary accepted action led to this state, if any. */
|
||||
voluntaryActor?: string;
|
||||
};
|
||||
|
||||
/** No remains binding through the named phase and reciprocal alliance. An
|
||||
* unfulfilled Yes is released only if an outside action or natural transition
|
||||
* makes completion impossible; its owner cannot voluntarily create that loss.
|
||||
* The engine owns the legal quote and must avoid speculative hidden funds. */
|
||||
export function reconcilePledgePromises(promises: readonly PledgePromise[], context: PledgeReconciliation): PledgePromise[] {
|
||||
if (!Array.isArray(promises) || !context || !positiveInteger(context.turn) ||
|
||||
!Number.isSafeInteger(context.phase) || typeof context.mutualAlly !== 'function' ||
|
||||
typeof context.canFulfill !== 'function' ||
|
||||
context.voluntaryActor !== undefined && !identifier(context.voluntaryActor))
|
||||
throw new PledgePromiseError('Retain the current ally-funding reconciliation context.');
|
||||
return promises.map(promise => {
|
||||
if (promise.fulfilled || promise.released) return promise;
|
||||
parsePledgeClaim(promise);
|
||||
const inScope = promise.turn === context.turn && promise.phase === context.phase &&
|
||||
context.mutualAlly(promise.player) === promise.ally;
|
||||
if (!promise.answer) return inScope ? promise : { ...promise, fulfilled: true };
|
||||
const requirement = inScope
|
||||
? pledgePromiseRequirements(promises, promise.player, context.turn, context.phase, promise.ally)
|
||||
: null;
|
||||
if (inScope && requirement && context.canFulfill(promise, requirement)) return promise;
|
||||
if (context.voluntaryActor === promise.player)
|
||||
throw new PledgePromiseError('You cannot voluntarily evade your Truthtrance ally-funding answer.');
|
||||
return { ...promise, released: true };
|
||||
});
|
||||
}
|
||||
@@ -3992,6 +3992,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
|
||||
'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
|
||||
'In a new freeform game-fact question, exact personal-spice, named-card, total-hand-size and selected weapon, defense or Worthless category forms are checked as current facts. Category questions use the displayed printed primary role. Two spice or named-card facts may be joined by one AND or OR; only their aggregate answer is published. The recognized interpretation appears before asking and with the question. Other wording and current-turn promises remain player-managed; bots answer unknown. Already-pending older questions keep their original behavior.',
|
||||
'In supported quiet Basic Bidding or Shipment and Movement, ask whether a player will update funding for their current mutual ally to at least a chosen amount this phase. Yes requires a future accepted update while possible; No forbids reaching the amount. An existing pledge alone does not count, a repeated same total does, and the ally chooses whether to spend it. Only one definite funding question is supported per phase; other future-action questions in that phase and optional modules remain separate.',
|
||||
'For current spice, choose Exactly, At least or At most and a nonnegative whole amount. This counts spice personally held when answering, excluding separately pledged aid and incoming payments. It does not ask whether a particular action is affordable or promise that the balance will stay unchanged. A true current-spice answer does not prevent later legal spending.',
|
||||
'For a concealed No-Field, choose its current printed value0,3 or5, not the prospective force pool. A missing or already revealed marker answers No, including for0; a concealed5 with empty reserves still answers Yes to5. Only the respondent sees the expected aggregate before public reply. Neither question nor answer turns up the physical token or commits later use; current-force facts continue to exclude markers.',
|
||||
'The questioned player answers publicly. A definite answer discards the card. If the answer cannot be known, the holder may ask a different question or save Truthtrance for later.',
|
||||
|
||||
+29
-1
@@ -23,6 +23,7 @@ import { parseRevivalExpressionClaim, revivalExpressionText, revivalExpressionPr
|
||||
import { parseLeaderRevivalClaim, leaderRevivalClaimText, leaderRevivalPromiseModeSupported, type LeaderRevivalClaim } from './leader-revival-promises';
|
||||
import { parseAllianceClaim, allianceClaimText, alliancePromiseModeSupported, type AllianceClaim } from './alliance-promises';
|
||||
import { parseBribeClaim, bribeClaimText, bribePromiseModeSupported, type BribeClaim } from './bribe-promises';
|
||||
import { parsePledgeClaim, type PledgeClaim } from './pledge-promises';
|
||||
import { parseRevivalClaim, revivalClaimText, revivalPromiseModeSupported, type RevivalClaim } from './revival-promises';
|
||||
import { parseMovementClaim, movementClaimText, movementPromiseTargetAvailable, MovementClaimError, type MovementClaim } from './movement-promises';
|
||||
import { shipmentAvailable } from './shipment-opportunity';
|
||||
@@ -112,6 +113,7 @@ export type TruthQuestion =
|
||||
| ({ kind: 'leaderRevival'; target: string } & LeaderRevivalClaim)
|
||||
| ({ kind: 'alliance'; target: string } & AllianceClaim)
|
||||
| ({ kind: 'bribe'; target: string } & BribeClaim)
|
||||
| ({ kind: 'pledgeAid'; target: string } & PledgeClaim)
|
||||
| ({ kind: 'revival'; target: string } & RevivalClaim)
|
||||
| ({ kind: 'bid'; target: string } & BidClaim)
|
||||
| ({ kind: 'phaseBid'; target: string } & PhaseBidClaim)
|
||||
@@ -279,6 +281,10 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
);
|
||||
// A joint planner for Charity and other future-action questions is not yet
|
||||
// available. Do not bind incompatible action obligations in the interim.
|
||||
if (v.kind !== 'pledgeAid' && v.kind !== 'fact' && !(v.kind === 'freeform' && v.scope === 'fact') &&
|
||||
g.truthHistory?.some(row => row.turn === g.turn && row.phase === g.phase && row.answer !== 'unknown' &&
|
||||
row.question.kind === 'pledgeAid'))
|
||||
throw new TruthError('Other future-action questions are unavailable in a phase with an ally-funding answer.');
|
||||
if (v.kind !== 'charity' && v.kind !== 'fact' && v.kind !== 'freeform' &&
|
||||
g.charityPromises?.length && liveCharityPromises(g.charityPromises,v.target as string,g.turn).length)
|
||||
throw new TruthError('Finish this player’s current Charity answer before binding another action question.');
|
||||
@@ -369,6 +375,16 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
'Choose current-turn ordinary bribe statements to non-allied participants in a supported table.');
|
||||
return { kind: 'bribeExpression', target: v.target, ...claim };
|
||||
}
|
||||
if (v.kind === 'pledgeAid') {
|
||||
check(Object.keys(v).sort().join(',') === 'ally,kind,minimum,phase,target,turn',
|
||||
'Supply the current phase, respondent, ally and funding minimum.');
|
||||
const claim = parsePledgeClaim(v), respondent = g.players.find(player => player.id === v.target)!;
|
||||
check(g.status === 'playing' && !g.advanced && claim.turn === g.turn && claim.phase === g.phase &&
|
||||
respondent.ally === claim.ally && g.players.some(player => player.id === claim.ally && player.ally === respondent.id) &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama,
|
||||
'Choose a current mutual ally in a supported quiet Basic Bidding or Shipment and Movement window.');
|
||||
return {kind: 'pledgeAid', 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.');
|
||||
@@ -711,7 +727,8 @@ export function validateSavedTruthtrance(g: Game) {
|
||||
}
|
||||
if (window?.question?.kind === 'fact' || window?.question?.kind === 'movement' ||
|
||||
window?.question?.kind === 'freeform' && window.question.interpreted ||
|
||||
window?.question?.kind === 'traitorCall' || window?.question?.kind === 'futureBattleLeader') {
|
||||
window?.question?.kind === 'traitorCall' || window?.question?.kind === 'futureBattleLeader' ||
|
||||
window?.question?.kind === 'pledgeAid') {
|
||||
check((window.stage === 'answer' || window.stage === 'unknown') && window.queue.length > 0,
|
||||
'The saved fact question must retain its asking player and answer stage.');
|
||||
check(JSON.stringify(parseQuestion(g, window.queue[0].player, window.question)) === JSON.stringify(window.question),
|
||||
@@ -825,6 +842,7 @@ export function truthQuestionText(
|
||||
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 === 'pledgeAid') return `Will you update funding available to ${playerName(q.ally)} to at least ${q.minimum} spice during this phase?`;
|
||||
if (q.kind === 'revival') return `Will you ${revivalClaimText(q)}?`;
|
||||
if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`;
|
||||
if (q.kind === 'phaseBid') return `Will you ${phaseBidClaimText(q)}?`;
|
||||
@@ -955,6 +973,8 @@ export function resolveTruthAction(
|
||||
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;
|
||||
pledgeAnswers?: (g: Game, p: Player, claim: PledgeClaim) => TruthAnswer[];
|
||||
bindPledge?: (g: Game, p: Player, claim: PledgeClaim, answer: boolean, asker: string) => void;
|
||||
mixedRevivalAnswers?: (g: Game, p: Player, claim: MixedRevivalClaim) => TruthAnswer[];
|
||||
bindMixedRevival?: (g: Game, p: Player, claim: MixedRevivalClaim, answer: boolean, asker: string) => void;
|
||||
revivalExpressionAnswers?: (g: Game, p: Player, claim: RevivalExpressionClaim) => TruthAnswer[];
|
||||
@@ -1161,6 +1181,9 @@ export function resolveTruthAction(
|
||||
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 === 'pledgeAid')
|
||||
check(effects.pledgeAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a supported ally-funding question compatible with current commitments.');
|
||||
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.');
|
||||
@@ -1295,6 +1318,11 @@ export function resolveTruthAction(
|
||||
'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 === 'pledgeAid') {
|
||||
check(effects.pledgeAnswers && effects.bindPledge && effects.pledgeAnswers(g, p, q).includes(a.answer),
|
||||
'Choose an answer consistent with current ally-funding legality.');
|
||||
if (a.answer !== 'unknown') effects.bindPledge(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,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
PledgePromiseError, livePledgePromises, parsePledgeClaim, pledgePromiseRequirements,
|
||||
reconcilePledgePromises, recordPromisedPledge, validatePledgePromises,
|
||||
type PledgePromise, type PledgeReconciliation,
|
||||
} from '../game/pledge-promises';
|
||||
|
||||
const promise = (overrides: Partial<PledgePromise> = {}): PledgePromise => ({
|
||||
turn: 2, phase: 3, ally: 'ally', minimum: 3,
|
||||
event: 'event-1', player: 'donor', asker: 'asker', answer: true, ...overrides,
|
||||
});
|
||||
const context = (overrides: Partial<PledgeReconciliation> = {}): PledgeReconciliation => ({
|
||||
turn: 2, phase: 3, mutualAlly: player => player === 'donor' ? 'ally' : null,
|
||||
canFulfill: () => true, ...overrides,
|
||||
});
|
||||
|
||||
void test('a pledge question requires a current supported phase, current ally identity and positive safe threshold', () => {
|
||||
assert.deepEqual(parsePledgeClaim({turn: 2, phase: 5, ally: 'ally', minimum: 4}),
|
||||
{turn: 2, phase: 5, ally: 'ally', minimum: 4});
|
||||
for (const value of [null, {}, {turn: 0, phase: 3, ally: 'ally', minimum: 1},
|
||||
{turn: 2, phase: 4, ally: 'ally', minimum: 1},
|
||||
{turn: 2, phase: 3, ally: ' ally ', minimum: 1},
|
||||
{turn: 2, phase: 3, ally: 'ally', minimum: 0},
|
||||
{turn: 2, phase: 3, ally: 'ally', minimum: Number.MAX_SAFE_INTEGER + 1}])
|
||||
assert.throws(() => parsePledgeClaim(value), PledgePromiseError);
|
||||
});
|
||||
|
||||
void test('overlapping future Yes and No answers combine into a single bounded update', () => {
|
||||
const rows = [promise({minimum: 2}), promise({event: 'event-2', minimum: 4}),
|
||||
promise({event: 'event-3', answer: false, minimum: 6})];
|
||||
assert.deepEqual(pledgePromiseRequirements(rows, 'donor', 2, 3, 'ally'),
|
||||
{ally: 'ally', minimum: 4, maximum: 5});
|
||||
assert.equal(pledgePromiseRequirements([...rows, promise({event: 'event-4', answer: false, minimum: 4})],
|
||||
'donor', 2, 3, 'ally'), null);
|
||||
assert.equal(pledgePromiseRequirements(rows, 'donor', 2, 3, 'different'), null);
|
||||
assert.deepEqual(pledgePromiseRequirements(rows, 'donor', 2, 5, 'ally'), {ally: 'ally', minimum: 0});
|
||||
});
|
||||
|
||||
void test('only a future accepted native funding update satisfies Yes, including an explicit same-value update', () => {
|
||||
const rows = [promise({minimum: 3}), promise({event: 'event-2', minimum: 5}),
|
||||
promise({event: 'event-3', answer: false, minimum: 6})];
|
||||
const before = structuredClone(rows);
|
||||
const low = recordPromisedPledge(rows, 'donor', 2, 3, {ally: 'ally', amount: 3});
|
||||
assert.equal(low[0].fulfilled, true);
|
||||
assert.equal(low[1].fulfilled, undefined);
|
||||
assert.equal(low[2].fulfilled, undefined);
|
||||
assert.deepEqual(rows, before);
|
||||
const full = recordPromisedPledge(low, 'donor', 2, 3, {ally: 'ally', amount: 5});
|
||||
assert.equal(full[1].fulfilled, true);
|
||||
assert.equal(full[2].fulfilled, undefined, 'No continues to restrict later same-phase updates');
|
||||
assert.throws(() => recordPromisedPledge(full, 'donor', 2, 3, {ally: 'ally', amount: 6}), PledgePromiseError);
|
||||
assert.deepEqual(rows, before);
|
||||
assert.deepEqual(recordPromisedPledge(rows, 'donor', 2, 3, {ally: 'other', amount: 10}), rows);
|
||||
assert.deepEqual(recordPromisedPledge(rows, 'donor', 2, 5, {ally: 'ally', amount: 10}), rows);
|
||||
});
|
||||
|
||||
void test('an owner cannot voluntarily evade a feasible Yes while a foreign impossibility releases it', () => {
|
||||
const rows = [promise(), promise({event: 'no', answer: false, minimum: 5})];
|
||||
const before = structuredClone(rows);
|
||||
const keep = reconcilePledgePromises(rows, context({canFulfill: (_row, quote) => {
|
||||
assert.deepEqual(quote, {ally: 'ally', minimum: 3, maximum: 4}); return true;
|
||||
}}));
|
||||
assert.deepEqual(keep, rows);
|
||||
assert.throws(() => reconcilePledgePromises(rows, context({canFulfill: () => false, voluntaryActor: 'donor'})),
|
||||
PledgePromiseError);
|
||||
assert.deepEqual(rows, before);
|
||||
const released = reconcilePledgePromises(rows, context({canFulfill: () => false, voluntaryActor: 'asker'}));
|
||||
assert.equal(released[0].released, true);
|
||||
assert.equal(released[1].fulfilled, undefined, 'The No survives a temporary affordability change');
|
||||
assert.deepEqual(rows, before);
|
||||
});
|
||||
|
||||
void test('phase or reciprocal alliance changes expire No and release an unfulfilled Yes', () => {
|
||||
const rows = [promise(), promise({event: 'no', answer: false})];
|
||||
for (const changed of [context({phase: 5}), context({turn: 3}), context({mutualAlly: () => null})]) {
|
||||
const result = reconcilePledgePromises(rows, changed);
|
||||
assert.equal(result[0].released, true);
|
||||
assert.equal(result[1].fulfilled, true);
|
||||
assert.deepEqual(livePledgePromises(result, 'donor', changed.turn, changed.phase), []);
|
||||
}
|
||||
assert.throws(() => reconcilePledgePromises(rows, context({phase: 5, voluntaryActor: 'donor'})), PledgePromiseError);
|
||||
assert.throws(() => reconcilePledgePromises(rows, context({mutualAlly: () => null, voluntaryActor: 'donor'})), PledgePromiseError);
|
||||
});
|
||||
|
||||
void test('saved rows retain distinct events, valid participants and noncontradictory terminal flags', () => {
|
||||
const players = ['donor', 'asker', 'ally'];
|
||||
assert.doesNotThrow(() => validatePledgePromises([promise()], players));
|
||||
for (const bad of [
|
||||
[promise(), promise()], [promise({player: 'ally'})], [promise({asker: 'donor'})],
|
||||
[promise({event: ''})], [promise({fulfilled: true, released: true})],
|
||||
[promise({answer: false, released: true})],
|
||||
]) assert.throws(() => validatePledgePromises(bad, players), PledgePromiseError);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
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 { PledgeClaimFields, pledgeClaimInputError } from '../components/pledge-claim-fields';
|
||||
|
||||
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||||
type Element = ReactElement<ElementProps>;
|
||||
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 input(props: Parameters<typeof PledgeClaimFields>[0]) {
|
||||
const field = elements(PledgeClaimFields(props)).find(element => element.props.id === 'truth-pledge-minimum');
|
||||
assert.ok(field && typeof field.props.onChange === 'function');
|
||||
return field;
|
||||
}
|
||||
function edit(props: Parameters<typeof PledgeClaimFields>[0], value: number) {
|
||||
(input(props).props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)(
|
||||
{ currentTarget: { valueAsNumber: value } });
|
||||
}
|
||||
|
||||
void test('ally funding question checks positive safe totals and respects disabled controls', () => {
|
||||
const changes: number[] = [];
|
||||
const props = {minimum: 2, allyName: 'House Guild', disabled: false, onChange: (value: number) => changes.push(value)};
|
||||
assert.equal(pledgeClaimInputError(props.minimum), null);
|
||||
edit(props, 1); edit(props, Number.MAX_SAFE_INTEGER);
|
||||
for (const value of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
assert.ok(pledgeClaimInputError(value));
|
||||
edit(props, value);
|
||||
}
|
||||
edit({ ...props, disabled: true }, 3);
|
||||
assert.deepEqual(changes, [1, Number.MAX_SAFE_INTEGER]);
|
||||
assert.match(renderToStaticMarkup(createElement(PledgeClaimFields, { ...props, minimum: 0 })), /role="alert"/);
|
||||
});
|
||||
|
||||
void test('ally funding explanation distinguishes an update, previous pledge and payment', () => {
|
||||
const html = renderToStaticMarkup(createElement(PledgeClaimFields, {
|
||||
minimum: 3, allyName: 'House Guild', disabled: false, onChange() {},
|
||||
}));
|
||||
assert.match(html, /Current ally: House Guild/);
|
||||
assert.match(html, /after the answer during this phase/);
|
||||
assert.match(html, /same total again counts/);
|
||||
assert.match(html, /earlier pledge alone does not/);
|
||||
assert.match(html, /not a payment/);
|
||||
assert.doesNotMatch(html, /current balance|available wallet/i);
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
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 { actTruthAlliance as act, advanceTruthAlliance as advance,
|
||||
createTruthAllianceFixture, truthAllianceClean as clean, truthAllianceInventory as inventory,
|
||||
truthAlliancePlayer as player } from './fixture-truth-alliance';
|
||||
import { declareTruthBribe } from './fixture-truth-bribe';
|
||||
|
||||
type PledgePromise = { player: string; ally: string; minimum: number; answer: boolean;
|
||||
turn: number; phase: number; fulfilled?: boolean; released?: boolean };
|
||||
const promise = (game: Game): PledgePromise => {
|
||||
const rows = (game as Game & { pledgePromises?: PledgePromise[] }).pledgePromises;
|
||||
assert.ok(rows?.length); return rows.at(-1)!;
|
||||
};
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
/** A real Basic setup, storm, worm, ordinary Nexus, reciprocal consent and
|
||||
* later Bidding opening. Only the source fixture's original unopened deal and
|
||||
* Spice order are selected; no alliance, purse, aid or question is staged. */
|
||||
function fixture(phase: 3 | 5 = 3) {
|
||||
const base = createTruthAllianceFixture({ consent: 'allied' });
|
||||
const game = advance(base.game, state => state.phase === phase && (phase !== 3 || !!state.auction) && clean(state));
|
||||
assert.equal(player(game, base.target).ally, base.partner);
|
||||
assert.equal(player(game, base.partner).ally, base.target);
|
||||
assert.deepEqual(inventory(game), base.originalCards);
|
||||
return { ...base, game, ally: base.partner, physical: inventory(game) };
|
||||
}
|
||||
type Fixture = ReturnType<typeof fixture>;
|
||||
function ask(f: Fixture, source: Game, minimum: number) {
|
||||
let game = declareTruthBribe(f, source).game;
|
||||
const askerView = viewGame(game, f.asker);
|
||||
assert.ok(askerView.truthPledgeTargets.includes(f.target));
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'pledgeAid', target: f.target,
|
||||
turn: game.turn, phase: game.phase, ally: f.ally, minimum } });
|
||||
return game;
|
||||
}
|
||||
function answer(game: Game, actor: string, response: 'yes' | 'no') {
|
||||
return act(game, actor, { type: 'truthAnswer', answer: response });
|
||||
}
|
||||
|
||||
void test('Basic Yes binds a future accepted same-value ally-funding update without treating it as a payment', () => {
|
||||
const f = fixture();
|
||||
let game = act(f.game, f.target, { type: 'pledgeAid', amount: 3 });
|
||||
const priorSpice = player(game, f.target).spice;
|
||||
const originalAid = structuredClone(game.aid[f.target]);
|
||||
game = ask(f, game, 3);
|
||||
assert.ok(viewGame(game, f.target).truthPledgeAnswers?.includes('yes'));
|
||||
assert.equal(viewGame(game, f.asker).truthPledgeAnswers, null);
|
||||
game = answer(game, f.target, 'yes');
|
||||
assert.equal(promise(game).player, f.target);
|
||||
assert.equal(promise(game).ally, f.ally);
|
||||
assert.equal(promise(game).minimum, 3);
|
||||
assert.ok(!promise(game).fulfilled, 'A pre-answer pledge does not fulfill a future update');
|
||||
assert.deepEqual(game.aid[f.target], originalAid);
|
||||
const guide = viewGame(game, f.target).pledgePromiseGuide;
|
||||
assert.ok(guide); assert.equal(guide.action.type, 'pledgeAid'); assert.equal(guide.action.amount, 3);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, f.target);
|
||||
view.players.find(seat => seat.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.ok(action, difficulty);
|
||||
assert.doesNotThrow(() => act(game, f.target, action));
|
||||
}
|
||||
game = act(game, f.target, guide.action);
|
||||
assert.equal(promise(game).fulfilled, true);
|
||||
assert.deepEqual(game.aid[f.target], originalAid);
|
||||
assert.equal(player(game, f.target).spice, priorSpice);
|
||||
assert.deepEqual(inventory(game), f.physical);
|
||||
});
|
||||
|
||||
void test('Basic No rejects a qualifying pledge without changing the real wallet or escrow', () => {
|
||||
const f = fixture();
|
||||
let game = ask(f, f.game, 3);
|
||||
assert.ok(viewGame(game, f.target).truthPledgeAnswers?.includes('no'));
|
||||
game = answer(game, f.target, 'no');
|
||||
assert.equal(promise(game).answer, false);
|
||||
game = act(game, f.target, { type: 'pledgeAid', amount: 2 });
|
||||
const aid = structuredClone(game.aid[f.target]), spice = player(game, f.target).spice;
|
||||
rejected(game, f.target, { type: 'pledgeAid', amount: 3 });
|
||||
assert.deepEqual(game.aid[f.target], aid);
|
||||
assert.equal(player(game, f.target).spice, spice);
|
||||
assert.deepEqual(inventory(game), f.physical);
|
||||
});
|
||||
|
||||
void test('an unaffordable Yes is unavailable and cannot be forced by the client', () => {
|
||||
const f = fixture(), available = player(f.game, f.target).spice;
|
||||
const game = ask(f, f.game, available + 1);
|
||||
assert.deepEqual(viewGame(game, f.target).truthPledgeAnswers, ['no']);
|
||||
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
});
|
||||
|
||||
void test('an unfulfilled Yes prevents the owner voluntarily spending its remaining pledge capacity', () => {
|
||||
const f = fixture();
|
||||
let game = ask(f, f.game, 4);
|
||||
game = answer(game, f.target, 'yes');
|
||||
const available = player(game, f.target).spice;
|
||||
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: available - 1 });
|
||||
assert.ok(!promise(game).fulfilled && !promise(game).released);
|
||||
const guide = viewGame(game, f.target).pledgePromiseGuide; assert.ok(guide);
|
||||
game = act(game, f.target, guide.action);
|
||||
assert.equal(promise(game).fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.physical);
|
||||
});
|
||||
|
||||
void test('a No pledge commitment stops applying when actual Bidding closes', () => {
|
||||
const f = fixture();
|
||||
let game = ask(f, f.game, 3);
|
||||
game = answer(game, f.target, 'no');
|
||||
game = advance(game, state => state.phase === 4 && clean(state));
|
||||
assert.ok(promise(game).released || promise(game).phase !== game.phase,
|
||||
'The earlier phase commitment cannot constrain a new phase');
|
||||
assert.deepEqual(inventory(game), f.physical);
|
||||
});
|
||||
|
||||
void test('Basic Shipment and Movement Yes preserves existing escrow and uses native pledge funding once', () => {
|
||||
const f = fixture(5);
|
||||
let game = act(f.game, f.target, { type: 'pledgeAid', amount: 1 });
|
||||
const before = structuredClone(game), recipientSpice = player(game, f.ally).spice;
|
||||
game = ask(f, game, 4);
|
||||
assert.ok(viewGame(game, f.target).truthPledgeAnswers?.includes('yes'));
|
||||
game = answer(game, f.target, 'yes');
|
||||
assert.equal(promise(game).phase, 5);
|
||||
assert.equal(game.aid[f.target].amount, 1);
|
||||
assert.equal(player(game, f.target).spice, player(before, f.target).spice);
|
||||
const guide = viewGame(game, f.target).pledgePromiseGuide;
|
||||
assert.ok(guide); assert.equal(guide.action.amount, 4);
|
||||
game = act(game, f.target, guide.action);
|
||||
assert.equal(game.aid[f.target].recipient, f.ally);
|
||||
assert.equal(game.aid[f.target].amount, 4);
|
||||
assert.equal(player(game, f.target).spice, player(before, f.target).spice - 3);
|
||||
assert.equal(player(game, f.ally).spice, recipientSpice, 'Pledged spice has not been paid to the ally');
|
||||
assert.equal(promise(game).fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.physical);
|
||||
});
|
||||
|
||||
void test('Basic Shipment and Movement No rejects qualifying aid and retains the accepted lower escrow', () => {
|
||||
const f = fixture(5);
|
||||
let game = ask(f, f.game, 4);
|
||||
game = answer(game, f.target, 'no');
|
||||
game = act(game, f.target, { type: 'pledgeAid', amount: 3 });
|
||||
const before = structuredClone(game);
|
||||
rejected(game, f.target, { type: 'pledgeAid', amount: 4 });
|
||||
assert.deepEqual(game.aid[f.target], before.aid[f.target]);
|
||||
assert.equal(player(game, f.target).spice, player(before, f.target).spice);
|
||||
game = act(game, f.target, { type: 'pledgeAid', amount: 2 });
|
||||
assert.equal(game.aid[f.target].amount, 2);
|
||||
assert.deepEqual(inventory(game), f.physical);
|
||||
});
|
||||
|
||||
for (const response of ['yes', 'no'] as const) {
|
||||
void test(`a live ${response} pledge excludes a new independent structured action question`, () => {
|
||||
const f = fixture();
|
||||
let game = ask(f, f.game, 3);
|
||||
game = answer(game, f.target, response);
|
||||
const remainingTruth = f.truths.find(id => player(game, f.asker).hand.some(card => card.id === id));
|
||||
assert.ok(remainingTruth, 'The native source retains its second physical Truthtrance');
|
||||
game = declareTruthBribe({ ...f, truths: [remainingTruth] }, game).game;
|
||||
rejected(game, f.asker, { type: 'truthAsk', question: { kind: 'bribe', target: f.target,
|
||||
turn: game.turn, recipient: f.asker, minimum: 1 } });
|
||||
assert.deepEqual(inventory(game), f.physical);
|
||||
});
|
||||
}
|
||||
|
||||
void test('another player’s final deal acceptance releases a now-unaffordable pledge Yes after the real payment', () => {
|
||||
const f = fixture();
|
||||
const minimum = 4, available = player(f.game, f.target).spice;
|
||||
const amount = available - minimum + 1;
|
||||
assert.ok(amount > 0);
|
||||
// The donor actually offers and therefore consents to the immediate
|
||||
// payment before the later Truthtrance answer. No pending deal is staged.
|
||||
let game = act(f.game, f.target, { type: 'dealOffer', target: f.asker,
|
||||
terms: 'A previously agreed payment.',
|
||||
payment: { kind: 'immediateBribe', payer: f.target, recipient: f.asker, amount } });
|
||||
const deal = game.deals?.at(-1); assert.ok(deal);
|
||||
game = ask(f, game, minimum);
|
||||
game = answer(game, f.target, 'yes');
|
||||
assert.ok(!promise(game).fulfilled && !promise(game).released);
|
||||
const before = structuredClone(game), oldBribes = player(game, f.asker).bribes;
|
||||
// The other player’s final acceptance, rather than a new voluntary action
|
||||
// by the payer, executes the already-consented native immediate bribe.
|
||||
game = act(game, f.asker, { type: 'dealRespond', deal: deal.id, accept: true });
|
||||
assert.equal(player(game, f.target).spice, player(before, f.target).spice - amount);
|
||||
assert.equal(player(game, f.asker).bribes, oldBribes + amount);
|
||||
assert.deepEqual(game.aid[f.target], before.aid[f.target]);
|
||||
assert.equal(promise(game).released, true);
|
||||
assert.ok(!promise(game).fulfilled);
|
||||
assert.deepEqual(inventory(game), f.physical);
|
||||
});
|
||||
|
||||
void test('a hidden pledge amount does not reveal whether a public Yes is fulfilled', () => {
|
||||
const f = fixture();
|
||||
let bound = ask(f, f.game, 3);
|
||||
bound = answer(bound, f.target, 'yes');
|
||||
// Both are real updates to the same alliance after the same public answer.
|
||||
// Their publicly logged action and event count are identical, but only the
|
||||
// private escrow amount and fulfillment differ.
|
||||
const below = act(bound, f.target, { type: 'pledgeAid', amount: 2 });
|
||||
const fulfilled = act(bound, f.target, { type: 'pledgeAid', amount: 3 });
|
||||
assert.ok(!promise(below).fulfilled);
|
||||
assert.equal(promise(fulfilled).fulfilled, true);
|
||||
const left = viewGame(below, f.asker), right = viewGame(fulfilled, f.asker);
|
||||
assert.deepEqual(left.log, right.log);
|
||||
assert.deepEqual(left.truthHistory, right.truthHistory);
|
||||
assert.deepEqual(left.aid, right.aid);
|
||||
assert.deepEqual(left.pledgePromises, []);
|
||||
assert.deepEqual(right.pledgePromises, []);
|
||||
assert.deepEqual(left.truthPledgeTargets, right.truthPledgeTargets);
|
||||
assert.deepEqual(left.truthBribeTargets, right.truthBribeTargets);
|
||||
|
||||
const remainingTruth = f.truths.find(id => player(below, f.asker).hand.some(card => card.id === id));
|
||||
assert.ok(remainingTruth);
|
||||
const rejection = (source: Game) => {
|
||||
const waiting = declareTruthBribe({ ...f, truths: [remainingTruth] }, source).game;
|
||||
const before = structuredClone(waiting);
|
||||
let reason = '';
|
||||
try {
|
||||
act(waiting, f.asker, { type: 'truthAsk', question: { kind: 'bribe', target: f.target,
|
||||
turn: waiting.turn, recipient: f.asker, minimum: 1 } });
|
||||
} catch (error) {
|
||||
reason = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
assert.ok(reason, 'The independent future-action question remains unavailable');
|
||||
assert.deepEqual(waiting, before);
|
||||
return reason;
|
||||
};
|
||||
assert.equal(rejection(below), rejection(fulfilled));
|
||||
assert.deepEqual(inventory(below), f.physical);
|
||||
assert.deepEqual(inventory(fulfilled), f.physical);
|
||||
});
|
||||
Reference in new issue
Block a user