Support whole and mixed final Nexus alliance questions

This commit is contained in:
Quintine committed 2026-10-26 17:26:05 +11:00
1 parent 0d979e4f8b
commit 05330ca7c9
26 files changed
+2408 -73

No files matched your search

+165
View File
@@ -0,0 +1,165 @@
import {
allianceClaimText, alliancePromisesMatch, findAlliancePromisePlan,
type AllianceOpportunity, type AlliancePromise,
} from './alliance-promises';
export const ALLIANCE_EXPRESSION_MAX_LEAVES = 16;
export const ALLIANCE_EXPRESSION_MAX_DEPTH = 4;
export type AllianceExpression =
| { kind: 'partner'; partner: string }
| { kind: 'and' | 'or'; terms: AllianceExpression[] };
export type AllianceExpressionClaim = { turn: number; claim: AllianceExpression };
export type AllianceExpressionPromise = AllianceExpressionClaim & {
event: string;
/** Original private ordinary-Nexus interval; never public question text. */
source: string;
player: string;
asker: string;
answer: boolean;
fulfilled?: boolean;
released?: boolean;
};
export type AllianceExpressionContext = {
players: readonly { id: string; ally: string | null }[];
offers: Readonly<Record<string, string>>;
};
export type AllianceAction = { type: 'alliance'; target: string | null };
export class AllianceExpressionPromiseError extends Error {
constructor(message: string) {
super(message);
this.name = 'AllianceExpressionPromiseError';
}
}
function check(value: unknown, message: string): asserts value {
if (!value) throw new AllianceExpressionPromiseError(message);
}
function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value === value.trim();
const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean';
const keys = (value: Record<string, unknown>, expected: string) => Object.keys(value).sort().join(',') === expected;
/** These input limits match the other full expression families. Parse every
* branch before evaluation, including clauses behind a decisive AND or OR. */
export function parseAllianceExpression(value: unknown): AllianceExpression {
let leaves = 0;
const parse = (input: unknown, depth: number): AllianceExpression => {
check(object(input) && depth <= ALLIANCE_EXPRESSION_MAX_DEPTH,
'Use an alliance expression within four nested groups.');
if (input.kind === 'and' || input.kind === 'or') {
check(keys(input, 'kind,terms') && Array.isArray(input.terms) && input.terms.length >= 2 &&
input.terms.length <= ALLIANCE_EXPRESSION_MAX_LEAVES,
'Join two or more final alliance statements with AND or OR.');
return { kind: input.kind, terms: input.terms.map(term => parse(term, depth + 1)) };
}
check(++leaves <= ALLIANCE_EXPRESSION_MAX_LEAVES && input.kind === 'partner' &&
keys(input, 'kind,partner') && identifier(input.partner),
'Choose at most sixteen exact final alliance partners.');
return { kind: 'partner', partner: input.partner };
};
return parse(value, 0);
}
export function parseAllianceExpressionClaim(value: unknown): AllianceExpressionClaim {
check(object(value) && keys(value, 'claim,turn') && typeof value.turn === 'number' &&
Number.isSafeInteger(value.turn) && value.turn >= 1,
'Choose the current turn and a final alliance expression.');
return { turn: value.turn, claim: parseAllianceExpression(value.claim) };
}
function evaluate(expression: AllianceExpression, ally: string | null): boolean {
if (expression.kind === 'partner') return ally === expression.partner;
return expression.kind === 'and' ? expression.terms.every(term => evaluate(term, ally)) :
expression.terms.some(term => evaluate(term, ally));
}
/** Positive truth at one final native pair, independent of the whole answer. */
export function evaluateAllianceExpression(expression: AllianceExpression, ally: string | null): boolean {
return evaluate(parseAllianceExpression(expression), ally);
}
export function allianceExpressionText(expression: AllianceExpression,
playerName: (id: string) => string = id => id): string {
const text = (node: AllianceExpression): string => node.kind === 'partner' ?
allianceClaimText({ turn: 1, partner: node.partner }, playerName) :
`(${node.terms.map(text).join(node.kind === 'and' ? ' AND ' : ' OR ')})`;
return text(parseAllianceExpression(expression));
}
export function liveAllianceExpressionPromises(promises: readonly AllianceExpressionPromise[], player: string,
opportunity: AllianceOpportunity): AllianceExpressionPromise[] {
return promises.filter(promise => promise.player === player && promise.turn === opportunity.turn &&
promise.source === opportunity.source && !promise.fulfilled && !promise.released);
}
/** A whole No negates the complete expression. No interim membership settles
* an answer; the engine owns the original ordinary Nexus closing event. */
export function allianceExpressionPromisesMatch(promises: readonly AllianceExpressionPromise[], player: string,
opportunity: AllianceOpportunity, ally: string | null): boolean {
return liveAllianceExpressionPromises(promises, player, opportunity)
.map(promise => evaluateAllianceExpression(promise.claim, ally) === promise.answer).every(Boolean);
}
/** Enumerate truth-compatible final identities, not promises of foreign
* consent. Keep current membership first so a legal no-op stays preferred. */
export function allianceExpressionOutcomes(scalars: readonly AlliancePromise[],
expressions: readonly AllianceExpressionPromise[], player: string, opportunity: AllianceOpportunity,
context: AllianceExpressionContext): (string | null)[] {
const owner = context.players.find(seat => seat.id === player);
if (!owner) return [];
const candidates = new Set<string | null>([owner.ally, null,
...context.players.filter(seat => seat.id !== player).map(seat => seat.id)]);
return [...candidates].filter(ally => alliancePromisesMatch(scalars, player, opportunity, ally) &&
allianceExpressionPromisesMatch(expressions, player, opportunity, ally));
}
/** Reuse the native scalar witness for each compatible outcome. Local exact
* outcome clauses are never persisted/projected and cannot invent a foreign
* acceptance, break or invitation. Return every owned route so the engine can
* try its original financial/preparation constraints without pinning an OR. */
export function findAllianceExpressionPlans(scalars: readonly AlliancePromise[],
expressions: readonly AllianceExpressionPromise[], player: string, opportunity: AllianceOpportunity,
context: AllianceExpressionContext): AllianceAction[][] {
if (!liveAllianceExpressionPromises(expressions, player, opportunity).length) {
const plan = findAlliancePromisePlan(scalars, player, opportunity, context);
return plan === null ? [] : [plan];
}
const plans: AllianceAction[][] = [];
const seen = new Set<string>();
for (const ally of allianceExpressionOutcomes(scalars, expressions, player, opportunity, context)) {
const exact: AlliancePromise[] = (ally === null ? context.players.filter(seat => seat.id !== player)
.map(seat => ({ partner: seat.id, answer: false })) : [{ partner: ally, answer: true }])
.map((claim, index) => ({ ...claim, turn: opportunity.turn, source: opportunity.source,
event: `alliance-expression-outcome-${index}`, player,
asker: context.players.find(seat => seat.id !== player)?.id ?? player }));
const plan = findAlliancePromisePlan([...scalars, ...exact], player, opportunity, context);
if (plan === null) continue;
const key = JSON.stringify(plan);
if (!seen.has(key)) { seen.add(key); plans.push(plan); }
}
return plans;
}
export function validateAllianceExpressionPromises(promises: readonly AllianceExpressionPromise[],
playerIds: readonly string[]): void {
check(Array.isArray(promises), 'The alliance expression commitment record is invalid.');
const events = new Set<string>();
for (const promise of promises) {
check(object(promise), 'The alliance expression commitment record is invalid.');
const { claim } = parseAllianceExpressionClaim({ turn: promise.turn, claim: promise.claim });
const validPartners = (node: AllianceExpression): boolean => node.kind === 'partner' ?
playerIds.includes(node.partner) && node.partner !== promise.player : node.terms.every(validPartners);
check(identifier(promise.event) && !events.has(promise.event) && identifier(promise.source) &&
identifier(promise.player) && identifier(promise.asker) && playerIds.includes(promise.player) &&
playerIds.includes(promise.asker) && promise.player !== promise.asker && validPartners(claim) &&
typeof promise.answer === 'boolean' && optionalBoolean(promise.fulfilled) && optionalBoolean(promise.released) &&
!(promise.fulfilled && promise.released),
'The alliance expression lost its Nexus source, event or participants.');
events.add(promise.event);
}
}
+5
View File
@@ -1336,6 +1336,11 @@ function policyActions(g: GameView): Action[] {
const answer = g.truthLeaderRevivalAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'allianceExpression' || w.question.kind === 'mixedAlliance') {
const choices = w.question.kind === 'allianceExpression' ? g.truthAllianceExpressionAnswers : g.truthMixedAllianceAnswers;
const answer = choices?.find(answer => answer !== 'unknown') ?? choices?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'alliance') {
const answer = g.truthAllianceAnswers?.find(answer => answer !== 'unknown') ?? g.truthAllianceAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
+212 -67
View File
@@ -1,3 +1,7 @@
import { AllianceExpressionPromiseError, allianceExpressionOutcomes, findAllianceExpressionPlans,
allianceExpressionPromisesMatch, evaluateAllianceExpression, validateAllianceExpressionPromises,
type AllianceExpression, type AllianceExpressionClaim, type AllianceExpressionPromise } from './alliance-expression-promises';
import type { AllianceActionClaim, MixedAllianceClaim } from './mixed-alliance-question';
import { mixedBidMinimum, type MixedBidClaim } from './mixed-bid-question';
import type { RevivalActionClaim, MixedRevivalClaim } from './mixed-revival-question';
import type { BribePaymentClaim, MixedBribeClaim } from './mixed-bribe-question';
@@ -13,8 +17,8 @@ import { RevivalExpressionPromiseError, liveRevivalExpressionPromises, revivalEx
import { LeaderRevivalPromiseError, liveLeaderRevivalPromises, leaderRevivalRequirements,
leaderRevivalPromisesSatisfied, recordPromisedLeaderRevival, validateLeaderRevivalPromises,
type LeaderRevivalClaim, type LeaderRevivalPromise } from './leader-revival-promises';
import { AlliancePromiseError, alliancePromiseModeSupported, liveAlliancePromises, alliancePromiseRequirements,
alliancePromisesMatch, findAlliancePromisePlan, validateAlliancePromises, type AllianceClaim, type AllianceOpportunity, type AlliancePromise } from './alliance-promises';
import { AlliancePromiseError, alliancePromiseModeSupported,
alliancePromisesMatch, validateAlliancePromises, type AllianceClaim, type AllianceOpportunity, type AlliancePromise } from './alliance-promises';
import { BribePromiseError, bribePromiseModeSupported, liveBribePromises, recordPromisedBribe,
validateBribePromises, type BribeClaim, type BribePromise } from './bribe-promises';
import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income';
@@ -2179,6 +2183,11 @@ export type Game = {
})[];
bribePromiseSequence?: string[];
alliancePromises?: AlliancePromise[];
allianceExpressionPromises?: AllianceExpressionPromise[];
alliancePromiseSequence?: string[];
mixedAllianceQuestions?: (MixedAllianceClaim & {
event: string; player: string; asker: string; answer: boolean; binding: boolean;
})[];
movementPromises?: MovementPromise[];
movementExpressionPromises?: MovementExpressionPromise[];
/** Original mixed questions only; actual obligations remain in the native ledger. */
@@ -6923,6 +6932,10 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
battle: g.battle,
shipmentPromises: g.shipmentPromises,
movementPromises: g.movementPromises,
...(['allianceExpression', 'mixedAlliance'].includes(next.record.question.kind) ? {
allianceExpressionPromises: g.allianceExpressionPromises, alliancePromiseSequence: g.alliancePromiseSequence,
mixedAllianceQuestions: g.mixedAllianceQuestions,
} : {}),
...(next.record.question.kind === 'mixedBid' ? {
mixedBidQuestions: g.mixedBidQuestions, bidPromises: g.bidPromises,
} : {}),
@@ -14952,10 +14965,27 @@ function pledgeAid(g: Game, p: Player, requested: unknown) {
}
function alliancePromiseRule<T>(calculate: () => T): T {
try { return calculate(); }
catch (error) { if (error instanceof AlliancePromiseError) throw new RuleError(error.message); throw error; }
catch (error) { if (error instanceof AlliancePromiseError || error instanceof AllianceExpressionPromiseError) throw new RuleError(error.message); throw error; }
}
function alliancePromiseIntegrity(g: Game) {
alliancePromiseRule(() => validateAlliancePromises(g.alliancePromises ?? [], g.players.map(player => player.id)));
alliancePromiseRule(() => {
const players = g.players.map(player => player.id);
validateAlliancePromises(g.alliancePromises ?? [], players);
validateAllianceExpressionPromises(g.allianceExpressionPromises ?? [], players);
});
const events = [...(g.alliancePromises ?? []), ...(g.allianceExpressionPromises ?? [])].map(promise => promise.event);
requireRule(new Set(events).size === events.length, 'Keep each alliance answer bound to one original event.');
}
type AllianceCommitment = AlliancePromise | AllianceExpressionPromise;
function orderedAllianceCommitments(g: Game): AllianceCommitment[] {
const order = new Map((g.alliancePromiseSequence ?? []).map((event, index) => [event, index]));
return [...(g.alliancePromises ?? []), ...(g.allianceExpressionPromises ?? [])]
.sort((a, b) => (order.get(a.event) ?? -1) - (order.get(b.event) ?? -1));
}
function reopenAllianceTruthReady(g: Game, p: Player) {
if (!g.ready.includes(p.id)) return;
g.ready = g.ready.filter(player => player !== p.id);
log(g, `${p.name} has a new Truthtrance alliance commitment and must confirm Nexus readiness again.`);
}
function allianceOpportunity(g: Game): AllianceOpportunity | null {
if (g.status !== 'playing' || g.phase !== 1 || !g.nexus || !alliancePromiseModeSupported(g)) return null;
@@ -14986,91 +15016,185 @@ function normalNexusAllianceQuote(g: Game, p: Player, target: string | null) {
return nexusRule(() => quoteNexusAlliance({ players: g.players.map(player => ({ id: player.id, ally: player.ally })),
offers: g.allianceOffers, actor: p.id, target }));
}
function alliancePromisePlan(g: Game, p: Player, promises = g.alliancePromises ?? []) {
function alliancePromisePlan(g: Game, p: Player, promises = g.alliancePromises ?? [],
expressions = g.allianceExpressionPromises ?? []) {
const opportunity = allianceOpportunity(g);
if (!opportunity) return null;
const actions = findAlliancePromisePlan(promises, p.id, opportunity, { players: g.players, offers: g.allianceOffers });
if (!actions) return null;
// Keeping an already matching membership spends nothing and changes no
// alliance. An unrelated foreign loss is not a prospective cost of Ready.
if (!actions.length) return { actions, bribePreparation: false };
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
trial.truthtrance = null;
let bribePreparation = false;
try {
// An already owed, currently payable bribe may precede accepting its named
// recipient. Keep that payment on its existing guide and native action.
const accepts = actions.find(action => action.target !== null)?.target;
const future = structuredClone(trial);
if (accepts) getPlayer(future, p.id).ally = accepts;
if (accepts && hasBribePromises(trial, p.id, true) && !bribePromiseCompletion(future, getPlayer(future, p.id))) {
const payment = bribePromiseCompletion(trial, actor)?.find(action => action.target === accepts);
if (!payment) return null;
const amount = payment.amount as number;
actor.spice -= amount;
getPlayer(trial, accepts).bribes += amount;
recordBribePayment(trial, p.id, accepts, amount);
bribePreparation = true;
}
for (const action of actions) {
const quote = normalNexusAllianceQuote(trial, actor, action.target);
for (const player of trial.players) player.ally = quote.allies[player.id];
trial.allianceOffers = quote.offers;
if (quote.formed) for (const id of quote.formed) getPlayer(trial, id).allySinceTurn = g.turn;
trial.ready = [];
}
const voluntary = { actor: p.id, action: { type: 'alliance' } };
reconcileShipmentPromises(trial, voluntary);
reconcileGroundMovementPromises(trial, p.id, voluntary.action, g.phase);
reconcileRevivalPromises(trial, voluntary);
reconcileBribePromises(trial, voluntary);
return { actions, bribePreparation };
} catch (error) { if (error instanceof RuleError) return null; throw error; }
const plans = findAllianceExpressionPlans(promises, expressions, p.id, opportunity,
{ players: g.players, offers: g.allianceOffers });
// Try every consent-supported final outcome. A financial restriction on one
// partner never fixes an OR answer to that route or hides another legal one.
for (const actions of plans) {
if (!actions.length) return { actions, bribePreparation: false };
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
trial.truthtrance = null;
let bribePreparation = false;
try {
const accepts = actions.find(action => action.target !== null)?.target;
const future = structuredClone(trial);
if (accepts) getPlayer(future, p.id).ally = accepts;
if (accepts && hasBribePromises(trial, p.id, true) && !bribePromiseCompletion(future, getPlayer(future, p.id))) {
const payment = bribePromiseCompletion(trial, actor)?.find(action => action.target === accepts);
if (!payment) continue;
const amount = payment.amount as number;
actor.spice -= amount;
getPlayer(trial, accepts).bribes += amount;
recordBribePayment(trial, p.id, accepts, amount);
bribePreparation = true;
}
for (const action of actions) {
const quote = normalNexusAllianceQuote(trial, actor, action.target);
for (const player of trial.players) player.ally = quote.allies[player.id];
trial.allianceOffers = quote.offers;
if (quote.formed) for (const id of quote.formed) getPlayer(trial, id).allySinceTurn = g.turn;
trial.ready = [];
}
const voluntary = { actor: p.id, action: { type: 'alliance' } };
reconcileShipmentPromises(trial, voluntary);
reconcileGroundMovementPromises(trial, p.id, voluntary.action, g.phase);
reconcileRevivalPromises(trial, voluntary);
reconcileBribePromises(trial, voluntary);
return { actions, bribePreparation };
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
return null;
}
function allianceUnknownConsent(g: Game, p: Player, promises = g.alliancePromises ?? [],
expressions = g.allianceExpressionPromises ?? []) {
const opportunity = allianceOpportunity(g);
return !!opportunity && allianceExpressionOutcomes(promises, expressions, p.id, opportunity,
{ players: g.players, offers: g.allianceOffers }).some(partner => partner !== null && p.ally !== partner &&
(getPlayer(g, partner).ally !== null || g.allianceOffers[partner] !== p.id));
}
function allianceTruthAnswers(g: Game, p: Player, claim: AllianceClaim): ('yes' | 'no' | 'unknown')[] {
const opportunity = allianceOpportunity(g);
if (!opportunity || !allianceQuestionWindow(g) || claim.turn !== g.turn || claim.partner === p.id ||
!g.players.some(player => player.id === claim.partner)) return [];
const prior = alliancePromiseRequirements(g.alliancePromises ?? [], p.id, opportunity);
const candidate = (answer: boolean): AlliancePromise => ({ ...claim, event: 'candidate', source: opportunity.source,
player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host, answer });
const answers: ('yes' | 'no' | 'unknown')[] = (['yes', 'no'] as const).filter(answer =>
!!alliancePromisePlan(g, p, [...(g.alliancePromises ?? []), { ...claim, event: 'candidate', source: opportunity.source,
player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes' }]));
const foreignConsent = p.ally !== claim.partner &&
(getPlayer(g, claim.partner).ally !== null || g.allianceOffers[claim.partner] !== p.id);
if (!answers.includes('yes') && foreignConsent && prior?.partner === null &&
!prior.forbidden.includes(claim.partner)) answers.push('unknown');
!!alliancePromisePlan(g, p, [...(g.alliancePromises ?? []), candidate(answer === 'yes')]));
if (!answers.includes('yes') && allianceUnknownConsent(g, p, [...(g.alliancePromises ?? []), candidate(true)]))
answers.push('unknown');
return answers;
}
function bindAllianceTruth(g: Game, p: Player, claim: AllianceClaim, answer: boolean, asker: string) {
const opportunity = allianceOpportunity(g);
requireRule(opportunity && allianceTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'Choose an alliance answer compatible with actual consent and earlier answers.');
(g.alliancePromises ??= []).push({ ...claim, event: crypto.randomUUID(), source: opportunity.source,
player: p.id, asker, answer });
if (g.ready.includes(p.id)) {
g.ready = g.ready.filter(player => player !== p.id);
log(g, `${p.name} has a new Truthtrance alliance commitment and must confirm Nexus readiness again.`);
const promise = { ...claim, event: crypto.randomUUID(), source: opportunity.source, player: p.id, asker, answer };
(g.alliancePromises ??= []).push(promise);
(g.alliancePromiseSequence ??= []).push(promise.event);
reopenAllianceTruthReady(g, p);
}
function allianceExpressionIdentities(g: Game, p: Player, claim: AllianceExpression): boolean {
return claim.kind === 'partner' ? claim.partner !== p.id && g.players.some(player => player.id === claim.partner)
: claim.terms.every(term => allianceExpressionIdentities(g, p, term));
}
function allianceExpressionTruthAnswers(g: Game, p: Player, claim: AllianceExpressionClaim): TruthAnswer[] {
const opportunity = allianceOpportunity(g);
if (!opportunity || !allianceQuestionWindow(g) || claim.turn !== g.turn ||
!allianceExpressionIdentities(g, p, claim.claim)) return [];
const candidate = (answer: boolean): AllianceExpressionPromise => ({ ...claim, event: 'candidate', source: opportunity.source,
player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host, answer });
const answers: TruthAnswer[] = (['yes', 'no'] as const).filter(answer => !!alliancePromisePlan(g, p,
g.alliancePromises ?? [], [...(g.allianceExpressionPromises ?? []), candidate(answer === 'yes')]));
if (!answers.includes('yes') && allianceUnknownConsent(g, p, g.alliancePromises ?? [],
[...(g.allianceExpressionPromises ?? []), candidate(true)])) answers.push('unknown');
return answers;
}
function bindAllianceExpressionTruth(g: Game, p: Player, claim: AllianceExpressionClaim, answer: boolean, asker: string) {
const opportunity = allianceOpportunity(g);
requireRule(opportunity && allianceExpressionTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'Choose a whole alliance answer compatible with original consent and earlier commitments.');
const promise: AllianceExpressionPromise = { ...claim, event: crypto.randomUUID(), source: opportunity.source,
player: p.id, asker, answer };
(g.allianceExpressionPromises ??= []).push(promise);
(g.alliancePromiseSequence ??= []).push(promise.event);
reopenAllianceTruthReady(g, p);
}
function mixedAllianceIdentities(g: Game, p: Player, mixed: MixedAllianceClaim['mixed']): boolean {
if (mixed.kind === 'fact') return true;
if (mixed.kind === 'action') return mixed.action.partner !== p.id && g.players.some(player => player.id === mixed.action.partner);
return mixed.terms.every(term => mixedAllianceIdentities(g, p, term));
}
function compileMixedAlliance(g: Game, p: Player, claim: MixedAllianceClaim, answer: boolean) {
const compiled = compileMixedActionExpression(claim.mixed,
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
if (compiled.kind === 'constant') return { constant: compiled.value };
const partners = (node: CompiledMixedAction<AllianceActionClaim>): AllianceExpression => {
if (node.kind === 'action') return { kind: 'partner', partner: node.action.partner };
requireRule(node.kind !== 'constant', 'Retain the privately compiled whole mixed alliance condition.');
return { kind: node.kind, terms: node.terms.map(partners) };
};
return { residual: { turn: claim.turn, claim: partners(compiled) } };
}
function mixedAllianceTruthAnswers(g: Game, p: Player, claim: MixedAllianceClaim): TruthAnswer[] {
if (!allianceOpportunity(g) || !allianceQuestionWindow(g) || claim.turn !== g.turn ||
!mixedAllianceIdentities(g, p, claim.mixed)) return [];
const answers: TruthAnswer[] = [];
let unknown = false;
for (const answer of [true, false]) {
const compiled = compileMixedAlliance(g, p, claim, answer);
if (compiled.constant !== undefined) {
if (compiled.constant === answer) answers.push(answer ? 'yes' : 'no');
} else {
const choices = allianceExpressionTruthAnswers(g, p, compiled.residual!);
if (choices.includes(answer ? 'yes' : 'no')) answers.push(answer ? 'yes' : 'no');
if (answer && choices.includes('unknown')) unknown = true;
}
}
if (unknown || !answers.length) answers.push('unknown');
return answers;
}
function bindMixedAllianceTruth(g: Game, p: Player, claim: MixedAllianceClaim, answer: boolean, asker: string) {
requireRule(mixedAllianceTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'Choose a mixed alliance answer compatible with the complete fact and final-partner expression.');
const compiled = compileMixedAlliance(g, p, claim, answer);
let event: string;
if (compiled.residual) {
bindAllianceExpressionTruth(g, p, compiled.residual, answer, asker);
event = g.allianceExpressionPromises!.at(-1)!.event;
} else {
requireRule(compiled.constant === answer, 'Retain the truthful whole mixed-alliance answer.');
event = crypto.randomUUID();
}
(g.mixedAllianceQuestions ??= []).push({ turn: claim.turn, mixed: structuredClone(claim.mixed),
event, player: p.id, asker, answer, binding: !!compiled.residual });
reopenAllianceTruthReady(g, p);
}
function isMixedAllianceEvent(g: Game, event: string): boolean {
return !!g.mixedAllianceQuestions?.some(question => question.event === event);
}
function projectedMixedAlliancePromises(g: Game, player: string) {
return (g.mixedAllianceQuestions ?? []).filter(question => question.player === player).map(question => {
const bound = g.allianceExpressionPromises?.find(promise => promise.event === question.event);
return { event: question.event, player: question.player, asker: question.asker, turn: question.turn,
mixed: structuredClone(question.mixed), answer: question.answer,
fulfilled: !question.binding || bound?.fulfilled === true, released: bound?.released === true };
});
}
function reconcileAlliancePromises(g: Game, voluntary?: { actor: string; action: Action }) {
if (!g.alliancePromises?.some(promise => !promise.fulfilled && !promise.released)) return;
const ordered = orderedAllianceCommitments(g);
if (!ordered.some(promise => !promise.fulfilled && !promise.released)) return;
alliancePromiseIntegrity(g);
if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.spiceWindow || g.spiceResolution ||
g.pendingTreacheryDiscard || g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal) return;
const opportunity = allianceOpportunity(g), kept: AlliancePromise[] = [];
for (const promise of g.alliancePromises) {
const opportunity = allianceOpportunity(g), kept: AlliancePromise[] = [], expressions: AllianceExpressionPromise[] = [];
for (const promise of ordered) {
if (promise.fulfilled || promise.released) continue;
const same = opportunity && promise.turn === opportunity.turn && promise.source === opportunity.source;
const player = getPlayer(g, promise.player), matches = (player.ally === promise.partner) === promise.answer;
const possible = same && alliancePromisePlan(g, player, [...kept, promise]);
const player = getPlayer(g, promise.player), whole = 'claim' in promise;
const matches = (whole ? evaluateAllianceExpression(promise.claim, player.ally) : player.ally === promise.partner) === promise.answer;
const scalars = whole ? kept : [...kept, promise], groups = whole ? [...expressions, promise] : expressions;
const possible = same && alliancePromisePlan(g, player, scalars, groups);
if (!same && matches) { promise.fulfilled = true; continue; }
if (!possible) {
requireRule(voluntary?.actor !== promise.player,
'You cannot voluntarily evade your Truthtrance alliance answer.');
requireRule(voluntary?.actor !== promise.player, 'You cannot voluntarily evade your Truthtrance alliance answer.');
promise.released = true;
log(g, `${player.name} can no longer fulfill the Truthtrance alliance answer; that answer is no longer binding.`);
} else {
kept.push(promise);
if (whole) expressions.push(promise); else kept.push(promise);
if (!matches && g.ready.includes(player.id)) g.ready = g.ready.filter(id => id !== player.id);
}
}
@@ -15078,17 +15202,25 @@ function reconcileAlliancePromises(g: Game, voluntary?: { actor: string; action:
function guardAlliancePromiseReady(g: Game, p: Player) {
const opportunity = allianceOpportunity(g);
if (!opportunity) return;
requireRule(alliancePromisesMatch(g.alliancePromises ?? [], p.id, opportunity, p.ally),
'Match your Truthtrance alliance answers before finishing this Nexus.');
requireRule(alliancePromisesMatch(g.alliancePromises ?? [], p.id, opportunity, p.ally) &&
allianceExpressionPromisesMatch(g.allianceExpressionPromises ?? [], p.id, opportunity, p.ally),
'Match your Truthtrance alliance answers before finishing this Nexus.');
}
function settleAlliancePromises(g: Game) {
const opportunity = allianceOpportunity(g);
if (!opportunity) return;
for (const player of g.players) guardAlliancePromiseReady(g, player);
for (const promise of g.alliancePromises ?? [])
for (const promise of orderedAllianceCommitments(g))
if (promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.fulfilled && !promise.released)
promise.fulfilled = true;
}
function projectedAllianceExpressionPromises(g: Game, player: string) {
return (g.allianceExpressionPromises ?? []).filter(promise => promise.player === player && !isMixedAllianceEvent(g, promise.event))
.map(promise => ({ event: promise.event, player: promise.player, asker: promise.asker, turn: promise.turn,
claim: structuredClone(promise.claim), answer: promise.answer,
...(promise.fulfilled === undefined ? {} : { fulfilled: promise.fulfilled }),
...(promise.released === undefined ? {} : { released: promise.released }) }));
}
function projectedAlliancePromises(g: Game, player: string) {
return (g.alliancePromises ?? []).filter(promise => promise.player === player).map(promise => ({
event: promise.event, player: promise.player, asker: promise.asker, turn: promise.turn,
@@ -15100,7 +15232,8 @@ function projectedAlliancePromises(g: Game, player: string) {
function projectedAlliancePromiseGuide(g: Game, player: string): { action: Action } | null {
const opportunity = allianceOpportunity(g);
if (!opportunity || !allianceQuestionWindow(g) || g.truthtrance || g.ready.includes(player)) return null;
const promise = liveAlliancePromises(g.alliancePromises ?? [], player, opportunity)[0];
const promise = orderedAllianceCommitments(g).find(record => record.player === player && record.turn === opportunity.turn &&
record.source === opportunity.source && !record.fulfilled && !record.released);
if (!promise) return null;
const plan = alliancePromisePlan(g, getPlayer(g, player));
if (!plan || plan.bribePreparation) return null;
@@ -36619,6 +36752,10 @@ function applyActionInner(
bindMovementExpression: bindMovementExpressionTruth,
mixedMovementAnswers: mixedMovementTruthAnswers,
bindMixedMovement: bindMixedMovementTruth,
allianceExpressionAnswers: allianceExpressionTruthAnswers,
bindAllianceExpression: bindAllianceExpressionTruth,
mixedAllianceAnswers: mixedAllianceTruthAnswers,
bindMixedAlliance: bindMixedAllianceTruth,
allianceAnswers: allianceTruthAnswers,
bindAlliance: bindAllianceTruth,
bribeAnswers: bribeTruthAnswers,
@@ -36648,7 +36785,7 @@ function applyActionInner(
)
return g;
} catch (error) {
if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof MovementExpressionPromiseError || error instanceof MixedActionQuestionError || error instanceof TraitorDecisionError)
if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof AllianceExpressionPromiseError || error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof MovementExpressionPromiseError || error instanceof MixedActionQuestionError || error instanceof TraitorDecisionError)
throw new RuleError(error.message);
throw error;
}
@@ -40485,6 +40622,14 @@ export function viewGame(state: Game, id: string) {
truthAllianceTargets: allianceQuestionWindow(g) ? g.players.map(player => player.id) : [],
truthAllianceAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'alliance' ? allianceTruthAnswers(g, me, g.truthtrance.question) : null,
truthAllianceExpressionTargets: allianceQuestionWindow(g) ? g.players.map(player => player.id) : [],
truthAllianceExpressionAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'allianceExpression' ? allianceExpressionTruthAnswers(g, me, g.truthtrance.question) : null,
allianceExpressionPromises: projectedAllianceExpressionPromises(g, id),
truthMixedAllianceTargets: allianceQuestionWindow(g) ? g.players.map(player => player.id) : [],
truthMixedAllianceAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'mixedAlliance' ? mixedAllianceTruthAnswers(g, me, g.truthtrance.question) : null,
mixedAlliancePromises: projectedMixedAlliancePromises(g, id),
alliancePromises: projectedAlliancePromises(g, id),
alliancePromiseGuide: projectedAlliancePromiseGuide(g, id),
truthBribeTargets: bribeQuestionWindow(g) ? g.players.map(player => player.id) : [],
+14
View File
@@ -0,0 +1,14 @@
import { AlliancePromiseError, parseAllianceClaim } from './alliance-promises';
import type { MixedActionExpression } from './mixed-action-question';
export type AllianceActionClaim = { partner: string };
export type MixedAllianceClaim = { turn: number; mixed: MixedActionExpression<AllianceActionClaim> };
/** One exact final partner only. Turn/source/compiled metadata belong to the
* outer current-Nexus question or its private answer-time binding. */
export function parseAllianceActionClaim(value: unknown): AllianceActionClaim {
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).join(',') !== 'partner')
throw new AlliancePromiseError('Supply one exact final alliance partner without turn or Nexus metadata.');
const { partner } = parseAllianceClaim({ turn: 1, partner: (value as Record<string, unknown>).partner });
return { partner };
}
+50
View File
@@ -3208,6 +3208,56 @@ 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-alliance-expression",
title: "Truthtrance: combined final alliance partners",
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: "Combine final partner conditions with AND/OR, retaining every owned consent route and whole No until actual Nexus close.",
steps: [
"Choose Combine final alliance partners with AND/OR. Build full groups using current participants other than the respondent. Every condition concerns actual membership when this ordinary Nexus closes.",
"OR keeps every compatible consent-supported partner available. AND requires every child; in the native pair system two different final partners cannot both be true. No negates the complete expression, rather than demanding each child separately be false.",
"A proposal alone is not membership. Yes stays binding after formation; No can permit temporary membership if it ends before the close. Both settle only at the actual Nexus close.",
"A known owned completion uses an existing partnership or a real incoming invitation. No future consent or withdrawal is invented. Missing consent can leave No or I don’t know available; the holder may save the card or ask again after a real offer.",
"Every compatible native consent plan is checked against earlier financial and other commitments. An OR answer selects no fixed route; another manual legal partner remains available. Existing bribes use their original payment controls before accepting a recipient when required.",
"Other participants keep their original withdrawal and departure rights. One surviving OR route preserves Yes; removing the last owned route can release an impossible answer in accepted order. A later reoffer never reactivates a released record.",
"Full editing, raw preview/Ask, private answers, own aggregate status and one exact shared acceptance/break/Ready guide connect to four minimal policies. A definite answer reopens only respondent readiness. Earlier-phase diplomacy, optional combinations, broader action families and final deployed acceptance remain unfinished.",
],
related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-mixed-alliance', 'truthtrance-bribe', 'implementation-checklist'],
checklist: [
{ area: "Implementation", status: "Partial", detail: "Finite final outcomes reuse original canonical consent plans, native financial preparation and one scalar/whole/mixed accepted sequence. Raw projection omits private source and mixed residuals.", evidence: ["game/alliance-expression-promises.ts", "game/mixed-alliance-question.ts", "game/engine.ts", "game/truthtrance.ts"] },
{ area: "Player controls", status: "Partial", detail: "Full partner/mixed editors, raw preview/Ask, private answers and one exact live native guide retain public context and original consent.", evidence: ["components/alliance-expression-fields.tsx", "components/mixed-alliance-fields.tsx", "components/alliance-expression-promises.tsx", "components/mixed-alliance-promises.tsx", "components/alliance-promises.tsx", "components/truthtrance.tsx", "components/game-table.tsx"] },
{ area: "AI", status: "Partial", detail: "Four minimal profiles answer and perform canonical native acceptance and Ready. Strategy/calibration remain deferred.", evidence: ["game/bots.ts", "tests/truth-alliance-expression-runtime.test.ts", "tests/truth-mixed-alliance-runtime.test.ts"] },
{ area: "Documentation", status: "Implemented", detail: "Current-Nexus interval, whole No, snapshot/unknown, owned consent, native financial preparation and remaining gates are explicit.", evidence: ["docs/TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md", "docs/RULE_DECISIONS.md"] },
{ area: "Verification", status: "Partial", detail: "Focused pure, original native consent/close/funding and controlled editing/guide cases. Wider compositions and final deployed acceptance remain open.", evidence: ["tests/alliance-expression-promises.test.ts", "tests/mixed-alliance-question.test.ts", "tests/fixture-truth-alliance-expression.ts", "tests/truth-alliance-expression-runtime.test.ts", "tests/truth-mixed-alliance-runtime.test.ts", "tests/truth-alliance-expression-funding.test.ts", "tests/truth-alliance-expression-controls.test.tsx", "tests/truth-mixed-alliance-controls.test.tsx"] },
],
},
{
id: "truthtrance-mixed-alliance",
title: "Truthtrance: current facts and final alliance partners",
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: "Combine answer-time facts with final Nexus partner conditions while preserving real consent, alternatives and original commitments.",
steps: [
"Choose Combine current facts and final alliance partners. Build full AND/OR groups with at least one current fact and one partner condition. Nested facts share the sixteen-condition, four-level input limit.",
"Current facts freeze at the actual answer. A fact that you hold at least10 spice stays that answer-time fact after a real payment leaves7. Only the aggregate answer is shared; frozen clause answers and reduced branches remain private.",
"A fact may settle the whole answer immediately, requiring no later alliance. Otherwise the remaining final-partner conditions use the same native system as whole alliance questions. Unknown facts cannot be relied on being true for Yes or false for No.",
"A known owned completion uses an existing partnership or a real incoming invitation. No future consent or withdrawal is invented. Missing consent can leave No or I don’t know available; the holder may save the card or ask again after a real offer.",
"Every compatible native consent plan is checked against earlier financial and other commitments. An OR answer selects no fixed route; another manual legal partner remains available. Existing bribes use their original payment controls before accepting a recipient when required.",
"Other participants keep their original withdrawal and departure rights. One surviving OR route preserves Yes; removing the last owned route can release an impossible answer in accepted order. A later reoffer never reactivates a released record.",
"Full editing, raw preview/Ask, private answers, own aggregate status and one exact shared acceptance/break/Ready guide connect to four minimal policies. A definite answer reopens only respondent readiness. Earlier-phase diplomacy, optional combinations, broader action families and final deployed acceptance remain unfinished.",
],
related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-alliance-expression', 'truthtrance-bribe', 'implementation-checklist'],
checklist: [
{ area: "Implementation", status: "Partial", detail: "Finite final outcomes reuse original canonical consent plans, native financial preparation and one scalar/whole/mixed accepted sequence. Raw projection omits private source and mixed residuals.", evidence: ["game/alliance-expression-promises.ts", "game/mixed-alliance-question.ts", "game/engine.ts", "game/truthtrance.ts"] },
{ area: "Player controls", status: "Partial", detail: "Full partner/mixed editors, raw preview/Ask, private answers and one exact live native guide retain public context and original consent.", evidence: ["components/alliance-expression-fields.tsx", "components/mixed-alliance-fields.tsx", "components/alliance-expression-promises.tsx", "components/mixed-alliance-promises.tsx", "components/alliance-promises.tsx", "components/truthtrance.tsx", "components/game-table.tsx"] },
{ area: "AI", status: "Partial", detail: "Four minimal profiles answer and perform canonical native acceptance and Ready. Strategy/calibration remain deferred.", evidence: ["game/bots.ts", "tests/truth-alliance-expression-runtime.test.ts", "tests/truth-mixed-alliance-runtime.test.ts"] },
{ area: "Documentation", status: "Implemented", detail: "Current-Nexus interval, whole No, snapshot/unknown, owned consent, native financial preparation and remaining gates are explicit.", evidence: ["docs/TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md", "docs/RULE_DECISIONS.md"] },
{ area: "Verification", status: "Partial", detail: "Focused pure, original native consent/close/funding and controlled editing/guide cases. Wider compositions and final deployed acceptance remain open.", evidence: ["tests/alliance-expression-promises.test.ts", "tests/mixed-alliance-question.test.ts", "tests/fixture-truth-alliance-expression.ts", "tests/truth-alliance-expression-runtime.test.ts", "tests/truth-mixed-alliance-runtime.test.ts", "tests/truth-alliance-expression-funding.test.ts", "tests/truth-alliance-expression-controls.test.tsx", "tests/truth-mixed-alliance-controls.test.tsx"] },
],
},
{
id: 'truthtrance-alliance',
title: 'Truthtrance: alliance membership at this Nexus close',
+40
View File
@@ -1,3 +1,5 @@
import { parseAllianceExpressionClaim, allianceExpressionText, type AllianceExpressionClaim } from './alliance-expression-promises';
import { parseAllianceActionClaim, type MixedAllianceClaim } from './mixed-alliance-question';
import { parseBidActionClaim, MIXED_BID_MAX_DEPTH, MIXED_BID_MAX_LEAVES, type MixedBidClaim } from './mixed-bid-question';
import { parseRevivalActionClaim, type MixedRevivalClaim } from './mixed-revival-question';
import { parseBribePaymentClaim, type MixedBribeClaim } from './mixed-bribe-question';
@@ -73,6 +75,8 @@ export type TruthFact =
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression<MovementClaim> };
export type TruthQuestion =
| ({ kind: 'allianceExpression'; target: string } & AllianceExpressionClaim)
| ({ kind: 'mixedAlliance'; target: string } & MixedAllianceClaim)
| ({ kind: 'mixedBid'; target: string } & MixedBidClaim)
| ({ kind: 'mixedRevival'; target: string } & MixedRevivalClaim)
| ({ kind: 'mixedBribe'; target: string } & MixedBribeClaim)
@@ -264,6 +268,19 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
'Choose a supported current-turn ordinary leader-revival question in a quiet window.');
return { kind: 'leaderRevival', target: v.target, ...claim };
}
if (v.kind === 'allianceExpression' || v.kind === 'mixedAlliance') {
check(Object.keys(v).sort().join(',') === (v.kind === 'allianceExpression' ? 'claim,kind,target,turn' : 'kind,mixed,target,turn'),
'Supply the current Nexus turn, complete alliance expression and respondent.');
check(alliancePromiseModeSupported(g) && g.status === 'playing' && g.phase === 1 && g.nexus &&
!g.spiceWindow && !g.spiceResolution && !g.response && !g.decision && !g.phaseOpening &&
!g.pendingTreacheryDiscard && !g.pendingKarama && v.turn === g.turn,
'Choose the current ordinary Nexus in a quiet supported window.');
if (v.kind === 'allianceExpression') return { kind: 'allianceExpression', target: v.target as string,
...parseAllianceExpressionClaim({ turn: v.turn, claim: v.claim }) };
const respondent = g.players.find(player => player.id === v.target)!;
return { kind: 'mixedAlliance', target: v.target as string, turn: g.turn,
mixed: parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), parseAllianceActionClaim) };
}
if (v.kind === 'alliance') {
check(Object.keys(v).sort().join(',') === 'kind,partner,target,turn',
'Supply the current Nexus turn, named alliance partner and respondent.');
@@ -595,6 +612,7 @@ export function truthQuestionText(
if (q.kind === 'freeform') return q.text;
if (q.kind === 'revivalExpression') return `Will you ${revivalExpressionText(q.claim, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))} from now until your Revival opportunity ends this turn?`;
if (q.kind === 'leaderRevival') return `Will you ${leaderRevivalClaimText(q, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))}?`;
if (q.kind === 'allianceExpression') return `Is it true that you ${allianceExpressionText(q.claim, playerName)}?`;
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)}?`;
@@ -620,6 +638,8 @@ export function truthQuestionText(
return `you currently hold ${f.compare === 'eq' ? 'exactly' : f.compare === 'gte' ? 'at least' : 'at most'} ${f.value} spice`;
return `(${f.terms.map(clause).join(f.kind === 'and' ? ' AND ' : ' OR ')})`;
};
if (q.kind === 'mixedAlliance') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => `you ${allianceClaimText({ turn: q.turn, partner: action.partner }, playerName)}`)}?`;
if (q.kind === 'mixedBid') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => `you ${bidClaimText({ turn: q.turn, lot: q.lot, minimum: action.minimum })}`)}?`;
if (q.kind === 'mixedRevival') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
@@ -658,6 +678,10 @@ export function resolveTruthAction(
bindMovementExpression?: (g: Game, p: Player, claim: MovementExpressionClaim, answer: boolean, asker: string) => void;
movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[];
bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void;
allianceExpressionAnswers?: (g: Game, p: Player, claim: AllianceExpressionClaim) => TruthAnswer[];
bindAllianceExpression?: (g: Game, p: Player, claim: AllianceExpressionClaim, answer: boolean, asker: string) => void;
mixedAllianceAnswers?: (g: Game, p: Player, claim: MixedAllianceClaim) => TruthAnswer[];
bindMixedAlliance?: (g: Game, p: Player, claim: MixedAllianceClaim, answer: boolean, asker: string) => void;
allianceAnswers?: (g: Game, p: Player, claim: AllianceClaim) => TruthAnswer[];
bindAlliance?: (g: Game, p: Player, claim: AllianceClaim, answer: boolean, asker: string) => void;
mixedBribeAnswers?: (g: Game, p: Player, claim: MixedBribeClaim) => TruthAnswer[];
@@ -805,6 +829,12 @@ export function resolveTruthAction(
if (w.question.kind === 'leaderRevival')
check(effects.leaderRevivalAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a named leader question with a compatible native completion.');
if (w.question.kind === 'allianceExpression')
check(effects.allianceExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a whole final-alliance question compatible with the current Nexus.');
if (w.question.kind === 'mixedAlliance')
check(effects.mixedAllianceAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a mixed fact/final-alliance question compatible with the current Nexus.');
if (w.question.kind === 'alliance')
check(effects.allianceAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a current Nexus question with a compatible answer.');
@@ -908,6 +938,16 @@ export function resolveTruthAction(
'Choose a named leader-revival answer compatible with native eligibility and earlier promises.');
if (a.answer !== 'unknown') effects.bindLeaderRevival(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'allianceExpression') {
check(effects.allianceExpressionAnswers && effects.bindAllianceExpression && effects.allianceExpressionAnswers(g, p, q).includes(a.answer),
'Choose a whole alliance answer compatible with original consent and earlier answers.');
if (a.answer !== 'unknown') effects.bindAllianceExpression(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'mixedAlliance') {
check(effects.mixedAllianceAnswers && effects.bindMixedAlliance && effects.mixedAllianceAnswers(g, p, q).includes(a.answer),
'Choose a mixed alliance answer compatible with the whole question and original consent.');
if (a.answer !== 'unknown') effects.bindMixedAlliance(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'alliance') {
check(effects.allianceAnswers && effects.bindAlliance && effects.allianceAnswers(g, p, q).includes(a.answer),
'Choose an answer consistent with actual alliance consent and earlier commitments.');