Add cumulative Truthtrance bribe-payment commitments
This commit is contained in:
1 parent
7fc21113e3
commit
335b4ce638
18 files changed
+1453
-21
No files matched your search
@@ -1307,6 +1307,7 @@ function policyActions(g: GameView): Action[] {
|
||||
? [{ type: 'decision', event: search.event, card: ordered[0].id }]
|
||||
: [];
|
||||
}
|
||||
if (g.bribePromiseGuide) return [g.bribePromiseGuide.action];
|
||||
if (g.revivalPromiseGuide) return [g.revivalPromiseGuide.action];
|
||||
if (g.bidPromiseGuide) return [g.bidPromiseGuide.action];
|
||||
if (g.truthtrance) {
|
||||
@@ -1315,6 +1316,10 @@ function policyActions(g: GameView): Action[] {
|
||||
return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }];
|
||||
if (w.stage === 'answer') {
|
||||
if (w.question?.target !== me.id) return [];
|
||||
if (w.question.kind === 'bribe') {
|
||||
const answer = g.truthBribeAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'revival') {
|
||||
const answer = g.truthRevivalAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope';
|
||||
|
||||
export type BribeClaim = { turn: number; recipient: string; minimum: number };
|
||||
export type BribePromise = BribeClaim & {
|
||||
event: string;
|
||||
player: string;
|
||||
asker: string;
|
||||
answer: boolean;
|
||||
/** Matching ordinary payments after this answer, before their later receipt. */
|
||||
progress: number;
|
||||
fulfilled?: boolean;
|
||||
released?: boolean;
|
||||
};
|
||||
type BribePayment = { recipient: string; amount: number };
|
||||
|
||||
export class BribePromiseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'BribePromiseError';
|
||||
}
|
||||
}
|
||||
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||
const positiveInteger = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||||
const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean';
|
||||
|
||||
export function parseBribeClaim(value: unknown): BribeClaim {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new BribePromiseError('Choose a current-turn ordinary bribe question.');
|
||||
const input = value as Record<string, unknown>;
|
||||
if (!positiveInteger(input.turn) || !identifier(input.recipient) || !positiveInteger(input.minimum))
|
||||
throw new BribePromiseError('Choose the current turn, exact recipient and a positive safe whole bribe minimum.');
|
||||
return { turn: input.turn, recipient: input.recipient, minimum: input.minimum };
|
||||
}
|
||||
|
||||
/** Permission for the existing bare native context, not a game-start gate.
|
||||
* The engine retains nonally targets, timing and ordinary payment authority. */
|
||||
export function bribePromiseModeSupported(game: NativePromiseScope & {
|
||||
semutaPreview?: unknown; kullPreview?: unknown; nexusKullPreview?: unknown;
|
||||
richeseBetrayalPreview?: unknown;
|
||||
}): boolean {
|
||||
return nativePromiseScopeSupported(game) && !game.semutaPreview && !game.kullPreview &&
|
||||
!game.nexusKullPreview && !game.richeseBetrayalPreview;
|
||||
}
|
||||
|
||||
export function bribeClaimText(claim: BribeClaim, recipientName: (id: string) => string = id => id): string {
|
||||
return `pay at least ${claim.minimum} spice to ${recipientName(claim.recipient)} in ordinary bribes from now until this turn ends`;
|
||||
}
|
||||
|
||||
export function liveBribePromises(promises: readonly BribePromise[], player: string, turn: number): BribePromise[] {
|
||||
return promises.filter(promise => promise.player === player && promise.turn === turn &&
|
||||
!promise.fulfilled && !promise.released);
|
||||
}
|
||||
|
||||
function validateProgress(promise: BribePromise): void {
|
||||
parseBribeClaim(promise);
|
||||
if (typeof promise.answer !== 'boolean' || !Number.isSafeInteger(promise.progress) || promise.progress < 0 ||
|
||||
!optionalBoolean(promise.fulfilled) || !optionalBoolean(promise.released) ||
|
||||
(promise.fulfilled && promise.released) ||
|
||||
(!promise.answer && promise.progress >= promise.minimum) ||
|
||||
(promise.answer && !promise.released && !!promise.fulfilled !== (promise.progress >= promise.minimum)))
|
||||
throw new BribePromiseError('The bribe commitment has invalid cumulative answer progress.');
|
||||
}
|
||||
|
||||
/** Provisional cumulative composition: only the actual payer's completed
|
||||
* ordinary payments count. Prior payments, card effects, gifts and the later
|
||||
* Mentat receipt do not call this function or add progress again. */
|
||||
export function recordPromisedBribe(promises: readonly BribePromise[], player: string, turn: number,
|
||||
payment: BribePayment): BribePromise[] {
|
||||
if (!payment || typeof payment !== 'object' || Array.isArray(payment) ||
|
||||
!identifier(payment.recipient) || !positiveInteger(payment.amount))
|
||||
throw new BribePromiseError('Retain the exact recipient and positive safe whole ordinary bribe payment.');
|
||||
const progress = new Map<BribePromise, number>();
|
||||
for (const promise of liveBribePromises(promises, player, turn)) {
|
||||
if (promise.recipient !== payment.recipient) continue;
|
||||
validateProgress(promise);
|
||||
const next = promise.progress + payment.amount;
|
||||
if (!Number.isSafeInteger(next))
|
||||
throw new BribePromiseError('The cumulative bribe amount exceeds the safe integer range.');
|
||||
if (!promise.answer && next >= promise.minimum)
|
||||
throw new BribePromiseError('This payment conflicts with your cumulative Truthtrance No answer.');
|
||||
progress.set(promise, next);
|
||||
}
|
||||
return promises.map(promise => {
|
||||
const next = progress.get(promise);
|
||||
return next === undefined ? promise : {
|
||||
...promise, progress: next,
|
||||
...(promise.answer && next >= promise.minimum ? { fulfilled: true } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Minimal final additional ordinary payments, grouped by exact recipient.
|
||||
* Overlapping Yes answers share the same future payment; different recipients
|
||||
* each require their own spice. No caps remain binding after smaller payments. */
|
||||
export function bribePromiseRequirements(promises: readonly BribePromise[], player: string, turn: number): BribePayment[] | null {
|
||||
const live = liveBribePromises(promises, player, turn);
|
||||
const required = new Map<string, number>();
|
||||
for (const promise of live) {
|
||||
validateProgress(promise);
|
||||
if (promise.answer)
|
||||
required.set(promise.recipient, Math.max(required.get(promise.recipient) ?? 0, promise.minimum - promise.progress));
|
||||
}
|
||||
for (const promise of live) {
|
||||
const amount = required.get(promise.recipient) ?? 0;
|
||||
if (!promise.answer && amount >= promise.minimum - promise.progress) return null;
|
||||
if (!Number.isSafeInteger(promise.progress + amount)) return null;
|
||||
}
|
||||
let total = 0;
|
||||
const payments: BribePayment[] = [];
|
||||
for (const [recipient, amount] of required) {
|
||||
total += amount;
|
||||
if (!Number.isSafeInteger(total)) return null;
|
||||
if (amount > 0) payments.push({ recipient, amount });
|
||||
}
|
||||
return payments;
|
||||
}
|
||||
|
||||
export function validateBribePromises(promises: readonly BribePromise[], players: readonly string[]): void {
|
||||
if (!Array.isArray(promises)) throw new BribePromiseError('The bribe commitment record is invalid.');
|
||||
const events = new Set<string>();
|
||||
for (const promise of promises) {
|
||||
parseBribeClaim(promise);
|
||||
if (!identifier(promise.event) || events.has(promise.event) ||
|
||||
!identifier(promise.player) || !identifier(promise.asker) ||
|
||||
!players.includes(promise.player) || !players.includes(promise.asker) || !players.includes(promise.recipient) ||
|
||||
promise.player === promise.asker || promise.player === promise.recipient)
|
||||
throw new BribePromiseError('The bribe commitment lost its original event or participants.');
|
||||
validateProgress(promise);
|
||||
events.add(promise.event);
|
||||
}
|
||||
}
|
||||
+172
-8
@@ -1,3 +1,5 @@
|
||||
import { BribePromiseError, bribePromiseModeSupported, liveBribePromises, recordPromisedBribe,
|
||||
bribePromiseRequirements, validateBribePromises, type BribeClaim, type BribePromise } from './bribe-promises';
|
||||
import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income';
|
||||
import { createRevivalOrder, validateRevivalOrder, revivalOrderBlock, startRevivalOrder,
|
||||
saphoRevivalOrderEvent, reorderSaphoRevivalOrder,
|
||||
@@ -2139,6 +2141,7 @@ export type Game = {
|
||||
shipmentPromises?: ShipmentPromise[];
|
||||
bidPromises?: BidPromise[];
|
||||
revivalPromises?: RevivalPromise[];
|
||||
bribePromises?: BribePromise[];
|
||||
movementPromises?: MovementPromise[];
|
||||
/** Server-only certainty of movement-relevant cards still in the discard. */
|
||||
movementDiscardKnowledge?: MovementDiscardKnowledge;
|
||||
@@ -14892,6 +14895,120 @@ function pledgeAid(g: Game, p: Player, requested: unknown) {
|
||||
g.aid[p.id] = { recipient: p.ally, amount };
|
||||
log(g, `${p.name} updated the spice available to their ally this phase.`);
|
||||
}
|
||||
function bribePromiseRule<T>(calculate: () => T): T {
|
||||
try { return calculate(); }
|
||||
catch (error) { if (error instanceof BribePromiseError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function bribePromiseIntegrity(g: Game) {
|
||||
bribePromiseRule(() => validateBribePromises(g.bribePromises ?? [], g.players.map(player => player.id)));
|
||||
}
|
||||
function bribePromiseTurn(g: Game): boolean {
|
||||
return g.status === 'playing' && g.phase >= 0 && g.phase < 8 && bribePromiseModeSupported(g);
|
||||
}
|
||||
function bribeQuestionWindow(g: Game): boolean {
|
||||
// A current Double side can fix No without authorizing any payment. Every
|
||||
// original pending-decision guard still applies to the structured question.
|
||||
return bribePromiseTurn(g) && !bribeTimingBlock({ ...g, truthtrance: null,
|
||||
inflation: g.inflation?.side === 'double' ? null : g.inflation });
|
||||
}
|
||||
/** First funded native prototype: reserve a jointly legal payment now, rather
|
||||
* than inventing future income or moving incoming bribes before Mentat. */
|
||||
function bribePromiseCompletion(g: Game, p: Player, promises = g.bribePromises ?? []): Action[] | null {
|
||||
if (!bribePromiseTurn(g)) return null;
|
||||
const required = bribePromiseRequirements(promises, p.id, g.turn);
|
||||
if (!required) return null;
|
||||
if (!required.length) return [];
|
||||
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
|
||||
trial.truthtrance = null;
|
||||
if (bribeTimingBlock(trial)) return null;
|
||||
const total = required.reduce((sum, payment) => sum + payment.amount, 0);
|
||||
if (total > uncommittedSpice(trial, actor)) return null;
|
||||
trial.bribePromises = structuredClone(promises);
|
||||
for (const payment of required) {
|
||||
if (payment.recipient === p.id || payment.recipient === p.ally ||
|
||||
!g.players.some(player => player.id === payment.recipient)) return null;
|
||||
trial.bribePromises = bribePromiseRule(() => recordPromisedBribe(trial.bribePromises ?? [], p.id,
|
||||
g.turn, payment));
|
||||
}
|
||||
actor.spice -= total;
|
||||
// This is a private compatibility quotation. It creates neither a payment
|
||||
// receipt nor recipient cash; the real native action owns both operations.
|
||||
try {
|
||||
const voluntary = { actor: p.id, action: { type: 'bribe' } };
|
||||
for (const payment of required)
|
||||
checkBureaucratContributors(trial, payment.recipient, [{ payer: p.id, amount: payment.amount }]);
|
||||
reconcileBattlePromises(trial, voluntary);
|
||||
reconcileShipmentPromises(trial, voluntary);
|
||||
reconcileGroundMovementPromises(trial, p.id, voluntary.action, g.phase);
|
||||
reconcileBidPromises(trial, voluntary);
|
||||
reconcileRevivalPromises(trial, voluntary);
|
||||
return required.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount }));
|
||||
} catch (error) { if (error instanceof RuleError) return null; throw error; }
|
||||
}
|
||||
function bribeCompatibleLaterPromises(g: Game, p: Player,
|
||||
later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises' | 'revivalPromises' | 'battle'>): boolean {
|
||||
if (!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) return true;
|
||||
return !!bribePromiseCompletion({ ...g, ...later }, p);
|
||||
}
|
||||
function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no' | 'unknown')[] {
|
||||
if (!bribeQuestionWindow(g) || claim.turn !== g.turn || claim.recipient === p.id ||
|
||||
claim.recipient === p.ally || !g.players.some(player => player.id === claim.recipient)) return [];
|
||||
return (['yes', 'no'] as const).filter(answer => !!bribePromiseCompletion(g, p, [...(g.bribePromises ?? []),
|
||||
{ ...claim, event: 'candidate', player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host,
|
||||
answer: answer === 'yes', progress: 0 }]));
|
||||
}
|
||||
function bindBribeTruth(g: Game, p: Player, claim: BribeClaim, answer: boolean, asker: string) {
|
||||
requireRule(bribeTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
|
||||
'Choose a bribe answer compatible with the current funds and earlier answers.');
|
||||
(g.bribePromises ??= []).push({ ...claim, event: crypto.randomUUID(), player: p.id, asker, answer, progress: 0 });
|
||||
if (answer && g.phase === 7 && g.ready.includes(p.id)) {
|
||||
g.ready = g.ready.filter(player => player !== p.id);
|
||||
log(g, `${p.name} has a new Truthtrance bribe commitment and must finish Collection again after paying it.`);
|
||||
}
|
||||
}
|
||||
function reconcileBribePromises(g: Game, voluntary?: { actor: string; action: Action }) {
|
||||
if (!g.bribePromises?.some(promise => !promise.fulfilled && !promise.released)) return;
|
||||
bribePromiseIntegrity(g);
|
||||
if (g.truthtrance) return;
|
||||
const kept: BribePromise[] = [];
|
||||
const no = g.bribePromises.filter(promise => !promise.answer && !promise.fulfilled && !promise.released);
|
||||
const pending = g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard ||
|
||||
g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal;
|
||||
if (pending && voluntary) {
|
||||
const required = bribePromiseRequirements(g.bribePromises, voluntary.actor, g.turn);
|
||||
requireRule(required && required.reduce((sum, payment) => sum + payment.amount, 0) <=
|
||||
Math.max(0, uncommittedSpice(g, getPlayer(g, voluntary.actor))),
|
||||
'Keep the current spice needed by your Truthtrance bribe answer.');
|
||||
}
|
||||
if (pending) return;
|
||||
for (const promise of g.bribePromises) {
|
||||
if (promise.fulfilled || promise.released) continue;
|
||||
const same = bribePromiseTurn(g) && promise.turn === g.turn;
|
||||
if (!promise.answer) { if (!same) promise.fulfilled = true; continue; }
|
||||
if (!same || !bribePromiseCompletion(g, getPlayer(g, promise.player), [...no, ...kept, promise])) {
|
||||
requireRule(voluntary?.actor !== promise.player,
|
||||
'You cannot voluntarily evade your Truthtrance bribe answer.');
|
||||
promise.released = true;
|
||||
log(g, `${getPlayer(g, promise.player).name} can no longer fulfill the Truthtrance bribe answer; that answer is no longer binding.`);
|
||||
} else kept.push(promise);
|
||||
}
|
||||
}
|
||||
function projectedBribePromiseGuide(g: Game, player: string): { action: Action } | null {
|
||||
if (!bribeQuestionWindow(g) || g.truthtrance) return null;
|
||||
const action = bribePromiseCompletion(g, getPlayer(g, player))?.[0];
|
||||
if (!action) return null;
|
||||
const promise = liveBribePromises(g.bribePromises ?? [], player, g.turn)
|
||||
.find(promise => promise.answer && promise.recipient === action.target);
|
||||
return promise ? { action: { ...action, bribePromiseEvent: promise.event } } : null;
|
||||
}
|
||||
function bribePaymentPreservesPromises(g: Game, p: Player, recipient: string, amount: number): boolean {
|
||||
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
|
||||
try {
|
||||
trial.bribePromises = recordPromisedBribe(trial.bribePromises ?? [], p.id, g.turn, { recipient, amount });
|
||||
actor.spice -= amount;
|
||||
return !!bribePromiseCompletion(trial, actor);
|
||||
} catch (error) { if (error instanceof BribePromiseError || error instanceof RuleError) return false; throw error; }
|
||||
}
|
||||
function projectedBribes(g: Game, p: Player): BribeOptions {
|
||||
const available = Math.max(0, uncommittedSpice(g, p));
|
||||
const blocked = bribeTimingBlock(g);
|
||||
@@ -14899,9 +15016,11 @@ function projectedBribes(g: Game, p: Player): BribeOptions {
|
||||
promise.player === p.id && !promise.released && !g.battle?.revealed && !g.battle?.plans[p.id]);
|
||||
const shipmentPromises = liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length > 0;
|
||||
const movementPromises = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer);
|
||||
const revivalPromises = liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer);
|
||||
const bribePromises = liveBribePromises(g.bribePromises ?? [], p.id, g.turn).length > 0;
|
||||
// Reuse the authoritative promise checks on an isolated resource-only copy.
|
||||
// No payment, receipt or live continuation is created by this projection.
|
||||
const maximum = !blocked && (battlePromises || shipmentPromises || movementPromises)
|
||||
const maximum = !blocked && (battlePromises || shipmentPromises || movementPromises || revivalPromises)
|
||||
? maximumBribe(available, amount => {
|
||||
const probe = structuredClone(g);
|
||||
getPlayer(probe, p.id).spice -= amount;
|
||||
@@ -14909,6 +15028,7 @@ function projectedBribes(g: Game, p: Player): BribeOptions {
|
||||
if (battlePromises) reconcileBattlePromises(probe, { actor: p.id, action: { type: 'bribe' } });
|
||||
if (shipmentPromises) reconcileShipmentPromises(probe, { actor: p.id, action: { type: 'bribe' } });
|
||||
if (movementPromises && !groundMovementCompletion(probe, p.id)) return false;
|
||||
if (revivalPromises) reconcileRevivalPromises(probe, { actor: p.id, action: { type: 'bribe' } });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof RuleError) return false;
|
||||
@@ -14920,8 +15040,11 @@ function projectedBribes(g: Game, p: Player): BribeOptions {
|
||||
available, incoming: p.bribes, blocked,
|
||||
targets: g.players.filter(target => target.id !== p.id && target.id !== p.ally).map(target => {
|
||||
let limit = maximum;
|
||||
if (!blocked && bribePromises) limit = maximumBribe(limit,
|
||||
amount => bribePaymentPreservesPromises(g, p, target.id, amount));
|
||||
let limitation: string | null = maximum < available
|
||||
? 'Some spice must remain available to fulfill your Truthtrance answer.' : null;
|
||||
if (limit < maximum) limitation = 'Your Truthtrance bribe answers limit this recipient’s payment.';
|
||||
if (!blocked && limit >= 5) {
|
||||
try { checkBureaucratContributors(g, target.id, [{ payer: p.id, amount: limit }]); }
|
||||
catch (error) {
|
||||
@@ -15125,15 +15248,20 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []
|
||||
const action: Action = { type: 'bid', ...normalBidQuote(g, p, range.minimum) };
|
||||
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
|
||||
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length ||
|
||||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length) {
|
||||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length ||
|
||||
liveBribePromises(g.bribePromises ?? [], p.id, g.turn).length) {
|
||||
const trial = structuredClone(g);
|
||||
trial.truthtrance = null;
|
||||
trial.auction!.bid = action.amount as number;
|
||||
trial.auction!.bidder = p.id;
|
||||
trial.auction!.allyPayment = action.allyPayment as number;
|
||||
// The detached accepted declaration satisfies its own Bid Yes before
|
||||
// another promise checks the remaining purse; it is not a second bid.
|
||||
trial.bidPromises = recordPromisedBid(promises, p.id, opportunity, action.amount as number);
|
||||
reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase });
|
||||
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
|
||||
reconcileRevivalPromises(trial, { actor: p.id, action });
|
||||
reconcileBribePromises(trial, { actor: p.id, action });
|
||||
}
|
||||
return action;
|
||||
}
|
||||
@@ -18211,7 +18339,8 @@ function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAn
|
||||
const promise = { ...claim, turn: g.turn, player: p.id, asker: g.players.find(other => other.id !== p.id)!.id, answer };
|
||||
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
if (groundMovementCompletion(answerSource, p.id, [...live, promise]) &&
|
||||
revivalCompatibleLaterPromises(answerSource, p, { movementPromises: [...(g.movementPromises ?? []), promise] }))
|
||||
revivalCompatibleLaterPromises(answerSource, p, { movementPromises: [...(g.movementPromises ?? []), promise] }) &&
|
||||
bribeCompatibleLaterPromises(answerSource, p, { movementPromises: [...(g.movementPromises ?? []), promise] }))
|
||||
answers.push(answer ? 'yes' : 'no');
|
||||
}
|
||||
return answers.length ? answers : ['unknown'];
|
||||
@@ -26426,6 +26555,9 @@ function earlyRevivalCompletionSeeds(initial: Game, source: Game, owner: string)
|
||||
}
|
||||
function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string): ShipmentCompletionSeed[] {
|
||||
const seeds = earlyRevivalCompletionSeeds(initial, source, owner);
|
||||
// A real Movement quote already owns its active Karama/rate and allowances.
|
||||
// Only earlier phases may pass through the future Revival/Movement reset.
|
||||
if (source.phase > 4) return seeds;
|
||||
const revivalSeeds: ShipmentCompletionSeed[] = [];
|
||||
for (const seed of seeds) {
|
||||
const g = structuredClone(seed.game); g.phase = 4;
|
||||
@@ -26670,7 +26802,8 @@ function shipmentTruthAnswers(
|
||||
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder)
|
||||
? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
if (findShipmentCompletion(answerSource, p, promises) &&
|
||||
revivalCompatibleLaterPromises(answerSource, p, { shipmentPromises: [...(g.shipmentPromises ?? []), promises[promises.length - 1]] }))
|
||||
revivalCompatibleLaterPromises(answerSource, p, { shipmentPromises: [...(g.shipmentPromises ?? []), promises[promises.length - 1]] }) &&
|
||||
bribeCompatibleLaterPromises(answerSource, p, { shipmentPromises: [...(g.shipmentPromises ?? []), promises[promises.length - 1]] }))
|
||||
answers.push(answer ? 'yes' : 'no');
|
||||
}
|
||||
return answers.length ? answers : ['unknown'];
|
||||
@@ -26803,7 +26936,8 @@ function battleTruthAnswers(
|
||||
if (
|
||||
findReachableBattlePlan(g, p, {
|
||||
promises: [...promises, { player: p.id, asker: '', claim, answer }],
|
||||
})
|
||||
}) && bribeCompatibleLaterPromises(g, p, { battle: g.battle ? { ...g.battle,
|
||||
truthPromises: [...promises, { player: p.id, asker: '', claim, answer }] } : null })
|
||||
)
|
||||
answers.push(answer ? 'yes' : 'no');
|
||||
return answers.length ? answers : ['unknown'];
|
||||
@@ -29914,7 +30048,8 @@ function revivalTruthAnswers(g: Game, p: Player, claim: RevivalClaim): ('yes' |
|
||||
return (['yes', 'no'] as const).filter(answer => {
|
||||
const promises = [...(g.revivalPromises ?? []), { ...claim, event: 'candidate', player: p.id,
|
||||
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: 0 }];
|
||||
if (!revivalPromiseCompletion(g, p, promises)) return false;
|
||||
if (!revivalPromiseCompletion(g, p, promises) ||
|
||||
!bribeCompatibleLaterPromises(g, p, { revivalPromises: promises })) return false;
|
||||
const lot = bidOpportunity(g);
|
||||
// An earlier Bid Yes reserves a future declaration even before it becomes
|
||||
// the actual leading invoice. Check that declaration with this candidate.
|
||||
@@ -34810,6 +34945,7 @@ function normalizeCardNames(g: Game) {
|
||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
bidPromiseIntegrity(state);
|
||||
revivalPromiseIntegrity(state);
|
||||
bribePromiseIntegrity(state);
|
||||
revivalOrderIntegrity(state);
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
@@ -34913,6 +35049,12 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
);
|
||||
return normalizeAutomaticGame(state);
|
||||
}
|
||||
if (action.bribePromiseEvent !== undefined) {
|
||||
const guide = projectedBribePromiseGuide(state, id);
|
||||
requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
|
||||
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
|
||||
'This bribe commitment guide is stale or belongs to another payment.');
|
||||
}
|
||||
if (action.revivalPromiseEvent !== undefined) {
|
||||
const guide = projectedRevivalPromiseGuide(state, id);
|
||||
requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
|
||||
@@ -34922,6 +35064,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
const g = applyActionInner(state, id, action);
|
||||
reconcileBidPromises(g, { actor: id, action });
|
||||
reconcileRevivalPromises(g, { actor: id, action });
|
||||
reconcileBribePromises(g, { actor: id, action });
|
||||
reconcileGroundMovementPromises(g, id, action, state.phase);
|
||||
reconcileTraitorTruth(g, id, action);
|
||||
harkonnenNexusBetrayalIntegrity(g);
|
||||
@@ -35045,6 +35188,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
reconcileShipmentPromises(g, { actor: id, action, phase: state.phase });
|
||||
reconcileBidPromises(g, { actor: id, action });
|
||||
reconcileRevivalPromises(g, { actor: id, action });
|
||||
reconcileBribePromises(g, { actor: id, action });
|
||||
settleAutomaticContinuations(g);
|
||||
observeOccupation(g);
|
||||
reconcileHomeworldEcazDuke(g);
|
||||
@@ -35247,6 +35391,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
reconcileShipmentPromises(g);
|
||||
reconcileBidPromises(g);
|
||||
reconcileRevivalPromises(g);
|
||||
reconcileBribePromises(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
reconcileTraitorTruth(g);
|
||||
requireRule(
|
||||
@@ -35262,6 +35407,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
export function normalizeAutomaticGame(state: Game): Game {
|
||||
bidPromiseIntegrity(state);
|
||||
revivalPromiseIntegrity(state);
|
||||
bribePromiseIntegrity(state);
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
harkonnenNexusBetrayalIntegrity(state);
|
||||
@@ -35347,6 +35493,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
reconcileShipmentPromises(g);
|
||||
reconcileBidPromises(g);
|
||||
reconcileRevivalPromises(g);
|
||||
reconcileBribePromises(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
reconcileTraitorTruth(g);
|
||||
settleAutomaticContinuations(g);
|
||||
@@ -35592,6 +35739,8 @@ function applyActionInner(
|
||||
bindShipment: bindShipmentTruth,
|
||||
movementAnswers: movementTruthAnswers,
|
||||
bindMovement: bindMovementTruth,
|
||||
bribeAnswers: bribeTruthAnswers,
|
||||
bindBribe: bindBribeTruth,
|
||||
revivalAnswers: revivalTruthAnswers,
|
||||
bindRevival: bindRevivalTruth,
|
||||
bidAnswers: bidTruthAnswers,
|
||||
@@ -35605,7 +35754,7 @@ function applyActionInner(
|
||||
)
|
||||
return g;
|
||||
} catch (error) {
|
||||
if (error instanceof TruthError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
|
||||
if (error instanceof TruthError || error instanceof BribePromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
|
||||
throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
@@ -37905,6 +38054,8 @@ function applyActionInner(
|
||||
);
|
||||
const n = integer(action.amount, 1, uncommittedSpice(g, p), 'Spice');
|
||||
checkBureaucratContributors(g,target.id,[{payer:p.id,amount:n}]);
|
||||
if (liveBribePromises(g.bribePromises ?? [], p.id, g.turn).length)
|
||||
g.bribePromises = bribePromiseRule(() => recordPromisedBribe(g.bribePromises ?? [], p.id, g.turn, { recipient: target.id, amount: n }));
|
||||
p.spice -= n;
|
||||
log(
|
||||
g,
|
||||
@@ -37953,6 +38104,9 @@ function applyActionInner(
|
||||
if (target !== null) {
|
||||
other = getPlayer(g, target);
|
||||
requireRule(other.id !== id, 'Choose another player.');
|
||||
requireRule(!liveBribePromises(g.bribePromises ?? [], p.id, g.turn)
|
||||
.some(promise => promise.answer && promise.recipient === other!.id),
|
||||
'Pay your promised ordinary bribe before offering that recipient an alliance.');
|
||||
const blocked = homeworldAllianceReason(g, id, other.id);
|
||||
requireRule(!blocked, blocked ?? 'This alliance is unavailable.');
|
||||
}
|
||||
@@ -38093,12 +38247,15 @@ function applyActionInner(
|
||||
const a = g.auction;
|
||||
if (t === 'bid') {
|
||||
const quotedBid = normalBidQuote(g, p, action.amount, action.allyPayment);
|
||||
if (liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
|
||||
if (liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
|
||||
liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
|
||||
const trial = structuredClone(g);
|
||||
trial.auction!.bid = quotedBid.amount;
|
||||
trial.auction!.bidder = p.id;
|
||||
trial.auction!.allyPayment = quotedBid.allyPayment;
|
||||
guardPromisedBid(trial, getPlayer(trial, p.id), action, quotedBid.amount);
|
||||
reconcileRevivalPromises(trial, { actor: p.id, action });
|
||||
reconcileBribePromises(trial, { actor: p.id, action });
|
||||
}
|
||||
guardPromisedBid(g, p, action, quotedBid.amount);
|
||||
a.bid = quotedBid.amount;
|
||||
@@ -39331,6 +39488,8 @@ function applyActionInner(
|
||||
return g;
|
||||
}
|
||||
if (t === 'ready') {
|
||||
if (g.phase === 7) requireRule(!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer),
|
||||
'Complete your Truthtrance bribe payments before finishing Collection.');
|
||||
if (g.phase === 4) guardPromisedRevival(g, p, action);
|
||||
requireRule(
|
||||
[1, 2, 4, 7, 8].includes(g.phase) ||
|
||||
@@ -39525,6 +39684,11 @@ export function viewGame(state: Game, id: string) {
|
||||
}).map(player => player.id) : [],
|
||||
truthBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'bid' ? bidTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
truthBribeTargets: bribeQuestionWindow(g) ? g.players.map(player => player.id) : [],
|
||||
truthBribeAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'bribe' ? bribeTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
bribePromises: (g.bribePromises ?? []).filter(promise => promise.player === id).map(promise => ({ ...promise })),
|
||||
bribePromiseGuide: projectedBribePromiseGuide(g, id),
|
||||
truthRevivalTargets: revivalQuestionWindow(g) ? g.players.filter(player => revivalOpportunity(g, player.id)).map(player => player.id) : [],
|
||||
truthRevivalAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'revival' ? revivalTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
|
||||
+29
-2
@@ -297,7 +297,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'You may pay out of turn when no decision or response is pending. You cannot bribe yourself or your ally, during Mentat Pause, or while Inflation shows Double. Emperor gifts to allies use their own power controls.',
|
||||
'Your payment limit excludes reserved spending and preserves binding Truthtrance answers. If an alliance, pending decision or available supply changes, recheck the recipient and amount. A rejected payment does not change the game.',
|
||||
'A qualifying Bureaucrat may divert two spice to the Bank without reducing the payer’s cost. The remainder stays deferred. Unsupported combinations remain unavailable and do not imply an official rule prohibition.',
|
||||
'AI seats can receive bribes but do not negotiate promises yet. Discuss an agreement with human players, then use the corresponding game controls; prose deals are not automatically enforced.',
|
||||
'Minimal AI follows supported Truthtrance ordinary-payment answers and their legal guides. Strategic bribe initiation and negotiation remain deferred. Discuss other agreements with human players; broader prose deals are not automatically enforced.',
|
||||
],
|
||||
related: ['table-discussion', 'alliance-funding', 'card-truthtrance', 'privacy'],
|
||||
checklist: [
|
||||
@@ -3208,6 +3208,31 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Verification', status: 'Partial', detail: '13 files101/101, types/lint/build and six native engine/answer-callback/GameTable SSR journeys pass. Genuine26/72/77-action Basic/allied/Cunning sources earn original cards, alliances, payments, reveal and draw/return. Four profiles answer/vote legally. Unmatched-card staging is labelled; submitted-false and response/typing fixture feedback is repaired. Full future promises, combinations and final assurance remain open.', evidence: ['tests/traitor-decision-promises.test.ts', 'tests/fixture-truth-traitor-decision.ts', 'tests/fixture-truth-traitor-harkonnen.ts', 'tests/truth-traitor-decision-engine.test.ts', 'tests/truth-traitor-harkonnen-engine.test.ts', 'tests/truth-traitor-decision-controls.test.tsx', 'docs/TRUTHTRANCE_TRAITOR_DECISIONS.md'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-bribe',
|
||||
title: 'Truthtrance: ordinary bribe-payment promises',
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Bind cumulative future ordinary spice payments to a named non-allied recipient, retaining actual funding and deferred collection.',
|
||||
steps: [
|
||||
'Choose Bind ordinary bribe payments this turn, another respondent, a named non-allied recipient and a positive whole minimum. The current turn is fixed. Registered native Basic and Advanced tables without optional modules support this funded-payment prototype.',
|
||||
'Yes requires cumulative matching ordinary payer payments after the answer while possible. No keeps later matching payments below the minimum, even across smaller payments. Earlier payments, gifts and allied aid do not count. Several Yes questions for one recipient share the largest deficit; different recipients reserve separate spice.',
|
||||
'The payer spends spice when the actual native bribe is accepted. The recipient keeps that payment deferred until the original Mentat collection. The question creates no immediate recipient cash, discount or refund. Current auction and sealed-battle liabilities stay reserved.',
|
||||
'Only answers supported by current spendable spice and earlier battle, bid, revival, shipment and movement commitments are offered. Future income, card preparations, pledge withdrawal and gifts are not forecast. A later supported question must preserve an earlier bribe Yes too.',
|
||||
'Your persistent panel shows named cumulative progress and fulfilled/released status. Its legal ordinary-payment guide works out of turn in a clear native window. Current payment controls also apply the recipient cap and retain funding for other Yes answers. Stale or modified guides reject.',
|
||||
'Your own voluntary spending, alliance offer to the recipient or finishing Collection cannot evade an unpaid Yes. An already-ready Collection respondent must finish again after a new Yes. Completed opposing consequences may release impossible answers in accepted order. No finishes at the original turn boundary.',
|
||||
'The current software bribe window excludes Mentat and Inflation Double. No can be fixed under Double, while Yes requires a currently legal funded payment. This cumulative funded-current-turn protocol is a provisional composition of general Truthtrance authority; optional modules, future preparations, broader deals and complete-mode acceptance remain due.',
|
||||
],
|
||||
related: ['card-truthtrance', 'bribes', 'truthtrance-bid', 'truthtrance-revival', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Recipient-specific cumulative ordinary payments, joint funding/caps, real payer completion, deferred native receipt and accepted-order release retain original mechanics.', evidence: ['game/bribe-promises.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/bribe-promises.test.ts', 'tests/truth-bribe-runtime.test.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Typed current-turn named-recipient fields, public preview, target-only answers and persistent guarded out-of-turn payment guide; ordinary recipient limits retain other commitments.', evidence: ['components/bribe-claim-fields.tsx', 'components/bribe-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-bribe-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Existing four minimal profiles answer and execute legal ordinary-payment guides. Strategic initiation/negotiation and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-bribe-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'General Truthtrance/physical bribery sources, provisional cumulative meaning, funded scope, original timing and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_BRIBE_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused pure, original deal/payment/Mentat runtime and controlled/native UI evidence. Native alliance campaigns, optional compositions, future preparations and final browser/deployed acceptance remain open.', evidence: ['tests/fixture-truth-bribe.ts', 'tests/truth-bribe-runtime.test.ts', 'tests/truth-bribe-controls.test.tsx'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-revival',
|
||||
title: 'Truthtrance: ordinary force-revival promises',
|
||||
@@ -3407,7 +3432,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
summary:
|
||||
'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
|
||||
'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, ordinary bribe-payment, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
|
||||
steps: [
|
||||
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
|
||||
'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
|
||||
@@ -3419,6 +3444,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier Basic phases with declared native families, owned Charity/ordinary revival preparation and exact normal/native auction funding, plus active or unused waiting phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.',
|
||||
'After both plans reveal, a separate current-Traitor question binds an actual unsubmitted native voter to Reveal or decline. The declaration fulfills it before later Karama/betrayal prevention. Cunning cannot voluntarily return a matching card needed by a live Yes; completed declarations retain their native later custody rights. See the current-Traitor guide for scope.',
|
||||
'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.',
|
||||
'A bribe-payment question binds cumulative future ordinary spice paid to one named non-allied recipient during this turn. Only the real payer action counts; the recipient still spends its receipt at Mentat. Split payments add together, and earlier obligations retain their funding. See the ordinary-bribe guide for the funded prototype and unfinished preparations.',
|
||||
'An ordinary force-revival question binds cumulative future total, normal or elite returns in native Revival later this turn, from supported phases0–4. Earlier and special returns do not count. Yes retains the original costs and limits; No also applies across smaller requests. See the revival-promise guide for exact timing and unfinished compositions.',
|
||||
'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. The complete grouped builder supports eight children per group, sixteen total conditions including groups and depth four. Add/wrap/remove/collapse retains the whole claim and validates every field. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.',
|
||||
'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.',
|
||||
@@ -3438,6 +3464,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'truthtrance-movement',
|
||||
'truthtrance-bid',
|
||||
'truthtrance-revival',
|
||||
'truthtrance-bribe',
|
||||
'richese-no-field',
|
||||
'card-reinforcements',
|
||||
],
|
||||
|
||||
+26
-1
@@ -1,3 +1,4 @@
|
||||
import { parseBribeClaim, bribeClaimText, bribePromiseModeSupported, type BribeClaim } from './bribe-promises';
|
||||
import { parseRevivalClaim, revivalClaimText, revivalPromiseModeSupported, type RevivalClaim } from './revival-promises';
|
||||
import { parseMovementClaim, movementClaimText, movementPromiseTargetAvailable, MovementClaimError, type MovementClaim } from './movement-promises';
|
||||
import { shipmentAvailable } from './shipment-opportunity';
|
||||
@@ -61,6 +62,7 @@ export type TruthFact =
|
||||
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
|
||||
| { kind: 'and' | 'or'; terms: TruthFact[] };
|
||||
export type TruthQuestion =
|
||||
| ({ kind: 'bribe'; target: string } & BribeClaim)
|
||||
| ({ kind: 'revival'; target: string } & RevivalClaim)
|
||||
| ({ kind: 'bid'; target: string } & BidClaim)
|
||||
| { kind: 'traitorCall'; target: string; battle: string; territory: string }
|
||||
@@ -214,6 +216,17 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
g.players.some((p) => p.id === v.target),
|
||||
'Ask one other player.',
|
||||
);
|
||||
if (v.kind === 'bribe') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,minimum,recipient,target,turn',
|
||||
'Supply the current turn, named bribe recipient, minimum and respondent.');
|
||||
const claim = parseBribeClaim(v), respondent = g.players.find(player => player.id === v.target)!;
|
||||
check(bribePromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase < 8 &&
|
||||
claim.turn === g.turn && claim.recipient !== respondent.id && claim.recipient !== respondent.ally &&
|
||||
g.players.some(player => player.id === claim.recipient) && !g.response && !g.decision && !g.phaseOpening &&
|
||||
!g.pendingTreacheryDiscard && !g.pendingKarama,
|
||||
'Choose a current-turn ordinary bribe to another non-allied participant in a supported table.');
|
||||
return { kind: 'bribe', target: v.target, ...claim };
|
||||
}
|
||||
if (v.kind === 'revival') {
|
||||
check(Object.keys(v).sort().join(',') === 'counter,kind,minimum,target,turn',
|
||||
'Supply the current Revival turn, physical counter type, minimum and respondent.');
|
||||
@@ -459,8 +472,10 @@ export function truthQuestionText(
|
||||
q: TruthQuestion,
|
||||
leaderName: (id: string) => string,
|
||||
locationName: (id: string) => string,
|
||||
playerName: (id: string) => string = id => id,
|
||||
): string {
|
||||
if (q.kind === 'freeform') return q.text;
|
||||
if (q.kind === 'bribe') return `Will you ${bribeClaimText(q, playerName)}?`;
|
||||
if (q.kind === 'revival') return `Will you ${revivalClaimText(q)}?`;
|
||||
if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`;
|
||||
if (q.kind === 'traitorCall') return `In this revealed battle in ${locationName(q.territory)}, will you declare the opposing leader a Traitor?`;
|
||||
@@ -508,6 +523,8 @@ export function resolveTruthAction(
|
||||
) => void;
|
||||
movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[];
|
||||
bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void;
|
||||
bribeAnswers?: (g: Game, p: Player, claim: BribeClaim) => TruthAnswer[];
|
||||
bindBribe?: (g: Game, p: Player, claim: BribeClaim, answer: boolean, asker: string) => void;
|
||||
revivalAnswers?: (g: Game, p: Player, claim: RevivalClaim) => TruthAnswer[];
|
||||
bindRevival?: (g: Game, p: Player, claim: RevivalClaim, answer: boolean, asker: string) => void;
|
||||
bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[];
|
||||
@@ -630,6 +647,9 @@ export function resolveTruthAction(
|
||||
'Waiting for the Truthtrance holder to ask a question.',
|
||||
);
|
||||
w.question = parseQuestion(g, p.id, a.question);
|
||||
if (w.question.kind === 'bribe')
|
||||
check(effects.bribeAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a current bribe question compatible with earlier answers.');
|
||||
if (w.question.kind === 'revival')
|
||||
check(effects.revivalAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a respondent with a remaining native Revival opportunity.');
|
||||
@@ -645,7 +665,7 @@ export function resolveTruthAction(
|
||||
)!;
|
||||
effects.log(
|
||||
g,
|
||||
`${p.name} asks ${target.name}: ${truthQuestionText(w.question, (id) => (id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : battleLeaderIdentities(g).find((leader) => leader.id === id)!.name), id => combatLocationName(g, id))}`,
|
||||
`${p.name} asks ${target.name}: ${truthQuestionText(w.question, (id) => (id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : battleLeaderIdentities(g).find((leader) => leader.id === id)!.name), id => combatLocationName(g, id), id => g.players.find(player => player.id === id)!.name)}`,
|
||||
);
|
||||
} else if (a.type === 'truthSave') {
|
||||
check(
|
||||
@@ -686,6 +706,11 @@ export function resolveTruthAction(
|
||||
'Choose an answer consistent with legal ground movement and earlier promises.');
|
||||
if (a.answer !== 'unknown') effects.bindMovement(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'bribe') {
|
||||
check(effects.bribeAnswers && effects.bindBribe && effects.bribeAnswers(g, p, q).includes(a.answer),
|
||||
'Choose an answer consistent with actual ordinary bribe funding and earlier promises.');
|
||||
if (a.answer !== 'unknown') effects.bindBribe(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'revival') {
|
||||
check(effects.revivalAnswers && effects.bindRevival && effects.revivalAnswers(g, p, q).includes(a.answer),
|
||||
'Choose an answer consistent with native force revivals and earlier answers.');
|
||||
|
||||
Reference in new issue
Block a user