Support whole and mixed final Nexus alliance questions
This commit is contained in:
1 parent
0d979e4f8b
commit
05330ca7c9
26 files changed
+2408
-73
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) : [],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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.');
|
||||
|
||||
Reference in new issue
Block a user