Bind Truthtrance alliance promises to actual Nexus closure
This commit is contained in:
1 parent
335b4ce638
commit
5627512d16
19 files changed
+1485
-30
No files matched your search
@@ -0,0 +1,134 @@
|
||||
import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope';
|
||||
import { NexusAllianceError, quoteNexusAlliance } from './nexus-alliance';
|
||||
|
||||
export type AllianceClaim = { turn: number; partner: string };
|
||||
export type AllianceOpportunity = { turn: number; source: string };
|
||||
export type AlliancePromise = AllianceClaim & {
|
||||
event: string;
|
||||
/** Internal binding to the original Nexus interval, not public question text. */
|
||||
source: string;
|
||||
player: string;
|
||||
asker: string;
|
||||
answer: boolean;
|
||||
fulfilled?: boolean;
|
||||
released?: boolean;
|
||||
};
|
||||
type AllianceContext = {
|
||||
players: readonly { id: string; ally: string | null }[];
|
||||
offers: Readonly<Record<string, string>>;
|
||||
};
|
||||
type AllianceAction = { type: 'alliance'; target: string | null };
|
||||
|
||||
export class AlliancePromiseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'AlliancePromiseError';
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
export function parseAllianceClaim(value: unknown): AllianceClaim {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new AlliancePromiseError('Choose a final-alliance question for the current Nexus.');
|
||||
const input = value as Record<string, unknown>;
|
||||
if (typeof input.turn !== 'number' || !Number.isSafeInteger(input.turn) || input.turn < 1 || !identifier(input.partner))
|
||||
throw new AlliancePromiseError('Choose the current turn and exact alliance partner.');
|
||||
return { turn: input.turn, partner: input.partner };
|
||||
}
|
||||
|
||||
/** This is the original bare native context. An ordinary worm Nexus does not
|
||||
* require selecting the optional Nexus Cards module or change a start gate. */
|
||||
export function alliancePromiseModeSupported(game: NativePromiseScope & {
|
||||
semutaPreview?: unknown; kullPreview?: unknown; nexusKullPreview?: unknown;
|
||||
richeseBetrayalPreview?: unknown;
|
||||
}): boolean {
|
||||
return nativePromiseScopeSupported(game) && !game.semutaPreview && !game.kullPreview &&
|
||||
!game.nexusKullPreview && !game.richeseBetrayalPreview;
|
||||
}
|
||||
|
||||
export function allianceClaimText(claim: AllianceClaim, partnerName: (id: string) => string = id => id): string {
|
||||
return `be allied with ${partnerName(claim.partner)} when this Nexus ends`;
|
||||
}
|
||||
|
||||
export function liveAlliancePromises(promises: readonly AlliancePromise[], player: string,
|
||||
opportunity: AllianceOpportunity): AlliancePromise[] {
|
||||
return promises.filter(promise => promise.player === player && promise.turn === opportunity.turn &&
|
||||
promise.source === opportunity.source && !promise.fulfilled && !promise.released);
|
||||
}
|
||||
|
||||
export function alliancePromiseRequirements(promises: readonly AlliancePromise[], player: string,
|
||||
opportunity: AllianceOpportunity): { partner: string | null; forbidden: string[] } | null {
|
||||
let partner: string | null = null;
|
||||
const forbidden = new Set<string>();
|
||||
for (const promise of liveAlliancePromises(promises, player, opportunity)) {
|
||||
if (promise.answer) {
|
||||
if (partner !== null && partner !== promise.partner) return null;
|
||||
partner = promise.partner;
|
||||
} else forbidden.add(promise.partner);
|
||||
}
|
||||
return partner !== null && forbidden.has(partner) ? null : { partner, forbidden: [...forbidden] };
|
||||
}
|
||||
|
||||
/** This describes final membership only. Temporary proposals or membership do
|
||||
* not settle a No; the engine owns the actual Nexus closing event. */
|
||||
export function alliancePromisesMatch(promises: readonly AlliancePromise[], player: string,
|
||||
opportunity: AllianceOpportunity, ally: string | null): boolean {
|
||||
return liveAlliancePromises(promises, player, opportunity)
|
||||
.every(promise => (ally === promise.partner) === promise.answer);
|
||||
}
|
||||
|
||||
/** Provisional owned-action witness. Never propose on another player's behalf
|
||||
* or assume a later acceptance, departure or renewed offer. */
|
||||
export function findAlliancePromisePlan(promises: readonly AlliancePromise[], player: string,
|
||||
opportunity: AllianceOpportunity, context: AllianceContext): AllianceAction[] | null {
|
||||
const requirements = alliancePromiseRequirements(promises, player, opportunity);
|
||||
if (!requirements) return null;
|
||||
let players = context.players.map(seat => ({ ...seat }));
|
||||
let offers = { ...context.offers };
|
||||
const owner = players.find(seat => seat.id === player);
|
||||
if (!owner) return null;
|
||||
if (alliancePromisesMatch(promises, player, opportunity, owner.ally)) return [];
|
||||
const actions: AllianceAction[] = [];
|
||||
const quote = (target: string | null) => {
|
||||
const result = quoteNexusAlliance({ players, offers, actor: player, target });
|
||||
players = players.map(seat => ({ ...seat, ally: result.allies[seat.id] }));
|
||||
offers = result.offers;
|
||||
actions.push({ type: 'alliance', target });
|
||||
};
|
||||
try {
|
||||
if (requirements.partner === null) {
|
||||
if (owner.ally !== null && requirements.forbidden.includes(owner.ally)) quote(null);
|
||||
} else {
|
||||
const desired = players.find(seat => seat.id === requirements.partner);
|
||||
if (!desired || desired.ally !== null || offers[desired.id] !== player) return null;
|
||||
if (owner.ally !== null) quote(null);
|
||||
// Breaking the owner's old pair may change offers. Use only consent
|
||||
// retained by the canonical quote, not the original copied proposal.
|
||||
if (offers[desired.id] !== player) return null;
|
||||
quote(desired.id);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof NexusAllianceError) return null;
|
||||
throw error;
|
||||
}
|
||||
return alliancePromisesMatch(promises, player, opportunity, players.find(seat => seat.id === player)!.ally)
|
||||
? actions : null;
|
||||
}
|
||||
|
||||
export function validateAlliancePromises(promises: readonly AlliancePromise[], players: readonly string[]): void {
|
||||
if (!Array.isArray(promises)) throw new AlliancePromiseError('The alliance commitment record is invalid.');
|
||||
const events = new Set<string>();
|
||||
for (const promise of promises) {
|
||||
parseAllianceClaim(promise);
|
||||
if (!identifier(promise.event) || events.has(promise.event) || !identifier(promise.source) ||
|
||||
!identifier(promise.player) || !identifier(promise.asker) ||
|
||||
!players.includes(promise.player) || !players.includes(promise.asker) || !players.includes(promise.partner) ||
|
||||
promise.player === promise.asker || promise.player === promise.partner || typeof promise.answer !== 'boolean' ||
|
||||
!optionalBoolean(promise.fulfilled) || !optionalBoolean(promise.released) || promise.fulfilled && promise.released)
|
||||
throw new AlliancePromiseError('The alliance commitment lost its Nexus source, event or participants.');
|
||||
events.add(promise.event);
|
||||
}
|
||||
}
|
||||
@@ -1308,6 +1308,7 @@ function policyActions(g: GameView): Action[] {
|
||||
: [];
|
||||
}
|
||||
if (g.bribePromiseGuide) return [g.bribePromiseGuide.action];
|
||||
if (g.alliancePromiseGuide) return [g.alliancePromiseGuide.action];
|
||||
if (g.revivalPromiseGuide) return [g.revivalPromiseGuide.action];
|
||||
if (g.bidPromiseGuide) return [g.bidPromiseGuide.action];
|
||||
if (g.truthtrance) {
|
||||
@@ -1316,6 +1317,10 @@ function policyActions(g: GameView): Action[] {
|
||||
return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }];
|
||||
if (w.stage === 'answer') {
|
||||
if (w.question?.target !== me.id) return [];
|
||||
if (w.question.kind === 'alliance') {
|
||||
const answer = g.truthAllianceAnswers?.find(answer => answer !== 'unknown') ?? g.truthAllianceAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'bribe') {
|
||||
const answer = g.truthBribeAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
|
||||
+182
-26
@@ -1,3 +1,5 @@
|
||||
import { AlliancePromiseError, alliancePromiseModeSupported, liveAlliancePromises, alliancePromiseRequirements,
|
||||
alliancePromisesMatch, findAlliancePromisePlan, validateAlliancePromises, type AllianceClaim, type AllianceOpportunity, type AlliancePromise } from './alliance-promises';
|
||||
import { BribePromiseError, bribePromiseModeSupported, liveBribePromises, recordPromisedBribe,
|
||||
bribePromiseRequirements, validateBribePromises, type BribeClaim, type BribePromise } from './bribe-promises';
|
||||
import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income';
|
||||
@@ -2142,6 +2144,7 @@ export type Game = {
|
||||
bidPromises?: BidPromise[];
|
||||
revivalPromises?: RevivalPromise[];
|
||||
bribePromises?: BribePromise[];
|
||||
alliancePromises?: AlliancePromise[];
|
||||
movementPromises?: MovementPromise[];
|
||||
/** Server-only certainty of movement-relevant cards still in the discard. */
|
||||
movementDiscardKnowledge?: MovementDiscardKnowledge;
|
||||
@@ -14895,6 +14898,157 @@ function pledgeAid(g: Game, p: Player, requested: unknown) {
|
||||
g.aid[p.id] = { recipient: p.ally, amount };
|
||||
log(g, `${p.name} updated the spice available to their ally this phase.`);
|
||||
}
|
||||
function alliancePromiseRule<T>(calculate: () => T): T {
|
||||
try { return calculate(); }
|
||||
catch (error) { if (error instanceof AlliancePromiseError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function alliancePromiseIntegrity(g: Game) {
|
||||
alliancePromiseRule(() => validateAlliancePromises(g.alliancePromises ?? [], g.players.map(player => player.id)));
|
||||
}
|
||||
function allianceOpportunity(g: Game): AllianceOpportunity | null {
|
||||
if (g.status !== 'playing' || g.phase !== 1 || !g.nexus || !alliancePromiseModeSupported(g)) return null;
|
||||
return { turn: g.turn, source: JSON.stringify(['ordinaryNexus', g.turn,
|
||||
g.spiceDiscard.map(pile => pile.length), g.spiceSequence?.pile ?? null, g.spiceSequence?.skipped?.length ?? 0]) };
|
||||
}
|
||||
function allianceQuestionWindow(g: Game): boolean {
|
||||
return !!allianceOpportunity(g) && !g.spiceWindow && !g.spiceResolution && !g.response &&
|
||||
!g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama;
|
||||
}
|
||||
/** The original native reciprocal quote owns identity and pairing legality. */
|
||||
function normalNexusAllianceQuote(g: Game, p: Player, target: string | null) {
|
||||
if (target !== null) {
|
||||
const other = getPlayer(g, target);
|
||||
requireRule(other.id !== p.id, 'Choose another player.');
|
||||
requireRule(!liveBribePromises(g.bribePromises ?? [], p.id, g.turn)
|
||||
.some(promise => promise.answer && promise.recipient === other.id),
|
||||
'Pay your promised ordinary bribe before offering that recipient an alliance.');
|
||||
const blocked = homeworldAllianceReason(g, p.id, other.id);
|
||||
requireRule(!blocked, blocked ?? 'This alliance is unavailable.');
|
||||
}
|
||||
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 ?? []) {
|
||||
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;
|
||||
if (accepts && liveBribePromises(trial.bribePromises ?? [], p.id, g.turn)
|
||||
.some(promise => promise.answer && promise.recipient === accepts)) {
|
||||
const payment = bribePromiseCompletion(trial, actor)?.find(action => action.target === accepts);
|
||||
if (!payment) return null;
|
||||
const amount = payment.amount as number;
|
||||
actor.spice -= amount;
|
||||
getPlayer(trial, accepts).bribes += amount;
|
||||
trial.bribePromises = bribePromiseRule(() => recordPromisedBribe(trial.bribePromises ?? [], p.id,
|
||||
g.turn, { recipient: accepts, amount }));
|
||||
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; }
|
||||
}
|
||||
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 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');
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
function reconcileAlliancePromises(g: Game, voluntary?: { actor: string; action: Action }) {
|
||||
if (!g.alliancePromises?.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) {
|
||||
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]);
|
||||
if (!same && matches) { promise.fulfilled = true; continue; }
|
||||
if (!possible) {
|
||||
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 (!matches && g.ready.includes(player.id)) g.ready = g.ready.filter(id => id !== player.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
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.');
|
||||
}
|
||||
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 ?? [])
|
||||
if (promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.fulfilled && !promise.released)
|
||||
promise.fulfilled = true;
|
||||
}
|
||||
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,
|
||||
partner: promise.partner, answer: promise.answer,
|
||||
...(promise.fulfilled === undefined ? {} : { fulfilled: promise.fulfilled }),
|
||||
...(promise.released === undefined ? {} : { released: promise.released }),
|
||||
}));
|
||||
}
|
||||
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];
|
||||
if (!promise) return null;
|
||||
const plan = alliancePromisePlan(g, getPlayer(g, player));
|
||||
if (!plan || plan.bribePreparation) return null;
|
||||
const action: Action = plan.actions[0] ?? { type: 'ready' };
|
||||
return { action: { ...action, alliancePromiseEvent: promise.event } };
|
||||
}
|
||||
function bribePromiseRule<T>(calculate: () => T): T {
|
||||
try { return calculate(); }
|
||||
catch (error) { if (error instanceof BribePromiseError) throw new RuleError(error.message); throw error; }
|
||||
@@ -18387,7 +18541,7 @@ function reconcileGroundMovementPromises(g: Game, actor?: string, action?: Actio
|
||||
source.producer === 'groundMovement' && source.mover === p.id)) continue;
|
||||
const early = g.phase < 5 || sourcePhase < 5;
|
||||
const voluntary = actor === p.id && action?.type !== 'passResponse' && (!early || !!action &&
|
||||
(['card', 'bribe', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) ||
|
||||
(['card', 'bribe', 'alliance', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) ||
|
||||
action.type === 'richeseBid' && typeof action.amount === 'number' && action.amount > 0 ||
|
||||
action.type === 'ready' && [2, 4].includes(sourcePhase)));
|
||||
const busy = !!(g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard ||
|
||||
@@ -26891,7 +27045,7 @@ function reconcileShipmentPromises(
|
||||
voluntary.action.type === 'richeseBid' && typeof voluntary.action.amount === 'number' && voluntary.action.amount > 0 ||
|
||||
voluntary.action.type === 'ready' && [2, 4].includes(voluntary.phase ?? g.phase));
|
||||
const ownVoluntary = voluntary?.actor === p.id &&
|
||||
(['card', 'bribe', 'pledgeAid', 'emperorGift', 'richeseGift'].includes(voluntary.action.type) ||
|
||||
(['card', 'bribe', 'alliance', 'pledgeAid', 'emperorGift', 'richeseGift'].includes(voluntary.action.type) ||
|
||||
earlyVoluntary ||
|
||||
future && voluntary.action.type === 'decision');
|
||||
// A foreign pending parent may spend or refund its approved funding. Wait
|
||||
@@ -34946,6 +35100,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
bidPromiseIntegrity(state);
|
||||
revivalPromiseIntegrity(state);
|
||||
bribePromiseIntegrity(state);
|
||||
alliancePromiseIntegrity(state);
|
||||
revivalOrderIntegrity(state);
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
@@ -35049,6 +35204,12 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
);
|
||||
return normalizeAutomaticGame(state);
|
||||
}
|
||||
if (action.alliancePromiseEvent !== undefined) {
|
||||
const guide = projectedAlliancePromiseGuide(state, id);
|
||||
requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
|
||||
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
|
||||
'This alliance commitment guide is stale or belongs to another Nexus choice.');
|
||||
}
|
||||
if (action.bribePromiseEvent !== undefined) {
|
||||
const guide = projectedBribePromiseGuide(state, id);
|
||||
requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
|
||||
@@ -35189,6 +35350,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
reconcileBidPromises(g, { actor: id, action });
|
||||
reconcileRevivalPromises(g, { actor: id, action });
|
||||
reconcileBribePromises(g, { actor: id, action });
|
||||
reconcileAlliancePromises(g, { actor: id, action });
|
||||
settleAutomaticContinuations(g);
|
||||
observeOccupation(g);
|
||||
reconcileHomeworldEcazDuke(g);
|
||||
@@ -35393,6 +35555,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
reconcileRevivalPromises(g);
|
||||
reconcileBribePromises(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
reconcileAlliancePromises(g);
|
||||
reconcileTraitorTruth(g);
|
||||
requireRule(
|
||||
JSON.stringify(g) !== before,
|
||||
@@ -35408,6 +35571,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
bidPromiseIntegrity(state);
|
||||
revivalPromiseIntegrity(state);
|
||||
bribePromiseIntegrity(state);
|
||||
alliancePromiseIntegrity(state);
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
harkonnenNexusBetrayalIntegrity(state);
|
||||
@@ -35495,6 +35659,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
reconcileRevivalPromises(g);
|
||||
reconcileBribePromises(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
reconcileAlliancePromises(g);
|
||||
reconcileTraitorTruth(g);
|
||||
settleAutomaticContinuations(g);
|
||||
observeOccupation(g);
|
||||
@@ -35739,6 +35904,8 @@ function applyActionInner(
|
||||
bindShipment: bindShipmentTruth,
|
||||
movementAnswers: movementTruthAnswers,
|
||||
bindMovement: bindMovementTruth,
|
||||
allianceAnswers: allianceTruthAnswers,
|
||||
bindAlliance: bindAllianceTruth,
|
||||
bribeAnswers: bribeTruthAnswers,
|
||||
bindBribe: bindBribeTruth,
|
||||
revivalAnswers: revivalTruthAnswers,
|
||||
@@ -35754,7 +35921,7 @@ function applyActionInner(
|
||||
)
|
||||
return g;
|
||||
} catch (error) {
|
||||
if (error instanceof TruthError || error instanceof BribePromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
|
||||
if (error instanceof TruthError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
|
||||
throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
@@ -38100,29 +38267,8 @@ function applyActionInner(
|
||||
const formerAlly = p.ally ? getPlayer(g, p.ally).name : null;
|
||||
const formerOffer = g.allianceOffers[id];
|
||||
const formerOfferName = g.players.find((player) => player.id === formerOffer)?.name;
|
||||
let other: Player | null = null;
|
||||
if (target !== null) {
|
||||
other = getPlayer(g, target);
|
||||
requireRule(other.id !== id, 'Choose another player.');
|
||||
requireRule(!liveBribePromises(g.bribePromises ?? [], p.id, g.turn)
|
||||
.some(promise => promise.answer && promise.recipient === other!.id),
|
||||
'Pay your promised ordinary bribe before offering that recipient an alliance.');
|
||||
const blocked = homeworldAllianceReason(g, id, other.id);
|
||||
requireRule(!blocked, blocked ?? 'This alliance is unavailable.');
|
||||
}
|
||||
const quote = nexusRule(() =>
|
||||
quoteNexusAlliance(
|
||||
{
|
||||
players: g.players.map((player) => ({
|
||||
id: player.id,
|
||||
ally: player.ally,
|
||||
})),
|
||||
offers: g.allianceOffers,
|
||||
actor: id,
|
||||
target,
|
||||
},
|
||||
),
|
||||
);
|
||||
const other = target === null ? null : getPlayer(g, target);
|
||||
const quote = normalNexusAllianceQuote(g, p, target);
|
||||
for (const player of g.players) player.ally = quote.allies[player.id];
|
||||
g.allianceOffers = quote.offers;
|
||||
if (target === null) {
|
||||
@@ -39488,6 +39634,7 @@ function applyActionInner(
|
||||
return g;
|
||||
}
|
||||
if (t === 'ready') {
|
||||
if (allianceQuestionWindow(g)) guardAlliancePromiseReady(g, p);
|
||||
if (g.phase === 7) requireRule(!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer),
|
||||
'Complete your Truthtrance bribe payments before finishing Collection.');
|
||||
if (g.phase === 4) guardPromisedRevival(g, p, action);
|
||||
@@ -39505,6 +39652,9 @@ function applyActionInner(
|
||||
}
|
||||
g.ready.push(id);
|
||||
if (g.ready.length === g.players.length) {
|
||||
// Every quiet negotiation closes before its native continuation, whether
|
||||
// that continuation draws Spice or begins worm rides.
|
||||
if (g.phase === 1 && allianceQuestionWindow(g)) settleAlliancePromises(g);
|
||||
if (g.phase === 0) startStormMovement(g, g.stormPending!);
|
||||
else if (g.phase === 1 && g.spiceWindow) finishSpiceWindow(g);
|
||||
else if (g.phase === 1 && (!g.nexus || g.summonedBeforeBlow))
|
||||
@@ -39684,6 +39834,12 @@ export function viewGame(state: Game, id: string) {
|
||||
}).map(player => player.id) : [],
|
||||
truthBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'bid' ? bidTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
alliancePromiseNexus: (() => { const nexus = allianceOpportunity(g); return nexus ? { turn: nexus.turn } : null; })(),
|
||||
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,
|
||||
alliancePromises: projectedAlliancePromises(g, id),
|
||||
alliancePromiseGuide: projectedAlliancePromiseGuide(g, id),
|
||||
truthBribeTargets: bribeQuestionWindow(g) ? g.players.map(player => player.id) : [],
|
||||
truthBribeAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'bribe' ? bribeTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
|
||||
+28
-1
@@ -3208,6 +3208,31 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Verification', status: 'Partial', detail: '13 files101/101, types/lint/build and six native engine/answer-callback/GameTable SSR journeys pass. Genuine26/72/77-action Basic/allied/Cunning sources earn original cards, alliances, payments, reveal and draw/return. Four profiles answer/vote legally. Unmatched-card staging is labelled; submitted-false and response/typing fixture feedback is repaired. Full future promises, combinations and final assurance remain open.', evidence: ['tests/traitor-decision-promises.test.ts', 'tests/fixture-truth-traitor-decision.ts', 'tests/fixture-truth-traitor-harkonnen.ts', 'tests/truth-traitor-decision-engine.test.ts', 'tests/truth-traitor-harkonnen-engine.test.ts', 'tests/truth-traitor-decision-controls.test.tsx', 'docs/TRUTHTRANCE_TRAITOR_DECISIONS.md'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-alliance',
|
||||
title: 'Truthtrance: alliance membership at this Nexus close',
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Bind actual partnership at the current ordinary Nexus close, using real reciprocal consent and original alliance actions.',
|
||||
steps: [
|
||||
'Choose Bind alliance membership at this Nexus close, another respondent and a named partner. This first version supports a quiet ordinary Nexus in registered native Basic and Advanced tables without optional modules. An ordinary worm Nexus does not require the Nexus Cards option.',
|
||||
'The question concerns actual final membership at this Nexus close. A proposal alone is not an alliance. Yes stays live after forming the pair and settles only at the close. No requires ending without the named partner; a temporary partnership may be broken before readiness.',
|
||||
'An owned Yes route uses an already held partnership or a currently available reciprocal invitation. The respondent may break an old pair first if the real invitation remains. Another player’s future consent, departure or renewed offer is not invented. Other players keep their original right to withdraw or break.',
|
||||
'If consent is not available and earlier answers do not fix the result, No or I don’t know may be available. Unknown preserves the physical card; the holder may ask a different question or save it to resume negotiations, then ask again after a real invitation.',
|
||||
'Your persistent panel offers the next native break, acceptance or Ready action in a quiet Nexus, including out of turn. A new definite answer reopens your own readiness. Ready requires the current partnership to match every live answer. Actual closing settles them before the next native draw or worm ride.',
|
||||
'Own actions cannot erase the last owned completion of Yes. A completed opposing withdrawal or break can release an impossible answer; earlier compatible answers remain. Existing financial and movement commitments are retained. An earlier owed bribe to the desired partner uses its original payment guide before acceptance.',
|
||||
'The current-Nexus interval, owned-consent witness and release priority are provisional online compositions of general current-turn Truthtrance authority. Wider negotiations, earlier-phase alliance questions, optional modules and complete-mode acceptance remain due. No start gate opens.',
|
||||
],
|
||||
related: ['card-truthtrance', 'nexus-offers', phaseRuleId(1), 'truthtrance-bribe', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Current-Nexus final membership, joint own Yes/No, canonical break/accept quotes, real closure and opposing-impossibility release retain original consent rights.', evidence: ['game/alliance-promises.ts', 'game/nexus-alliance.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/alliance-promises.test.ts', 'tests/truth-alliance-runtime.test.ts', 'tests/truth-alliance-interactions.test.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Fixed-turn named partner, public final-status preview, target-only answers and persistent event-bound break/accept/Ready guidance. No private Nexus source is projected.', evidence: ['components/alliance-claim-fields.tsx', 'components/alliance-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-alliance-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four minimal policies use feasible answers and owned native guides. Strategic negotiation and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-alliance-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Physical alliance/general Truthtrance sources, owned consent, final interval, unknown/save and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_ALLIANCE_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Original setup/Worm/consent histories and focused UI/pure/actor-order cases; wider source compositions, browser and final deployed acceptance remain open.', evidence: ['tests/fixture-truth-alliance.ts', 'tests/truth-alliance-runtime.test.ts', 'tests/truth-alliance-controls.test.tsx', 'tests/truth-alliance-interactions.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-bribe',
|
||||
title: 'Truthtrance: ordinary bribe-payment promises',
|
||||
@@ -3432,7 +3457,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
summary:
|
||||
'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, ordinary bribe-payment, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
|
||||
'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, ordinary bribe-payment, current-Nexus alliance, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
|
||||
steps: [
|
||||
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
|
||||
'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
|
||||
@@ -3444,6 +3469,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier Basic phases with declared native families, owned Charity/ordinary revival preparation and exact normal/native auction funding, plus active or unused waiting phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.',
|
||||
'After both plans reveal, a separate current-Traitor question binds an actual unsubmitted native voter to Reveal or decline. The declaration fulfills it before later Karama/betrayal prevention. Cunning cannot voluntarily return a matching card needed by a live Yes; completed declarations retain their native later custody rights. See the current-Traitor guide for scope.',
|
||||
'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.',
|
||||
'A current-Nexus alliance question binds actual final membership with a named partner at that Nexus close. Yes uses existing partnership or an actual invitation; offers alone do not form alliances. No may require a later break before Ready. Unknown can save the card until consent exists. Other players retain their original withdrawal/break rights. See the alliance-promise guide for its provisional interval and scope.',
|
||||
'A bribe-payment question binds cumulative future ordinary spice paid to one named non-allied recipient during this turn. Only the real payer action counts; the recipient still spends its receipt at Mentat. Split payments add together, and earlier obligations retain their funding. See the ordinary-bribe guide for the funded prototype and unfinished preparations.',
|
||||
'An ordinary force-revival question binds cumulative future total, normal or elite returns in native Revival later this turn, from supported phases0–4. Earlier and special returns do not count. Yes retains the original costs and limits; No also applies across smaller requests. See the revival-promise guide for exact timing and unfinished compositions.',
|
||||
'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. The complete grouped builder supports eight children per group, sixteen total conditions including groups and depth four. Add/wrap/remove/collapse retains the whole claim and validates every field. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.',
|
||||
@@ -3465,6 +3491,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'truthtrance-bid',
|
||||
'truthtrance-revival',
|
||||
'truthtrance-bribe',
|
||||
'truthtrance-alliance',
|
||||
'richese-no-field',
|
||||
'card-reinforcements',
|
||||
],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseAllianceClaim, allianceClaimText, alliancePromiseModeSupported, type AllianceClaim } from './alliance-promises';
|
||||
import { parseBribeClaim, bribeClaimText, bribePromiseModeSupported, type BribeClaim } from './bribe-promises';
|
||||
import { parseRevivalClaim, revivalClaimText, revivalPromiseModeSupported, type RevivalClaim } from './revival-promises';
|
||||
import { parseMovementClaim, movementClaimText, movementPromiseTargetAvailable, MovementClaimError, type MovementClaim } from './movement-promises';
|
||||
@@ -62,6 +63,7 @@ export type TruthFact =
|
||||
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
|
||||
| { kind: 'and' | 'or'; terms: TruthFact[] };
|
||||
export type TruthQuestion =
|
||||
| ({ kind: 'alliance'; target: string } & AllianceClaim)
|
||||
| ({ kind: 'bribe'; target: string } & BribeClaim)
|
||||
| ({ kind: 'revival'; target: string } & RevivalClaim)
|
||||
| ({ kind: 'bid'; target: string } & BidClaim)
|
||||
@@ -216,6 +218,17 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
g.players.some((p) => p.id === v.target),
|
||||
'Ask one other player.',
|
||||
);
|
||||
if (v.kind === 'alliance') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,partner,target,turn',
|
||||
'Supply the current Nexus turn, named alliance partner and respondent.');
|
||||
const claim = parseAllianceClaim(v);
|
||||
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 && claim.turn === g.turn &&
|
||||
claim.partner !== v.target && g.players.some(player => player.id === claim.partner),
|
||||
'Choose the current ordinary Nexus and another named partner in a supported table.');
|
||||
return { kind: 'alliance', target: v.target, ...claim };
|
||||
}
|
||||
if (v.kind === 'bribe') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,minimum,recipient,target,turn',
|
||||
'Supply the current turn, named bribe recipient, minimum and respondent.');
|
||||
@@ -475,6 +488,7 @@ export function truthQuestionText(
|
||||
playerName: (id: string) => string = id => id,
|
||||
): string {
|
||||
if (q.kind === 'freeform') return q.text;
|
||||
if (q.kind === 'alliance') return `Will you ${allianceClaimText(q, playerName)}?`;
|
||||
if (q.kind === 'bribe') return `Will you ${bribeClaimText(q, playerName)}?`;
|
||||
if (q.kind === 'revival') return `Will you ${revivalClaimText(q)}?`;
|
||||
if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`;
|
||||
@@ -523,6 +537,8 @@ export function resolveTruthAction(
|
||||
) => void;
|
||||
movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[];
|
||||
bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void;
|
||||
allianceAnswers?: (g: Game, p: Player, claim: AllianceClaim) => TruthAnswer[];
|
||||
bindAlliance?: (g: Game, p: Player, claim: AllianceClaim, answer: boolean, asker: string) => void;
|
||||
bribeAnswers?: (g: Game, p: Player, claim: BribeClaim) => TruthAnswer[];
|
||||
bindBribe?: (g: Game, p: Player, claim: BribeClaim, answer: boolean, asker: string) => void;
|
||||
revivalAnswers?: (g: Game, p: Player, claim: RevivalClaim) => TruthAnswer[];
|
||||
@@ -647,6 +663,9 @@ export function resolveTruthAction(
|
||||
'Waiting for the Truthtrance holder to ask a question.',
|
||||
);
|
||||
w.question = parseQuestion(g, p.id, a.question);
|
||||
if (w.question.kind === 'alliance')
|
||||
check(effects.allianceAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a current Nexus question with a compatible answer.');
|
||||
if (w.question.kind === 'bribe')
|
||||
check(effects.bribeAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a current bribe question compatible with earlier answers.');
|
||||
@@ -706,6 +725,11 @@ export function resolveTruthAction(
|
||||
'Choose an answer consistent with legal ground movement and earlier promises.');
|
||||
if (a.answer !== 'unknown') effects.bindMovement(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === '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.');
|
||||
if (a.answer !== 'unknown') effects.bindAlliance(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'bribe') {
|
||||
check(effects.bribeAnswers && effects.bindBribe && effects.bribeAnswers(g, p, q).includes(a.answer),
|
||||
'Choose an answer consistent with actual ordinary bribe funding and earlier promises.');
|
||||
|
||||
Reference in new issue
Block a user