diff --git a/components/alliance-expression-fields.tsx b/components/alliance-expression-fields.tsx
new file mode 100644
index 00000000..3ea16325
--- /dev/null
+++ b/components/alliance-expression-fields.tsx
@@ -0,0 +1,116 @@
+'use client';
+
+import type { ReactElement } from 'react';
+import {
+ parseAllianceExpression, ALLIANCE_EXPRESSION_MAX_DEPTH, ALLIANCE_EXPRESSION_MAX_LEAVES,
+ type AllianceExpression,
+} from '@/game/alliance-expression-promises';
+import { Button } from './ui/button';
+
+type Partner = { id: string; name: string };
+const newLeaf = (): AllianceExpression => ({ kind: 'partner', partner: '' });
+export function allianceExpressionInputError(value: unknown, partners: readonly Partner[]): string | null {
+ try {
+ const expression = parseAllianceExpression(value);
+ const visit = (node: AllianceExpression): void => {
+ if (node.kind === 'partner') {
+ if (!partners.some(partner => partner.id === node.partner)) throw new Error('Choose a current named other alliance partner.');
+ } else node.terms.forEach(visit);
+ };
+ visit(expression);
+ return null;
+ } catch (error) {
+ return error instanceof Error ? error.message : 'Choose a valid final-alliance expression.';
+ }
+}
+
+// Detached placeholders validate only structure, leaving incomplete public
+// partner choices visible and repairable in the original controlled tree.
+const structuralDraft = (node: AllianceExpression): AllianceExpression => node.kind === 'partner'
+ ? { ...node, partner: 'partner' }
+ : { ...node, terms: node.terms.map(structuralDraft) };
+const inputSize = (node: AllianceExpression): { leaves: number; depth: number } => {
+ if (node.kind === 'partner') return { leaves: 1, depth: 0 };
+ const sizes = node.terms.map(inputSize);
+ return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) };
+};
+
+/** Public partner choices never predict another player's consent. The server
+ * owns feasibility and final actual membership at the original Nexus close. */
+export function AllianceExpressionFields({ value, partners, disabled, onChange }: {
+ value: AllianceExpression;
+ partners: readonly Partner[];
+ disabled: boolean;
+ onChange: (expression: AllianceExpression) => void;
+}) {
+ const size = inputSize(value), error = allianceExpressionInputError(value, partners);
+ const structuralError = (next: AllianceExpression) => {
+ try { parseAllianceExpression(structuralDraft(next)); return false; } catch { return true; }
+ };
+ const commit = (next: AllianceExpression) => {
+ if (!disabled && !structuralError(next)) onChange(next);
+ };
+ const render = (node: AllianceExpression, path: string, replace: (next: AllianceExpression) => AllianceExpression,
+ remove?: () => AllianceExpression): ReactElement => {
+ const group = node.kind !== 'partner', id = `truth-alliance-expression-${path}`;
+ const allowed = (next: AllianceExpression) => !disabled && !structuralError(replace(next));
+ const change = (next: AllianceExpression) => commit(replace(next));
+ const wrap = (kind: 'and' | 'or'): AllianceExpression => ({ kind, terms: [node, newLeaf()] });
+ return
+ {group ? `Alliance group ${path} · ${node.kind.toUpperCase()}` : `Alliance condition ${path}`}
+ {group ? <>
+ Alliance group {path} meaning
+ {
+ const kind = event.currentTarget.value;
+ if (kind === 'and' || kind === 'or') change({ ...node, kind });
+ }}>All conditions · AND At least one condition · OR
+ {node.terms.map((term, index) => render(term, `${path}.${index + 1}`,
+ next => replace({ ...node, terms: node.terms.map((old, i) => i === index ? next : old) }),
+ () => {
+ const terms = node.terms.filter((_, i) => i !== index);
+ return replace(terms.length === 1 ? terms[0] : { ...node, terms });
+ }))}
+
+ change({ ...node, terms: [...node.terms, newLeaf()] })}>Add alliance condition
+ {(['and', 'or'] as const).map(kind => {
+ const next: AllianceExpression = { ...node, terms: [...node.terms, { kind, terms: [newLeaf(), newLeaf()] }] };
+ return change(next)}>Add nested {kind.toUpperCase()} group ;
+ })}
+
+ > : <>
+ Named alliance partner
+ partner.id === node.partner) ? node.partner : ''}
+ disabled={disabled} aria-invalid={!partners.some(partner => partner.id === node.partner)} onChange={event => {
+ const partner = event.currentTarget.value;
+ if (partners.some(choice => choice.id === partner)) change({ ...node, partner });
+ }}>
+ Choose a partner
+ {partners.map(partner => {partner.name} )}
+
+ >}
+
+ {(['and', 'or'] as const).map(kind => change(wrap(kind))}>Group with {kind.toUpperCase()} )}
+ {remove && commit(remove())}>Remove {group ? 'group' : 'condition'} }
+
+ ;
+ };
+ return
+ Final alliance expression · Current Nexus
+ Each condition concerns actual membership when this Nexus closes. Offers alone are not alliances.
+ AND requires every condition; OR requires at least one. No makes the whole expression false, not necessarily every condition.
+ Legal alternatives remain available; the question does not pin a fixed branch.
+ Yes and No settle only at the actual Nexus close. Forming an alliance does not fulfill the answer early.
+ You cannot promise the other player’s consent. If future consent is needed, unknown may apply; ask again or save the card.
+ App input limits: {ALLIANCE_EXPRESSION_MAX_LEAVES} conditions and {ALLIANCE_EXPRESSION_MAX_DEPTH} levels of grouping.
+ Groups do not count as conditions. These are input limits, not additional printed rules.
+ {size.leaves} / {ALLIANCE_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {ALLIANCE_EXPRESSION_MAX_DEPTH} levels of grouping
+ {error && {error}
}
+ {render(value, '1', next => next)}
+ ;
+}
diff --git a/components/alliance-expression-promises.tsx b/components/alliance-expression-promises.tsx
new file mode 100644
index 00000000..e4b6de67
--- /dev/null
+++ b/components/alliance-expression-promises.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import type { Action, GameView } from '@/game/engine';
+import { allianceExpressionText } from '@/game/alliance-expression-promises';
+import { AlliancePromiseGuideButton } from './alliance-promises';
+
+export function AllianceExpressionPromises(props: { game: GameView; busy: boolean; send: (action: Action) => void }) {
+ const game = props.game, own = game.allianceExpressionPromises.filter(promise => promise.player === game.me);
+ if (!own.length) return null;
+ const name = (id: string) => game.players.find(player => player.id === id)?.name ?? 'the named partner';
+ const live = own.some(promise => promise.turn === game.alliancePromiseNexus?.turn && !promise.fulfilled && !promise.released);
+ const scalarOwnsGuide = game.alliancePromises.some(promise => promise.player === game.me &&
+ promise.turn === game.alliancePromiseNexus?.turn && !promise.fulfilled && !promise.released);
+ return
+ Your alliance expression promises
+ {own.map(promise =>
+ Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {allianceExpressionText(promise.claim, name)}.
+ {promise.released ? ' Released: this commitment is no longer possible.'
+ : promise.fulfilled ? ' Fulfilled at the Nexus close.' : ' This whole answer stays binding until this Nexus closes.'}
+ )}
+ Each condition concerns final actual alliance membership when this Nexus closes.
+ AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.
+ Legal alternatives remain available; no fixed branch is selected for you.
+ Yes and No settle only at the actual Nexus close. Forming an alliance does not fulfill the answer early.
+ Offers alone are not alliances.
+ An owned Yes route needs an existing alliance or an actual reciprocal invitation.
+ Neither answer promises another player’s consent or prevents their original actions.
+ If future consent is needed, unknown may apply; the holder can ask again or save the card.
+ Honor any earlier promises first; their own controls handle required payments.
+ {' '}Alliance expression rules .
+ {live && !scalarOwnsGuide && }
+ ;
+}
diff --git a/components/alliance-promises.tsx b/components/alliance-promises.tsx
index 17126234..c65719a8 100644
--- a/components/alliance-promises.tsx
+++ b/components/alliance-promises.tsx
@@ -2,6 +2,7 @@
import type { Action, GameView } from '@/game/engine';
import { allianceClaimText, alliancePromiseModeSupported } from '@/game/alliance-promises';
+import { evaluateAllianceExpression } from '@/game/alliance-expression-promises';
import { quoteNexusAlliance } from '@/game/nexus-alliance';
import { Button } from './ui/button';
@@ -22,13 +23,21 @@ export function AlliancePromiseGuideButton(props: AlliancePromiseProps) {
const current = props.game, me = current.players.find(player => player.id === current.me);
const live = current.alliancePromises.filter(promise => promise.player === actor && promise.turn === turn &&
!promise.fulfilled && !promise.released);
- let ownedAction = action.type === 'ready' && !!me && live.every(promise => (me.ally === promise.partner) === promise.answer);
+ const pure = current.allianceExpressionPromises.filter(promise => promise.player === actor && promise.turn === turn &&
+ !promise.fulfilled && !promise.released);
+ const mixed = current.mixedAlliancePromises.filter(promise => promise.player === actor && promise.turn === turn &&
+ !promise.fulfilled && !promise.released);
+ // Pure membership is visible. The server alone knows a mixed question's
+ // frozen facts and reduced future obligation; never reconstruct it here.
+ const matches = (ally: string | null) => live.every(promise => (ally === promise.partner) === promise.answer) &&
+ pure.every(promise => evaluateAllianceExpression(promise.claim, ally) === promise.answer);
+ let ownedAction = action.type === 'ready' && !!me && matches(me.ally);
if (action.type === 'alliance') {
if (action.target === null) ownedAction = true;
else if (typeof action.target === 'string' && action.target !== actor && current.players.some(player => player.id === action.target)) {
try {
const quote = quoteNexusAlliance({ ...publicContext(current), actor, target: action.target });
- ownedAction = !!quote.formed && live.every(promise => (quote.allies[actor] === promise.partner) === promise.answer);
+ ownedAction = !!quote.formed && matches(quote.allies[actor]);
} catch { ownedAction = false; }
}
}
@@ -37,7 +46,7 @@ export function AlliancePromiseGuideButton(props: AlliancePromiseProps) {
current.me === actor && current.turn === turn && current.alliancePromiseNexus?.turn === turn &&
current.alliancePromiseGuide === guide && current.alliancePromiseGuide.action === action && JSON.stringify(action) === signature &&
JSON.stringify(publicContext(current)) === contextSignature && typeof event === 'string' &&
- live.some(promise => promise.event === event) && ownedAction && !!me && !me.autopilot &&
+ [...live, ...pure, ...mixed].some(promise => promise.event === event) && ownedAction && !!me && !me.autopilot &&
!current.truthtrance && !current.response && !current.decision && !current.phaseOpening &&
!current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
@@ -55,6 +64,7 @@ export function AlliancePromiseGuideButton(props: AlliancePromiseProps) {
export function AlliancePromises(props: AlliancePromiseProps) {
const own = props.game.alliancePromises.filter(promise => promise.player === props.game.me);
if (!own.length) return null;
+ const live = own.some(promise => promise.turn === props.game.alliancePromiseNexus?.turn && !promise.fulfilled && !promise.released);
const name = (id: string) => props.game.players.find(player => player.id === id)?.name ?? 'the named partner';
return
Your alliance promises
@@ -76,6 +86,6 @@ export function AlliancePromises(props: AlliancePromiseProps) {
Honor any earlier promises first; their own controls handle required payments.
{' '}Alliance promise rules .
-
+ {live && }
;
}
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 1f04467b..f2e1b46f 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -27,6 +27,8 @@ import {
} from '@/game/shipment-promises';
import { ShipmentPromises } from './shipment-promises';
import { AlliancePromises } from './alliance-promises';
+import { AllianceExpressionPromises } from './alliance-expression-promises';
+import { MixedAlliancePromises } from './mixed-alliance-promises';
import { BribePromises } from './bribe-promises';
import { BribeExpressionPromises } from './bribe-expression-promises';
import { MixedBribePromises } from './mixed-bribe-promises';
@@ -2048,6 +2050,8 @@ export function GameTable({
{g.status === 'playing' && }
{g.status === 'playing' && }
{g.status === 'playing' && }
+ {g.status === 'playing' && }
+ {g.status === 'playing' && }
{g.status === 'playing' && }
{g.status === 'playing' && }
{g.status === 'playing' && }
diff --git a/components/mixed-alliance-fields.tsx b/components/mixed-alliance-fields.tsx
new file mode 100644
index 00000000..3f076164
--- /dev/null
+++ b/components/mixed-alliance-fields.tsx
@@ -0,0 +1,58 @@
+'use client';
+
+import type { GameView } from '@/game/engine';
+import type { MixedActionExpression } from '@/game/mixed-action-question';
+import { ALLIANCE_EXPRESSION_MAX_DEPTH, ALLIANCE_EXPRESSION_MAX_LEAVES } from '@/game/alliance-expression-promises';
+import { parseAllianceActionClaim, type AllianceActionClaim } from '@/game/mixed-alliance-question';
+import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields';
+
+const partners = (game: GameView, target: string) => game.players.some(player => player.id === target)
+ ? game.players.filter(player => player.id !== target) : [];
+const config: MixedActionConfig = {
+ prefix: 'mixed-alliance',
+ legend: 'Mixed current-fact / final alliance claim · Current Nexus',
+ actionLabel: 'final alliance condition',
+ actionTiming: 'Final alliance · this Nexus close',
+ actionHelp: <>The future condition concerns actual membership when this Nexus closes. Offers alone are not alliances.
+ Any remaining alliance obligation settles at the actual Nexus close; forming an alliance does not fulfill it early.
+ A constant whole answer can already be complete without an alliance obligation.
+ You cannot promise the other player’s consent. If no owned route exists, unknown may apply; ask again or save the card.
+ Legal alternatives remain available; the question does not pin a fixed branch. Original alliance and payment rules still apply.>,
+ fallbackError: 'Choose a current fact and a final alliance condition.',
+ maxLeaves: ALLIANCE_EXPRESSION_MAX_LEAVES,
+ maxDepth: ALLIANCE_EXPRESSION_MAX_DEPTH,
+ newAction: () => ({ partner: '' }),
+ parseAction: (value, game, target) => {
+ const action = parseAllianceActionClaim(value);
+ if (!partners(game, target).some(partner => partner.id === action.partner))
+ throw new Error('Choose a current named other alliance partner.');
+ return action;
+ },
+ renderAction: ({ value, id, game, target, disabled, onChange }) => {
+ const choices = partners(game, target), valid = choices.some(partner => partner.id === value.partner);
+ return <>
+ Named alliance partner
+ {
+ const partner = event.currentTarget.value;
+ if (partner === '' || choices.some(choice => choice.id === partner)) onChange({ ...value, partner });
+ }}>
+ Choose a partner
+ {choices.map(partner => {partner.name} )}
+
+ >;
+ },
+};
+
+export function mixedAllianceInputError(value: unknown, game: GameView, target: string): string | null {
+ return mixedActionInputError(value, game, target, config);
+}
+export function MixedAllianceFields(props: {
+ value: MixedActionExpression;
+ game: GameView;
+ target: string;
+ disabled: boolean;
+ onChange: (expression: MixedActionExpression) => void;
+}) {
+ return MixedActionFields({ ...props, config });
+}
diff --git a/components/mixed-alliance-promises.tsx b/components/mixed-alliance-promises.tsx
new file mode 100644
index 00000000..ffd13d69
--- /dev/null
+++ b/components/mixed-alliance-promises.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import type { Action, GameView } from '@/game/engine';
+import { truthQuestionText } from '@/game/truthtrance';
+import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
+import { AlliancePromiseGuideButton } from './alliance-promises';
+
+/** Show only the own original question and aggregate result. Raw current facts
+ * cannot reveal which private future obligation the server compiled. */
+export function MixedAlliancePromises(props: { game: GameView; busy: boolean; send: (action: Action) => void }) {
+ const game = props.game, own = game.mixedAlliancePromises.filter(promise => promise.player === game.me);
+ if (!own.length) return null;
+ const name = (id: string) => game.players.find(player => player.id === id)?.name ?? 'the named partner';
+ const live = own.some(promise => promise.turn === game.alliancePromiseNexus?.turn && !promise.fulfilled && !promise.released);
+ const ownsLive = (promise: { player: string; turn: number; fulfilled?: boolean; released?: boolean }) =>
+ promise.player === game.me && promise.turn === game.alliancePromiseNexus?.turn && !promise.fulfilled && !promise.released;
+ const sharedGuideElsewhere = game.alliancePromises.some(ownsLive) || game.allianceExpressionPromises.some(ownsLive);
+ return
+ Your mixed alliance promises
+ {own.map(promise =>
+ Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'mixedAlliance', target: promise.player,
+ turn: promise.turn, mixed: promise.mixed }, id => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine'
+ : game.allLeaders.find(leader => leader.id === id)?.name ?? id,
+ id => game.combatLocations.find(location => location.id === id)?.name ?? id, name)}.
+ {promise.released ? ' Released: this whole commitment is no longer possible.'
+ : promise.fulfilled ? ' Fulfilled: this whole commitment is complete.' : ' This whole answer stays binding until this Nexus closes.'}
+ )}
+ Current facts are evaluated at the answer. They create no promise to keep cards, spice or forces afterward.
+ Only the aggregate answer is shared. Individual fact answers and any reduced future branch remain private.
+ AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.
+ Legal alternatives remain available; no fixed branch is selected for you.
+ The future condition concerns final actual alliance membership when this Nexus closes.
+ A remaining alliance obligation settles only at the actual Nexus close; forming an alliance does not fulfill it early.
+ Offers alone are not alliances. A constant whole answer can already be complete without an alliance obligation.
+ Neither answer promises another player’s consent or prevents their original actions.
+ If future consent is needed, unknown may apply; the holder can ask again or save the card.
+ Honor any earlier promises first; their own controls handle required payments.
+ {' '}Mixed alliance rules .
+ {live && !sharedGuideElsewhere && }
+ ;
+}
diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx
index 066a8baa..2b2ebb4d 100644
--- a/components/truthtrance.tsx
+++ b/components/truthtrance.tsx
@@ -19,6 +19,10 @@ import { LeaderRevivalClaimFields, leaderRevivalClaimInputError } from './leader
import type { LeaderRevivalClaim } from '@/game/leader-revival-promises';
import { MovementClaimFields } from './movement-claim-fields';
import { AllianceClaimFields, allianceClaimInputError } from './alliance-claim-fields';
+import { AllianceExpressionFields, allianceExpressionInputError } from './alliance-expression-fields';
+import { MixedAllianceFields, mixedAllianceInputError } from './mixed-alliance-fields';
+import type { AllianceExpression } from '@/game/alliance-expression-promises';
+import type { AllianceActionClaim } from '@/game/mixed-alliance-question';
import type { AllianceClaim } from '@/game/alliance-promises';
import { BribeClaimFields, bribeClaimInputError } from './bribe-claim-fields';
import type { BribeClaim } from '@/game/bribe-promises';
@@ -79,7 +83,7 @@ export function Truthtrance({
g.players.find((p) => p.id !== me.id)!.id,
);
const [kind, setKind] = useState<
- 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid'
+ 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance'
>('fact');
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
@@ -97,6 +101,15 @@ export function Truthtrance({
const [alliancePartner, setAlliancePartner] = useState(g.me);
const allianceClaim: AllianceClaim = { turn: g.turn, partner: alliancePartner };
const alliancePartners = g.players.filter(player => player.id !== target);
+ const [allianceExpression, setAllianceExpression] = useState({ kind: 'or', terms: [
+ { kind: 'partner', partner: g.me }, { kind: 'partner', partner: '' },
+ ] });
+ const allianceExpressionError = allianceExpressionInputError(allianceExpression, alliancePartners);
+ const [mixedAlliance, setMixedAlliance] = useState>({ kind: 'and', terms: [
+ { kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 10 } },
+ { kind: 'action', action: { partner: g.me } },
+ ] });
+ const mixedAllianceError = mixedAllianceInputError(mixedAlliance, g, target);
const [bribeSelection, setBribeSelection] = useState>({ recipient: g.me, minimum: 3 });
const bribeClaim: BribeClaim = { turn: g.turn, ...bribeSelection };
const bribeRespondent = g.players.find(player => player.id === target);
@@ -179,6 +192,8 @@ export function Truthtrance({
: kind === 'mixedRevival' ? { kind, target, turn: g.turn, mixed: mixedRevival }
: kind === 'revivalExpression' ? { kind, target, turn: g.turn, claim: revivalExpression }
: kind === 'leaderRevival' ? { kind, target, ...leaderRevivalClaim }
+ : kind === 'allianceExpression' ? { kind, target, turn: g.turn, claim: allianceExpression }
+ : kind === 'mixedAlliance' ? { kind, target, turn: g.turn, mixed: mixedAlliance }
: kind === 'alliance' ? { kind, target, ...allianceClaim }
: kind === 'mixedBribe' ? { kind, target, turn: g.turn, mixed: mixedBribe }
: kind === 'bribeExpression' ? { kind, target, turn: g.turn, claim: bribeExpression }
@@ -321,6 +336,13 @@ export function Truthtrance({
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'unknown'}`, { type: 'truthAnswer', answer })}
)}
>
+ ) : w.question!.kind === 'allianceExpression' || w.question!.kind === 'mixedAlliance' ? (
+ <>
+ Answer the whole expression about actual final membership when this Nexus closes. Offers and temporary formation do not settle a binding answer. OR preserves each consent-supported partner choice; No negates the complete expression. Other participants retain their own consent and withdrawal rights. Current facts in mixed questions freeze now; individual factual answers remain private.
+ {((w.question!.kind === 'allianceExpression' ? g.truthAllianceExpressionAnswers : g.truthMixedAllianceAnswers) ?? []).map(answer =>
+ {button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
+
)}
+ >
) : w.question!.kind === 'alliance' ? (
<>
This answer concerns actual membership when this Nexus closes. An offer alone does not form an alliance. Yes remains binding after formation; No requires ending without this named partner. Consent that is not yet available may be unknown, letting the holder ask again or save the card.
@@ -497,6 +519,8 @@ export function Truthtrance({
Combine ground moves with AND/OR
Combine current facts and future ground moves
Bind alliance membership at this Nexus close
+ Combine final alliance partners with AND/OR
+ Combine current facts and final alliance partners
Bind ordinary bribe payments this turn
Combine ordinary bribe payments with AND/OR
Combine current facts and ordinary payments
@@ -521,7 +545,21 @@ export function Truthtrance({
)}
- {kind === 'mixedBid' ? (
+ {kind === 'allianceExpression' ? (
+ <>
+
+ {!g.truthAllianceExpressionTargets.includes(target) && Choose a supported participant in a quiet ordinary Nexus.
}
+ {allianceExpressionError ?? truthQuestionText(question, leaderName, locationName, name)}
+ >
+ ) : kind === 'mixedAlliance' ? (
+ <>
+
+ {!g.truthMixedAllianceTargets.includes(target) && Choose a supported participant in a quiet ordinary Nexus.
}
+ {mixedAllianceError ?? truthQuestionText(question, leaderName, locationName, name)}
+ >
+ ) : kind === 'mixedBid' ? (
<>
@@ -723,6 +761,8 @@ export function Truthtrance({
(kind === 'mixedRevival' && (!!mixedRevivalError || !g.truthMixedRevivalTargets.includes(target))) ||
(kind === 'revivalExpression' && (!!revivalExpressionInputError(revivalExpression) || !validRevivalExpressionNames || !g.truthRevivalExpressionTargets.includes(target))) ||
(kind === 'leaderRevival' && (!!leaderRevivalClaimInputError(leaderRevivalClaim, leaderRevivalChoices) || !g.truthLeaderRevivalTargets.includes(target))) ||
+ (kind === 'allianceExpression' && (!!allianceExpressionError || !g.truthAllianceExpressionTargets.includes(target))) ||
+ (kind === 'mixedAlliance' && (!!mixedAllianceError || !g.truthMixedAllianceTargets.includes(target))) ||
(kind === 'alliance' && (!!allianceClaimInputError(allianceClaim, alliancePartners) || !g.truthAllianceTargets.includes(target))) ||
(kind === 'mixedBribe' && (!!mixedBribeError || !g.truthMixedBribeTargets.includes(target))) ||
(kind === 'bribeExpression' && (!!bribeExpressionError || !validBribeExpressionRecipients || !g.truthBribeExpressionTargets.includes(target))) ||
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index d3c56c70..c8161dca 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,42 @@ open mode gates.
## Current checkpoint and work
+**Truthtrance / whole and mixed final Nexus alliances — 26 October. Prototyped / Partial.**
+Full AND/OR partner trees and mixed current facts now retain every compatible
+native consent route. Finite final outcomes reuse the original reciprocal
+quote and financial preparations; OR fixes no partner. Whole No and Yes stay
+binding through actual Nexus close, preserving temporary formation, opposing
+withdrawal/departure and accepted-order release. Scalar/whole/mixed share one
+sequence; earlier scalar behavior stays. Mixed facts freeze before payment,
+constants need no alliance, and all definite answers uniformly reopen only own
+readiness. Full editors/raw Ask/private answers/own aggregates and one exact
+native guide connect to four minimal profiles without projecting residuals.
+
+Root integration with disjoint rules/controls/native lanes passes **24 files,
+226/226**, types/lint/build and **twelve native paths**,42 GameTable SSR renders/
+17 guide callbacks plus one editor callback followed by native Ask. Original
+H/A/E base33 Basic48/Advanced49 dual-invitation prefixes add9 for real OR
+acceptance/Ready/close, or10 with actual bribe3 E10→7 and frozen mixed fact.
+Manual other-partner acceptance stays legal. Existing-pair48 adds9 for whole
+OR No/break/close. Edited constant48 adds11 and reopens only respondent Ready;
+false AND47 adds9 with later real acceptance. Unknown47 adds9/10 for actual Yes
+partnership or temporary No pair/break. Dual withdrawals48 add11: one survives,
+last releases, reoffer does not rebind. Earlier owed bribe48 adds12 with actual
+3 payment/manual alternate/final E7. All33 original cards remain physical.
+
+Sixteen focused helper,28 native/funding and15 controlled cases supplement
+existing scalar/Nexus/funding proof. Two original Truths retain scalar No or
+Yes, owed payments and whole-first contradiction in accepted order. A bounded
+read-only planner/release review finds no concrete issue. V1 catches two native
+profile test fields needing explicit TruthAnswer narrowing; complete v2 passes.
+Proof/manifest:
+`/home/quintine/.local/share/dune/checkpoints/20261026-truth-alliance-expression-proof-v2/`;
+journeys/HTML: adjacent `20261026-truth-alliance-expression-runtime/`.
+No saved-game/database/server/reset/restart/deployment operation is performed.
+Earlier-phase diplomacy, other families/correlated unknowns, further preparations,
+optional/wider compositions and final rules/browser/deployed/assurance remain.
+[Authority, consent/financial model and evidence](TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md).
+
**Truthtrance / current facts and ordinary bids — 26 October. Prototyped / Partial.**
Full mixed AND/OR questions freeze facts before actual current-lot declarations
and winning invoices. Remaining same-interval minima reduce exactly (AND max,
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index d7e2c8a8..3e75d699 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,44 @@
# Dune implementation status
+## 26 October 2026 — Whole and mixed final Nexus alliance partners
+
+**Prototyped / Partial.**
+Full AND/OR partner trees and mixed current facts now retain every compatible
+native consent route. Finite final outcomes reuse the original reciprocal
+quote and financial preparations; OR fixes no partner. Whole No and Yes stay
+binding through actual Nexus close, preserving temporary formation, opposing
+withdrawal/departure and accepted-order release. Scalar/whole/mixed share one
+sequence; earlier scalar behavior stays. Mixed facts freeze before payment,
+constants need no alliance, and all definite answers uniformly reopen only own
+readiness. Full editors/raw Ask/private answers/own aggregates and one exact
+native guide connect to four minimal profiles without projecting residuals.
+
+Root integration with disjoint rules/controls/native lanes passes **24 files,
+226/226**, types/lint/build and **twelve native paths**,42 GameTable SSR renders/
+17 guide callbacks plus one editor callback followed by native Ask. Original
+H/A/E base33 Basic48/Advanced49 dual-invitation prefixes add9 for real OR
+acceptance/Ready/close, or10 with actual bribe3 E10→7 and frozen mixed fact.
+Manual other-partner acceptance stays legal. Existing-pair48 adds9 for whole
+OR No/break/close. Edited constant48 adds11 and reopens only respondent Ready;
+false AND47 adds9 with later real acceptance. Unknown47 adds9/10 for actual Yes
+partnership or temporary No pair/break. Dual withdrawals48 add11: one survives,
+last releases, reoffer does not rebind. Earlier owed bribe48 adds12 with actual
+3 payment/manual alternate/final E7. All33 original cards remain physical.
+
+Sixteen focused helper,28 native/funding and15 controlled cases supplement
+existing scalar/Nexus/funding proof. Two original Truths retain scalar No or
+Yes, owed payments and whole-first contradiction in accepted order. A bounded
+read-only planner/release review finds no concrete issue. V1 catches two native
+profile test fields needing explicit TruthAnswer narrowing; complete v2 passes.
+Proof/manifest:
+`/home/quintine/.local/share/dune/checkpoints/20261026-truth-alliance-expression-proof-v2/`;
+journeys/HTML: adjacent `20261026-truth-alliance-expression-runtime/`.
+No saved-game/database/server/reset/restart/deployment operation is performed.
+Earlier-phase diplomacy, other families/correlated unknowns, further preparations,
+optional/wider compositions and final rules/browser/deployed/assurance remain.
+[Authority, consent/financial model and evidence](TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md).
+
+
## 26 October 2026 — Current facts with ordinary bid declarations
**Prototyped / Partial.**
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 6eb77a68..2f5c9cfc 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,22 @@
# Rule decision index
+## Whole and mixed final Nexus alliances — 26 October 2026
+
+Compose recorded publisher FAQp8 AND/OR/current-turn authority and supplied
+Advanced physical39 compliance/release/unknown with the existing final Nexus
+membership interval. Supplied physical15 retains reciprocal pairing, at most
+one alliance and own departure rights. Evaluate whole partner trees against
+one final partner or None; No negates the complete tree. Enumerate every truth-
+compatible outcome, then reuse original native consent quotes and financial
+preparations without fixing an OR route. Do not invent foreign consent.
+Facts in mixed questions freeze at the answer; unknown is false for definite
+Yes and true for No. Only a needed residual binds the native whole ledger.
+Scalar/whole/mixed answers share accepted order and actual close; decisive
+mixed constants need no alliance, with uniform own readiness reopening.
+This online interval/witness/release model remains provisional; no dedicated
+publisher solver, correlated unknown inference, mode or publication change.
+[Contract and evidence](TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md).
+
## Current facts combined with ordinary bids — 26 October 2026
Reuse recorded publisher FAQp8 current-turn/AND/OR and supplied Advanced
diff --git a/docs/TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md b/docs/TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md
new file mode 100644
index 00000000..ad11d0ea
--- /dev/null
+++ b/docs/TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md
@@ -0,0 +1,151 @@
+# Truthtrance whole and mixed final-alliance questions
+
+## Current-Nexus prototype — 26 October 2026
+
+**Prototyped / Partial.** Pure questions use
+`{kind:'allianceExpression',target,turn,claim}` with partner leaves
+`{kind:'partner',partner}` and full AND/OR groups. Mixed questions use
+`{kind:'mixedAlliance',target,turn,mixed}` with generic fact/action/AND/OR
+clauses and action leaves `{partner}`. Both concern final actual membership
+when this current ordinary Nexus closes, within existing bare native Basic/
+Advanced scope. Start/mode gates stay unchanged.
+
+## Authority and meaning
+
+Reuse the [scalar alliance contract](TRUTHTRANCE_ALLIANCE_PROMISES.md), recorded
+publisher November2020 FAQp8 AND/OR/current-turn authority and supplied Advanced
+physical39/printed37 compliance/release/unknown. Supplied physical15 ALLIANCES
+retains reciprocal formation, at most one alliance and each participant's right
+to leave. The online current-Nexus interval, owned witness and accepted-order
+release remain provisional under standing14October priority; no dedicated
+publisher solver or complete diplomacy certification is claimed.
+
+A pure expression evaluates one final native partner or None. OR retains any
+compatible partner; AND requires every condition. Two distinct final partners
+cannot both be true in the native pair model. No negates the whole tree. A
+proposal alone is not membership. Neither formation nor one false leaf settles
+a binding whole answer early; Yes and No settle at the actual Nexus close.
+A temporary pair may be ended before readiness if whole No remains reachable.
+
+Mixed facts all freeze at the actual answer. A current-spice fact at10 stays
+that answer-time fact after an actual native bribe3 leaves7. Individual frozen
+values and compiled branches remain private. Unknown takes false for definite
+Yes and true for No, then the remaining owned obligation must guarantee the
+aggregate. Correlated unknowns are not inferred. True fact OR partnership can
+settle Yes immediately; false fact AND partnership can settle No immediately.
+Constants need no future alliance binding. Every definite answer uniformly
+reopens only its respondent's readiness, including constants.
+
+## One native consent and financial system
+
+`game/alliance-expression-promises.ts` strictly validates full trees under the
+existing16-leaf/depth4 software budget. Every raw partner must be another current
+participant, including beneath decisive mixed facts. Public frozen constants,
+private source or binding metadata are not accepted. Mixed action parsing reuses
+the scalar partner decoder and shared generic fact grammar.
+
+`allianceExpressionOutcomes` enumerates final partner/None states matching all
+live scalar and whole answers. `findAllianceExpressionPlans` reuses the original
+`findAlliancePromisePlan` for each exact outcome; local synthetic requirements
+are never persisted or projected. No second reciprocal-consent implementation
+exists. Already matching membership/no-op stays preferred; every owned route is
+retained. Another participant's future acceptance, departure or renewed offer is
+not invented. Missing foreign consent can leave No or unknown/save/reask available.
+Known financial impossibility is not relabelled unknown consent.
+
+The engine tries each native route against original financial/preparation
+quotes. A blocked first route cannot pin OR or hide another legal route.
+Earlier owed bribes retain native payment controls before accepting their
+recipient when needed. Shipment, movement, Revival, payments and owned funds
+remain constrained by original rules. Keeping an already matching membership
+adds no prospective foreign cost, preserving the scalar contract.
+
+One `alliancePromiseSequence` spans scalar and whole records; unstamped older
+scalar records retain original priority. Mixed residuals enter the same whole
+ledger. Reconciliation keeps earlier possible commitments, rejects voluntary
+evasion, and preserves opposing withdrawal/departure rights. Removing one OR
+invitation keeps another owned route; removing the last can release Yes. A
+reoffer never reactivates history. Native Ready checks both ledgers and actual
+Nexus closure settles them together. No prior question is rebound to a new Nexus.
+
+## Controls and projection
+
+Pure and mixed editors support full add/wrap/nest/switch/remove/collapse,
+repairable partner drafts, unique IDs, nested facts and the complete raw Ask
+preview. Choices depend only on the public current roster, not private hands,
+funding or future consent. Current consent affects server completion, not which
+questions the author can write.
+
+Pure own rows omit physical Nexus source. Own mixed rows retain original tree,
+aggregate answer and terminal flags while hiding binding metadata, frozen facts
+and the residual; pure projection excludes mixed events. No progress count is
+needed: actual membership is visible, and binding outcomes settle at close.
+A shared exact event/turn/context native guide serves scalar, pure or mixed
+records once. Historical rows stay passive. The client checks visible scalar/
+pure membership and canonical own quotes; it never reconstructs a mixed branch.
+Existing bribe guides handle prior payments. Four minimal profiles answer and
+execute legal acceptance/break/Ready; full strategy/calibration remain deferred.
+New question discard signatures include native/mixed metadata without changing
+older signatures. No saved game or prior answer is rewritten.
+
+## Evidence and remaining gates
+
+Pure: `tests/alliance-expression-promises.test.ts`,
+`tests/mixed-alliance-question.test.ts`. Original native source/journeys:
+`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`.
+Controlled: `tests/truth-alliance-expression-controls.test.tsx`,
+`tests/truth-mixed-alliance-controls.test.tsx`.
+Twelve pure expression and four mixed helper cases,14 pure native,10 mixed native,
+four original accepted-order/funding and15 controlled cases supplement the
+existing scalar/Nexus/financial coverage. The frozen **24-file,226/226** union,
+types/lint, production build and12 native paths pass on the same unchanged
+repository source. Actual GameTable SSR renders42 times;17 exact native guide
+callbacks and one editor callback submit real packets. Four minimal profiles
+actually answer, accept and Ready through the original closure. A bounded
+read-only review of the new planner/accepted-order/release integration finds no
+concrete issue; comprehensive review remains deferred.
+
+| Original source | Prefix actions | Further actions | Actual result |
+| --- | ---: | ---: | --- |
+| Basic whole OR | 48 | 9 | Two actual invitations, canonical acceptance/Ready, settlement only at close |
+| Advanced whole OR | 49 | 9 | Same native final interval and consent rights |
+| Basic manual OR alternative | 48 | 9 | Other real invitation accepted without following the suggested partner |
+| Basic whole OR No | 48 | 9 | Actual existing pair breaks; No stays pending until close |
+| Basic mixed frozen spice | 48 | 10 | Actual bribe3 leaves E7; original E10 fact binds through acceptance/close |
+| Advanced mixed frozen spice | 49 | 10 | Same snapshot and real native payment/membership |
+| Basic edited constant OR | 48 | 11 | Actual AND→OR editor/Ask; only respondent Ready reopens; later pair legal |
+| Basic false AND | 47 | 9 | Immediate No, no binding, real later acceptance remains legal |
+| Basic unknown OR Yes | 47 | 9 | Unknown forecast requires actual partnership and close |
+| Basic unknown AND No | 47 | 10 | Temporary pair allowed, actual break/Ready required, no early settlement |
+| Basic two withdrawals | 48 | 11 | First leaves OR possible; last releases; reoffer does not reactivate |
+| Basic earlier owed bribe | 48 | 12 | Actual promised3 payment, manual other OR partner, whole close at E7 |
+
+Other native cases preserve unknown/save/reask after real consent, exact
+owner/turn/context/event/JSON packets, private source/raw aggregate boundaries,
+No to distinct-partner AND and actual opposing departure. Two original Truths
+keep prior scalar No with the other whole-OR route, share one scalar/mixed Yes
+at close, retain the earlier bribe snapshot, and reject a later scalar No
+contradicting an earlier whole Yes. All33 original cards remain physical.
+
+V1 typecheck identifies two test action fields needing explicit TruthAnswer
+narrowing. No rule expectations change; the complete v2 batch passes. Verified
+source tree: `1c2a3a627ebf95e40f7b603b112416cb74a0cf89d0aa338aeddd0e05cb640009`
+(3194 files). Final evidence prose is a separate three-document overlay.
+Proof/report/manifest: `/home/quintine/.local/share/dune/checkpoints/20261026-truth-alliance-expression-proof-v2/`;
+journeys/HTML: `/home/quintine/.local/share/dune/checkpoints/20261026-truth-alliance-expression-runtime/`.
+SSR/editor/guide callbacks are local engine/control evidence, not DOM,
+hydration, browser/mobile or deployed acceptance.
+
+The inherited H/A/E base33 source earns its original Worm Nexus and consent;
+one optional second invitation is an actual H action. Only raw questions change;
+no played purse/hand/clock/offer/pair/answer/promise is installed. Controlled
+views retain their labels rather than claiming acquisition histories.
+
+Earlier-phase/whole-turn diplomacy, other action families, correlated unknowns,
+future preparations, optional modules and broader faction/card/funding
+composition remain required, followed by final rules/recovery/privacy/custody/
+browser/mobile/deployed acceptance. No complete mode is certified. No saved-game/
+database/server/reset/restart/deployment operation is performed by this batch.
diff --git a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md
index 302ab34e..335092d3 100644
--- a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md
+++ b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md
@@ -1,5 +1,10 @@
# Truthtrance nonbattle readiness and first implementation slice
+The [whole/mixed final-alliance follow-up](TRUTHTRANCE_ALLIANCE_EXPRESSIONS.md)
+adds full AND/OR partner conditions and answer-time facts at the current ordinary
+Nexus close. Actual native consent, final membership and financial preparation
+remain; broader diplomacy and cross-action combinations remain unfinished.
+
The [mixed current-lot bid follow-up](TRUTHTRANCE_MIXED_BIDS.md) composes
answer-time facts with future accepted ordinary declarations. Native scalar
thresholds exactly represent remaining same-lot AND/OR minima; phase-wide and
diff --git a/game/alliance-expression-promises.ts b/game/alliance-expression-promises.ts
new file mode 100644
index 00000000..2494538e
--- /dev/null
+++ b/game/alliance-expression-promises.ts
@@ -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>;
+};
+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 {
+ 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, 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([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();
+ 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();
+ 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);
+ }
+}
diff --git a/game/bots.ts b/game/bots.ts
index a176eadb..ea8a1899 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -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 }] : [];
diff --git a/game/engine.ts b/game/engine.ts
index bbb57608..4f3fdbff 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -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(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): 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) : [],
diff --git a/game/mixed-alliance-question.ts b/game/mixed-alliance-question.ts
new file mode 100644
index 00000000..11ef6f71
--- /dev/null
+++ b/game/mixed-alliance-question.ts
@@ -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 };
+
+/** 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).partner });
+ return { partner };
+}
diff --git a/game/reference.ts b/game/reference.ts
index 0809d275..a425571d 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -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',
diff --git a/game/truthtrance.ts b/game/truthtrance.ts
index 5b28741d..06cbf803 100644
--- a/game/truthtrance.ts
+++ b/game/truthtrance.ts
@@ -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 };
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.');
diff --git a/tests/alliance-expression-promises.test.ts b/tests/alliance-expression-promises.test.ts
new file mode 100644
index 00000000..0aa8841d
--- /dev/null
+++ b/tests/alliance-expression-promises.test.ts
@@ -0,0 +1,212 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ ALLIANCE_EXPRESSION_MAX_DEPTH, ALLIANCE_EXPRESSION_MAX_LEAVES,
+ AllianceExpressionPromiseError, allianceExpressionOutcomes, allianceExpressionPromisesMatch,
+ allianceExpressionText, evaluateAllianceExpression, findAllianceExpressionPlans,
+ liveAllianceExpressionPromises, parseAllianceExpression, parseAllianceExpressionClaim,
+ validateAllianceExpressionPromises, type AllianceAction, type AllianceExpression,
+ type AllianceExpressionContext, type AllianceExpressionPromise,
+} from '../game/alliance-expression-promises';
+import { findAlliancePromisePlan, type AllianceOpportunity, type AlliancePromise } from '../game/alliance-promises';
+import { quoteNexusAlliance } from '../game/nexus-alliance';
+
+const seats = ['respondent', 'partner-a', 'partner-b', 'partner-c', 'asker'];
+const opportunity = (): AllianceOpportunity => ({ turn: 2, source: 'original-worm-nexus' });
+const leaf = (partner: string): AllianceExpression => ({ kind: 'partner', partner });
+const group = (kind: 'and' | 'or', ...terms: AllianceExpression[]): AllianceExpression => ({ kind, terms });
+const either = () => group('or', leaf('partner-a'), leaf('partner-b'));
+function promise(event: string, claim = either(), extra: Partial = {}): AllianceExpressionPromise {
+ return { event, turn: 2, source: 'original-worm-nexus', player: 'respondent', asker: 'asker', claim, answer: true, ...extra };
+}
+function scalar(event: string, partner: string, answer: boolean): AlliancePromise {
+ return { event, turn: 2, source: 'original-worm-nexus', player: 'respondent', asker: 'asker', partner, answer };
+}
+function context(): AllianceExpressionContext {
+ return { players: seats.map(id => ({ id, ally: null })), offers: {} };
+}
+function action(state: AllianceExpressionContext, actor: string, target: string | null): AllianceExpressionContext {
+ const quoted = quoteNexusAlliance({ ...state, actor, target });
+ return { players: state.players.map(seat => ({ ...seat, ally: quoted.allies[seat.id] })), offers: quoted.offers };
+}
+function invitations(): AllianceExpressionContext {
+ return action(action(context(), 'partner-a', 'respondent'), 'partner-b', 'respondent');
+}
+function execute(state: AllianceExpressionContext, plan: AllianceAction[]): AllianceExpressionContext {
+ return plan.reduce((current, step) => action(current, 'respondent', step.target), state);
+}
+const finalAlly = (state: AllianceExpressionContext) => state.players.find(seat => seat.id === 'respondent')!.ally;
+function freeze(value: unknown): void {
+ if (!value || typeof value !== 'object') return;
+ for (const child of Object.values(value as Record)) freeze(child);
+ Object.freeze(value);
+}
+
+void test('alliance expression claims retain full nesting and exact public partner text without private metadata', () => {
+ const claim = { turn: 2, claim: group('and', either(), leaf('partner-a')) };
+ const parsed = parseAllianceExpressionClaim(claim);
+ assert.deepEqual(parsed, claim); assert.notEqual(parsed.claim, claim.claim);
+ assert.equal(allianceExpressionText(parsed.claim, id => id === 'partner-a' ? 'Atreides' : 'Emperor'),
+ '((be allied with Atreides when this Nexus ends OR be allied with Emperor when this Nexus ends) AND be allied with Atreides when this Nexus ends)');
+ for (const turn of [0, -1, 1.5, Infinity, NaN, Number.MAX_SAFE_INTEGER + 1, '2', null])
+ assert.throws(() => parseAllianceExpressionClaim({ ...claim, turn }), AllianceExpressionPromiseError);
+ for (const extra of [{ source: 'private-source' }, { event: 'private-event' }, { answer: true }])
+ assert.throws(() => parseAllianceExpressionClaim({ ...claim, ...extra }), AllianceExpressionPromiseError);
+});
+
+void test('full alliance parsing enforces all branches, two-child groups, sixteen leaves and four grouping levels', () => {
+ assert.equal(ALLIANCE_EXPRESSION_MAX_LEAVES, 16); assert.equal(ALLIANCE_EXPRESSION_MAX_DEPTH, 4);
+ const nested = (depth: number): AllianceExpression => depth === 0 ? leaf('partner-a') :
+ group('and', nested(depth - 1), leaf('partner-b'));
+ assert.deepEqual(parseAllianceExpression(nested(4)), nested(4));
+ assert.deepEqual(parseAllianceExpression(group('or', ...Array.from({ length: 16 }, () => leaf('partner-a')))),
+ group('or', ...Array.from({ length: 16 }, () => leaf('partner-a'))));
+ const invalid: unknown[] = [null, [], {}, { kind: 'partner', partner: '' }, { kind: 'partner', partner: ' partner-a' },
+ { kind: 'partner', partner: 'partner-a ' }, { kind: 'partner', partner: null },
+ { kind: 'partner', partner: 'partner-a', source: 'private' }, { kind: 'and', terms: [] },
+ { kind: 'or', terms: [leaf('partner-a')] }, { kind: 'not', terms: [leaf('partner-a'), leaf('partner-b')] },
+ { kind: 'or', terms: [leaf('partner-a'), { kind: 'partner', partner: 2 }] },
+ group('or', ...Array.from({ length: 17 }, () => leaf('partner-a'))), nested(5),
+ group('and', ...Array.from({ length: 9 }, () => either()))];
+ for (const value of invalid) assert.throws(() => parseAllianceExpression(value), AllianceExpressionPromiseError);
+ const hiddenInvalid = { kind: 'or', terms: [leaf('partner-a'), { kind: 'partner', partner: '' }] } as AllianceExpression;
+ assert.throws(() => evaluateAllianceExpression(hiddenInvalid, 'partner-a'), AllianceExpressionPromiseError);
+});
+
+void test('positive final truth uses one native partner and a whole No negates the whole nested question', () => {
+ const both = group('and', leaf('partner-a'), leaf('partner-b'));
+ for (const ally of [null, 'partner-a', 'partner-b', 'partner-c']) {
+ assert.equal(evaluateAllianceExpression(both, ally), false);
+ assert.equal(allianceExpressionPromisesMatch([promise('no-both', both, { answer: false })], 'respondent', opportunity(), ally), true);
+ assert.equal(evaluateAllianceExpression(either(), ally), ally === 'partner-a' || ally === 'partner-b');
+ }
+ const nested = group('and', either(), group('or', leaf('partner-b'), leaf('partner-c')));
+ assert.equal(evaluateAllianceExpression(nested, 'partner-a'), false);
+ assert.equal(evaluateAllianceExpression(nested, 'partner-b'), true);
+ assert.equal(evaluateAllianceExpression(group('and', leaf('partner-a'), leaf('partner-a')), 'partner-a'), true);
+});
+
+void test('truth-compatible outcomes keep current membership first and intersect scalar and whole answers', () => {
+ const state = action(invitations(), 'respondent', 'partner-b');
+ const records = [promise('either')];
+ assert.deepEqual(allianceExpressionOutcomes([], records, 'respondent', opportunity(), state), ['partner-b', 'partner-a']);
+ assert.deepEqual(allianceExpressionOutcomes([scalar('no-b', 'partner-b', false)], records, 'respondent', opportunity(), state), ['partner-a']);
+ assert.deepEqual(allianceExpressionOutcomes([], [promise('whole-no', either(), { answer: false })],
+ 'respondent', opportunity(), context()), [null, 'partner-c', 'asker']);
+ assert.deepEqual(allianceExpressionOutcomes([], records, 'absent', opportunity(), state), []);
+});
+
+void test('two actual incoming invitations produce both canonical owned OR routes and leave manual alternatives available', () => {
+ const state = invitations(); const records = [promise('either')];
+ const plans = findAllianceExpressionPlans([], records, 'respondent', opportunity(), state);
+ assert.deepEqual(plans, [[{ type: 'alliance', target: 'partner-a' }], [{ type: 'alliance', target: 'partner-b' }]]);
+ for (const plan of plans) {
+ const completed = execute(state, plan);
+ assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(completed)), true);
+ const selected = finalAlly(completed)!;
+ assert.equal(completed.players.find(seat => seat.id === selected)!.ally, 'respondent');
+ }
+ const manual = action(state, 'respondent', 'partner-b');
+ assert.equal(finalAlly(manual), 'partner-b');
+ assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(manual)), true);
+ assert.equal(records[0].fulfilled, undefined, 'No branch is stored or fulfilled before actual Nexus close');
+});
+
+void test('missing, own, withdrawn or occupied-partner offers cannot invent foreign consent', () => {
+ const records = [promise('either')];
+ assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), context()), []);
+ const ownOffer = action(context(), 'respondent', 'partner-a');
+ assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), ownOffer), []);
+ const oneWithdrawn = action(invitations(), 'partner-a', null);
+ assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), oneWithdrawn),
+ [[{ type: 'alliance', target: 'partner-b' }]]);
+ const bothWithdrawn = action(oneWithdrawn, 'partner-b', null);
+ assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), bothWithdrawn), []);
+ const occupied = action(action(action(context(), 'partner-a', 'respondent'), 'partner-c', 'partner-a'), 'partner-a', 'partner-c');
+ assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), occupied), []);
+});
+
+void test('whole OR No can require exactly None while allowing an interim pair until the owned break', () => {
+ const anyPartner = group('or', ...seats.filter(id => id !== 'respondent').map(leaf));
+ const records = [promise('no-one', anyPartner, { answer: false })];
+ const paired = action(invitations(), 'respondent', 'partner-a');
+ assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(paired)), false);
+ assert.deepEqual(allianceExpressionOutcomes([], records, 'respondent', opportunity(), paired), [null]);
+ const plans = findAllianceExpressionPlans([], records, 'respondent', opportunity(), paired);
+ assert.deepEqual(plans, [[{ type: 'alliance', target: null }]]);
+ assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(execute(paired, plans[0]))), true);
+ assert.equal(records[0].fulfilled, undefined); assert.equal(records[0].released, undefined);
+});
+
+void test('whole AND No retains a matching held pair and all other currently owned final routes', () => {
+ const state = action(invitations(), 'respondent', 'partner-a');
+ const records = [promise('not-both', group('and', leaf('partner-a'), leaf('partner-b')), { answer: false })];
+ const plans = findAllianceExpressionPlans([], records, 'respondent', opportunity(), state);
+ assert.deepEqual(plans, [[], [{ type: 'alliance', target: null }],
+ [{ type: 'alliance', target: null }, { type: 'alliance', target: 'partner-b' }]]);
+ assert.deepEqual(allianceExpressionOutcomes([], records, 'respondent', opportunity(), state),
+ ['partner-a', null, 'partner-b', 'partner-c', 'asker']);
+ for (const plan of plans) assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(execute(state, plan))), true);
+});
+
+void test('joint scalar and whole-expression contradictions have no outcome or invented branch', () => {
+ const state = invitations();
+ const cases = [
+ { scalars: [scalar('yes-a', 'partner-a', true)], expressions: [promise('no-either', either(), { answer: false })] },
+ { scalars: [scalar('no-a', 'partner-a', false)], expressions: [promise('yes-a-twice', group('and', leaf('partner-a'), leaf('partner-a')))] },
+ { scalars: [], expressions: [promise('yes-both', group('and', leaf('partner-a'), leaf('partner-b')))] },
+ { scalars: [], expressions: [promise('yes-either'), promise('no-either', either(), { answer: false })] },
+ ];
+ for (const selected of cases) {
+ assert.deepEqual(allianceExpressionOutcomes(selected.scalars, selected.expressions, 'respondent', opportunity(), state), []);
+ assert.deepEqual(findAllianceExpressionPlans(selected.scalars, selected.expressions, 'respondent', opportunity(), state), []);
+ }
+});
+
+void test('no live expression preserves the original scalar planner exactly, including no-op and absent-owner behavior', () => {
+ const old = [promise('old', either(), { source: 'older-nexus' }), promise('released', either(), { released: true })];
+ for (const state of [context(), invitations(), action(invitations(), 'respondent', 'partner-a')])
+ for (const records of [[], [scalar('yes-a', 'partner-a', true)], [scalar('no-a', 'partner-a', false)]]) {
+ const expected = findAlliancePromisePlan(records, 'respondent', opportunity(), state);
+ assert.deepEqual(findAllianceExpressionPlans(records, old, 'respondent', opportunity(), state), expected === null ? [] : [expected]);
+ }
+ assert.deepEqual(findAllianceExpressionPlans([], [], 'absent', opportunity(), context()), []);
+});
+
+void test('original turn, Nexus source, owner and terminal state isolate the live whole-expression records', () => {
+ const live = promise('live');
+ const records = [live, promise('old-turn', leaf('partner-c'), { turn: 1 }),
+ promise('other-source', leaf('partner-c'), { source: 'other-worm-same-turn' }),
+ promise('other-owner', leaf('partner-c'), { player: 'partner-a' }),
+ promise('fulfilled', leaf('partner-c'), { fulfilled: true }), promise('released', leaf('partner-c'), { released: true })];
+ assert.deepEqual(liveAllianceExpressionPromises(records, 'respondent', opportunity()), [live]);
+ assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), 'partner-a'), true);
+ assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), 'partner-c'), false);
+ assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), invitations()),
+ [[{ type: 'alliance', target: 'partner-a' }], [{ type: 'alliance', target: 'partner-b' }]]);
+});
+
+void test('expression records validate source, unique event, every personal partner and terminal flags without mutating inputs', () => {
+ const records = [promise('live'), promise('done', leaf('partner-a'), { fulfilled: true }),
+ promise('released-no', leaf('asker'), { answer: false, released: true })];
+ const state = invitations(), scalars = [scalar('no-c', 'partner-c', false)];
+ const before = structuredClone({ records, state, scalars });
+ freeze(records); freeze(state); freeze(scalars);
+ assert.doesNotThrow(() => validateAllianceExpressionPromises(records, seats));
+ assert.doesNotThrow(() => validateAllianceExpressionPromises(JSON.parse(JSON.stringify(records)) as AllianceExpressionPromise[], seats));
+ parseAllianceExpressionClaim({ turn: 2, claim: records[0].claim });
+ allianceExpressionText(records[0].claim); evaluateAllianceExpression(records[0].claim, 'partner-a');
+ allianceExpressionOutcomes(scalars, records, 'respondent', opportunity(), state);
+ findAllianceExpressionPlans(scalars, records, 'respondent', opportunity(), state);
+ assert.deepEqual({ records, state, scalars }, before);
+ const base = promise('event');
+ const invalid: unknown[] = [null, {}, [null], [base, { ...base }],
+ ...[{ event: '' }, { event: ' event' }, { source: '' }, { source: ' source' },
+ { player: 'missing' }, { asker: 'missing' }, { asker: 'respondent' }, { answer: 'yes' },
+ { turn: 0 }, { fulfilled: 1 }, { released: 'true' }, { fulfilled: true, released: true },
+ { claim: group('or', leaf('partner-a'), leaf('missing')) },
+ { claim: group('or', leaf('partner-a'), leaf('respondent')) }].map(extra => [{ ...base, ...extra }])];
+ for (const value of invalid)
+ assert.throws(() => validateAllianceExpressionPromises(value as AllianceExpressionPromise[], seats), AllianceExpressionPromiseError);
+ assert.deepEqual({ records, state, scalars }, before);
+});
diff --git a/tests/fixture-truth-alliance-expression.ts b/tests/fixture-truth-alliance-expression.ts
new file mode 100644
index 00000000..c7d75b5f
--- /dev/null
+++ b/tests/fixture-truth-alliance-expression.ts
@@ -0,0 +1,142 @@
+import assert from 'node:assert/strict';
+import { viewGame, type Action, type Game } from '../game/engine';
+import type { AllianceExpression } from '../game/alliance-expression-promises';
+import type { AllianceActionClaim } from '../game/mixed-alliance-question';
+import type { MixedActionExpression } from '../game/mixed-action-question';
+import type { TruthFact } from '../game/truthtrance';
+import {
+ actTruthAlliance, advanceTruthAlliance, answerTruthAlliance, closeTruthAllianceNexus,
+ createTruthAllianceFixture, declareTruthAlliance, finishTruthAlliance, nextTruthAlliance,
+ settleTruthAlliance, truthAllianceClean, truthAllianceInventory, truthAllianceNexus,
+ truthAlliancePlayer, type TruthAllianceStep,
+} from './fixture-truth-alliance';
+
+export type TruthAllianceExpressionStep = TruthAllianceStep;
+export type TruthMixedAllianceExpression = MixedActionExpression;
+export const actTruthAllianceExpression = actTruthAlliance;
+export const advanceTruthAllianceExpression = advanceTruthAlliance;
+export const answerTruthAllianceExpression = answerTruthAlliance;
+export const closeTruthAllianceExpressionNexus = closeTruthAllianceNexus;
+export const finishTruthAllianceExpression = finishTruthAlliance;
+export const nextTruthAllianceExpression = nextTruthAlliance;
+export const settleTruthAllianceExpression = settleTruthAlliance;
+export const truthAllianceExpressionClean = truthAllianceClean;
+export const truthAllianceExpressionInventory = truthAllianceInventory;
+export const truthAllianceExpressionNexus = truthAllianceNexus;
+export const truthAllianceExpressionPlayer = truthAlliancePlayer;
+export const actTruthMixedAlliance = actTruthAlliance;
+export const advanceTruthMixedAlliance = advanceTruthAlliance;
+export const answerTruthMixedAlliance = answerTruthAlliance;
+export const closeTruthMixedAllianceNexus = closeTruthAllianceNexus;
+export const finishTruthMixedAlliance = finishTruthAlliance;
+export const nextTruthMixedAlliance = nextTruthAlliance;
+export const settleTruthMixedAlliance = settleTruthAlliance;
+export const truthMixedAllianceClean = truthAllianceClean;
+export const truthMixedAllianceInventory = truthAllianceInventory;
+export const truthMixedAllianceNexus = truthAllianceNexus;
+export const truthMixedAlliancePlayer = truthAlliancePlayer;
+
+export const truthAllianceExpressionPartner = (partner: string): AllianceExpression => ({ kind: 'partner', partner });
+export const truthAllianceExpressionGroup = (kind: 'and' | 'or', ...terms: AllianceExpression[]): AllianceExpression =>
+ ({ kind, terms });
+export const truthMixedAllianceFact = (fact: TruthFact): TruthMixedAllianceExpression => ({ kind: 'fact', fact });
+export const truthMixedAlliancePartner = (partner: string): TruthMixedAllianceExpression =>
+ ({ kind: 'action', action: { partner } });
+export const truthMixedAllianceGroup = (kind: 'and' | 'or', ...terms: TruthMixedAllianceExpression[]): TruthMixedAllianceExpression =>
+ ({ kind, terms });
+
+type SourceOptions = Parameters[0] & { secondOffer?: boolean };
+
+/** Thin original H/A/E base33 adapter. The optional second invitation is an
+ * actual Harkonnen action after the original Worm and native Atreides offer.
+ * No played hand, purse, offer, alliance, promise or answer-time fact is set. */
+export function createTruthAllianceExpressionFixture(options: SourceOptions & { claim?: AllianceExpression } = {}) {
+ const { claim: suppliedClaim, secondOffer = false, ...sourceOptions } = options;
+ const base = createTruthAllianceFixture(sourceOptions);
+ const actions = structuredClone(base.sourceActions);
+ let game = base.game;
+ let secondInvitation: { before: Game; steps: TruthAllianceStep[]; after: Game } | null = null;
+ if (secondOffer) {
+ const before = structuredClone(game), from = actions.length;
+ game = actTruthAlliance(game, base.asker, { type: 'alliance', target: base.target }, actions);
+ assert.equal(game.allianceOffers[base.asker], base.target);
+ secondInvitation = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
+ }
+ const claim = structuredClone(suppliedClaim ?? truthAllianceExpressionGroup('or',
+ truthAllianceExpressionPartner(base.asker), truthAllianceExpressionPartner(base.opponent)));
+ const question = { kind: 'allianceExpression' as const, target: base.target, turn: game.turn, claim };
+ assert.deepEqual(truthAllianceInventory(game), base.originalCards);
+ return { ...base, game, sourceGame: structuredClone(game), allianceSource: base, claim, question,
+ questionAction: { type: 'truthAsk', question } satisfies Action,
+ secondOffer, secondInvitation, actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
+ sourceCounts: { inherited: base.sourceActionCount, additionalInvitations: Number(secondOffer) },
+ sourceDisclosure: `${base.sourceDisclosure} This adapter changes only the raw final-partner expression and, if requested, adds one real Harkonnen invitation. It installs no aggregate or compiled branch.` };
+}
+export type TruthAllianceExpressionFixture = ReturnType;
+
+export function declareTruthAllianceExpression(f: TruthAllianceExpressionFixture, source = f.game,
+ options: { copies?: 1 | 2 } = {}) {
+ return declareTruthAlliance(f.allianceSource, source, options);
+}
+export function askTruthAllianceExpression(game: Game, asker: string, target: string, claim: AllianceExpression,
+ actions: TruthAllianceStep[] = []): Game {
+ return actTruthAlliance(game, asker, { type: 'truthAsk', question: {
+ kind: 'allianceExpression', target, turn: game.turn, claim: structuredClone(claim),
+ } }, actions);
+}
+export function bindTruthAllianceExpression(f: TruthAllianceExpressionFixture, answer: 'yes' | 'no' = 'yes',
+ claim = f.claim, source = f.game) {
+ const declared = declareTruthAllianceExpression(f, source), actions = [...declared.actions];
+ const asked = askTruthAllianceExpression(declared.game, f.asker, f.target, claim, actions);
+ const game = answerTruthAlliance(asked, f.target, answer, actions);
+ return { game, declared, asked, actions, actionCount: actions.length };
+}
+export function truthAllianceExpressionPromise(game: Game,
+ target = game.truthHistory?.at(-1)?.question.target ?? game.players[0].id) {
+ const row = viewGame(game, target).allianceExpressionPromises.find(row => row.player === target);
+ assert.ok(row, 'The respondent retains the original whole final-partner question and aggregate');
+ return row;
+}
+
+export function createTruthMixedAllianceFixture(options: SourceOptions & { mixed?: TruthMixedAllianceExpression } = {}) {
+ const { mixed: suppliedMixed, ...sourceOptions } = options;
+ const base = createTruthAllianceExpressionFixture(sourceOptions);
+ const shield = base.setup.deck.find(card => card.kind === 'shield');
+ const hajr = base.setup.deck.find(card => card.effect === 'hajr');
+ assert.ok(shield && hajr, 'Original unopened base33 supplies printed current-fact identities');
+ const shieldFact: TruthFact = { kind: 'hand', name: shield.name };
+ const hajrFact: TruthFact = { kind: 'hand', name: hajr.name };
+ const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: 10 };
+ const forecastFact: TruthFact = { kind: 'stormForecast', compare: 'gte', value: 1 };
+ const mixed = structuredClone(suppliedMixed ?? truthMixedAllianceGroup('and', truthMixedAllianceFact(spiceFact),
+ truthMixedAllianceGroup('or', truthMixedAlliancePartner(base.asker), truthMixedAlliancePartner(base.opponent))));
+ const question = { kind: 'mixedAlliance' as const, target: base.target, turn: base.game.turn, mixed };
+ return { ...base, expressionSource: base, mixed, question, shieldFact, hajrFact, spiceFact, forecastFact,
+ questionAction: { type: 'truthAsk', question } satisfies Action,
+ sourceDisclosure: `${base.sourceDisclosure} Current facts and final actual partnership share one raw AND/OR question. Facts and residual clauses are never staged.` };
+}
+export type TruthMixedAllianceFixture = ReturnType;
+
+export function declareTruthMixedAlliance(f: TruthMixedAllianceFixture, source = f.game,
+ options: { copies?: 1 | 2 } = {}) {
+ return declareTruthAllianceExpression(f.expressionSource, source, options);
+}
+export function askTruthMixedAlliance(game: Game, asker: string, target: string, mixed: TruthMixedAllianceExpression,
+ actions: TruthAllianceStep[] = []): Game {
+ return actTruthAlliance(game, asker, { type: 'truthAsk', question: {
+ kind: 'mixedAlliance', target, turn: game.turn, mixed: structuredClone(mixed),
+ } }, actions);
+}
+export function bindTruthMixedAlliance(f: TruthMixedAllianceFixture, answer: 'yes' | 'no' = 'yes',
+ mixed = f.mixed, source = f.game) {
+ const declared = declareTruthMixedAlliance(f, source), actions = [...declared.actions];
+ const asked = askTruthMixedAlliance(declared.game, f.asker, f.target, mixed, actions);
+ const game = answerTruthAlliance(asked, f.target, answer, actions);
+ return { game, declared, asked, actions, actionCount: actions.length };
+}
+export function truthMixedAlliancePromise(game: Game,
+ target = game.truthHistory?.at(-1)?.question.target ?? game.players[0].id) {
+ const row = viewGame(game, target).mixedAlliancePromises.find(row => row.player === target);
+ assert.ok(row, 'The respondent retains the original mixed question and whole-answer aggregate');
+ return row;
+}
diff --git a/tests/mixed-alliance-question.test.ts b/tests/mixed-alliance-question.test.ts
new file mode 100644
index 00000000..f53dfbb0
--- /dev/null
+++ b/tests/mixed-alliance-question.test.ts
@@ -0,0 +1,51 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { AlliancePromiseError } from '../game/alliance-promises';
+import { parseAllianceActionClaim, type AllianceActionClaim, type MixedAllianceClaim } from '../game/mixed-alliance-question';
+import { compileMixedActionExpression, parseMixedActionExpression } from '../game/mixed-action-question';
+import type { TruthFact } from '../game/truthtrance';
+
+void test('mixed alliance action leaves retain one exact partner without adding turn or private Nexus bindings', () => {
+ const input = Object.freeze({ partner: 'exact-partner-id' });
+ const parsed = parseAllianceActionClaim(input);
+ assert.deepEqual(parsed, input); assert.notEqual(parsed, input);
+ assert.deepEqual(parseAllianceActionClaim({ partner: 'asker-can-be-the-partner' }), { partner: 'asker-can-be-the-partner' });
+});
+
+void test('mixed alliance action leaves reject metadata, frozen values, groups and missing or inexact identities', () => {
+ const invalid: unknown[] = [null, undefined, [], 'partner-a', {}, { partner: '' }, { partner: ' ' },
+ { partner: ' partner-a' }, { partner: 'partner-a ' }, { partner: 2 }, { partner: null },
+ { partner: 'partner-a', turn: 1 }, { partner: 'partner-a', source: 'private' },
+ { partner: 'partner-a', event: 'private' }, { partner: 'partner-a', answer: true },
+ { partner: 'partner-a', fulfilled: false }, { kind: 'partner', partner: 'partner-a' },
+ { kind: 'or', terms: [{ partner: 'partner-a' }, { partner: 'partner-b' }] }];
+ for (const value of invalid) assert.throws(() => parseAllianceActionClaim(value), AlliancePromiseError);
+});
+
+void test('shared mixed parsing rejects a malformed late alliance leaf before a decisive fact can hide it', () => {
+ const fact: TruthFact = { kind: 'spice', compare: 'gte', value: 10 };
+ const parseFact = (): TruthFact => fact;
+ const input = { kind: 'or', terms: [{ kind: 'fact', fact },
+ { kind: 'action', action: { partner: 'partner-a' } }, { kind: 'action', action: { partner: 'partner-b', turn: 1 } }] };
+ assert.throws(() => parseMixedActionExpression(input, parseFact, parseAllianceActionClaim), AlliancePromiseError);
+ const valid = { kind: 'and', terms: [{ kind: 'fact', fact }, { kind: 'or', terms: [
+ { kind: 'action', action: { partner: 'partner-a' } }, { kind: 'action', action: { partner: 'partner-b' } }] }] };
+ const parsed = parseMixedActionExpression(valid, parseFact, parseAllianceActionClaim);
+ assert.deepEqual(parsed, valid);
+ assert.deepEqual(compileMixedActionExpression(parsed, () => 'yes', true), valid.terms[1]);
+});
+
+void test('frozen current facts reduce only the residual final-partner expression and preserve raw mixed text', () => {
+ const question: MixedAllianceClaim = { turn: 2, mixed: { kind: 'or', terms: [
+ { kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 10 } },
+ { kind: 'and', terms: [{ kind: 'action', action: { partner: 'partner-a' } },
+ { kind: 'action', action: { partner: 'partner-b' } }] },
+ ] } };
+ const before = structuredClone(question);
+ assert.deepEqual(compileMixedActionExpression(question.mixed, () => 'yes', true),
+ { kind: 'constant', value: true });
+ assert.deepEqual(compileMixedActionExpression(question.mixed, () => 'no', false),
+ { kind: 'and', terms: [{ kind: 'action', action: { partner: 'partner-a' } },
+ { kind: 'action', action: { partner: 'partner-b' } }] });
+ assert.deepEqual(question, before);
+});
diff --git a/tests/truth-alliance-expression-controls.test.tsx b/tests/truth-alliance-expression-controls.test.tsx
new file mode 100644
index 00000000..9b989f62
--- /dev/null
+++ b/tests/truth-alliance-expression-controls.test.tsx
@@ -0,0 +1,229 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { AllianceExpressionFields, allianceExpressionInputError } from '../components/alliance-expression-fields';
+import { AllianceExpressionPromises } from '../components/alliance-expression-promises';
+import { AlliancePromises, AlliancePromiseGuideButton } from '../components/alliance-promises';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
+import { parseAllianceExpression, type AllianceExpression } from '../game/alliance-expression-promises';
+import { createTechTokens } from '../game/tech-tokens';
+
+type ElementProps = { children?: ReactNode; [key: string]: unknown };
+type Element = ReactElement;
+const partners = [{ id: 'r', name: 'Partner' }, { id: 'a', name: 'Asker' }];
+const partner = (id = 'r'): AllianceExpression => ({ kind: 'partner', partner: id });
+function elements(node: ReactNode): Element[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!isValidElement(node)) return [];
+ return [node, ...elements(node.props.children)];
+}
+function find(tree: ReactNode, key: string, value: string): Element {
+ const found = elements(tree).find(element => element.props[key] === value); assert.ok(found, `${key}=${value}`); return found;
+}
+const control = (tree: ReactNode, key: string) => find(tree, 'data-alliance-expression-control', key);
+const click = (button: Element) => (button.props.onClick as () => void)();
+function select(tree: ReactNode, path: string, field: string, value: string) {
+ const input = find(tree, 'id', `truth-alliance-expression-${path}-${field}`);
+ (input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
+}
+function editor(initial: AllianceExpression, disabled = false, choices = partners) {
+ let value = initial, changes = 0;
+ return {
+ get value() { return value; }, get changes() { return changes; },
+ tree: () => AllianceExpressionFields({ value, partners: choices, disabled, onChange(next) { value = next; changes++; } }),
+ html: () => renderToStaticMarkup(createElement(AllianceExpressionFields, { value, partners: choices, disabled, onChange() {} })),
+ };
+}
+function nested(depth: number): AllianceExpression {
+ let value = partner();
+ for (let i = 0; i < depth; i++) value = { kind: 'and', terms: [value, partner('a')] };
+ return value;
+}
+function guide(props: Parameters[0]) {
+ const button = AlliancePromiseGuideButton(props); assert.ok(button); return button;
+}
+const press = (button: ReturnType) => button.props.onClick();
+const markup = (game: GameView) => renderToStaticMarkup(createElement(AllianceExpressionPromises, { game, busy: false, send() {} }));
+
+/** Controlled own projections prove UI behavior. Native consent, answer
+ * feasibility and the actual Nexus closing event have separate engine proof. */
+function projected(kind: 'accept' | 'break' | 'ready' = 'accept', advanced = false): GameView {
+ const source = createGame('TRUTHALLIANCEEXPRESSIONCONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced);
+ joinGame(source, newPlayer('a', 'Asker', 'harkonnen'));
+ joinGame(source, newPlayer('r', 'Partner', 'atreides'));
+ const game = viewGame(source, 'p');
+ game.status = 'playing'; game.turn = 2; game.phase = 1; game.nexus = true; game.spiceWindow = null;
+ game.active = 'a'; game.ready = []; game.alliancePromiseNexus = { turn: 2 };
+ game.alliancePromises = []; game.mixedAlliancePromises = [];
+ game.allianceExpressionPromises = [{ event: 'controlled-pure-alliance', player: 'p', asker: 'a', turn: 2,
+ claim: kind === 'break' ? partner() : { kind: 'or', terms: [partner(), partner('a')] }, answer: kind !== 'break' }];
+ game.allianceOffers = kind === 'accept' ? { r: 'p' } : {};
+ if (kind !== 'accept') {
+ game.players.find(player => player.id === 'p')!.ally = 'r';
+ game.players.find(player => player.id === 'r')!.ally = 'p';
+ }
+ game.alliancePromiseGuide = { action: { type: kind === 'ready' ? 'ready' : 'alliance',
+ ...(kind === 'ready' ? {} : { target: kind === 'accept' ? 'r' : null }), alliancePromiseEvent: 'controlled-pure-alliance' } };
+ return game;
+}
+
+void test('all sixteen final partner clauses remain controlled with complete AND/OR branches and singleton collapse', () => {
+ const e = editor(partner()); click(control(e.tree(), 'or-1'));
+ assert.deepEqual(e.value, { kind: 'or', terms: [partner(), partner('')] });
+ assert.ok(allianceExpressionInputError(e.value, partners)); select(e.tree(), '1.2', 'partner', 'a');
+ for (let count = 3; count <= 16; count++) {
+ click(control(e.tree(), 'add-1')); select(e.tree(), `1.${count}`, 'partner', count % 2 ? 'r' : 'a');
+ }
+ assert.ok(e.value.kind === 'or'); assert.equal(e.value.terms.length, 16);
+ assert.deepEqual(parseAllianceExpression(e.value), e.value); assert.match(e.html(), /16 \/ 16 conditions/);
+ assert.equal((e.html().match(/Named alliance partner/g) ?? []).length, 16);
+ const ids = [...e.html().matchAll(/ id="([^"]+)"/g)].map(match => match[1]); assert.equal(new Set(ids).size, ids.length);
+ const before = structuredClone(e.value), changes = e.changes;
+ for (const key of ['add-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1']) {
+ assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
+ }
+ assert.equal(e.changes, changes); assert.deepEqual(e.value, before);
+ const grouped = editor({ kind: 'and', terms: [partner(), partner('a')] });
+ click(control(grouped.tree(), 'or-1.2')); select(grouped.tree(), '1.2', 'meaning', 'and');
+ click(control(grouped.tree(), 'nested-or-1')); click(control(grouped.tree(), 'nested-and-1'));
+ assert.ok(grouped.value.kind === 'and'); assert.equal(grouped.value.terms.length, 4);
+ click(control(grouped.tree(), 'remove-1.2.2')); assert.deepEqual(grouped.value.terms[1], partner('a'));
+ click(control(grouped.tree(), 'remove-1.4')); click(control(grouped.tree(), 'remove-1.3'));
+ click(control(grouped.tree(), 'remove-1.1')); assert.deepEqual(grouped.value, partner('a'));
+});
+
+void test('four grouping levels and strict envelopes coexist with repairable unnamed or removed partner drafts', () => {
+ const deep = editor(nested(4)); assert.equal(allianceExpressionInputError(deep.value, partners), null);
+ assert.match(deep.html(), /4 \/ 4 levels of grouping/);
+ for (const key of ['and-1', 'or-1.1.1.1.1', 'nested-and-1.1.1.1']) {
+ assert.equal(control(deep.tree(), key).props.disabled, true); click(control(deep.tree(), key));
+ }
+ assert.equal(deep.changes, 0); select(deep.tree(), '1.1.1.1.1', 'partner', 'a'); assert.equal(deep.changes, 1);
+ const draft = editor(partner('removed')); assert.match(draft.html(), /role="alert"/);
+ click(control(draft.tree(), 'or-1')); assert.ok(draft.value.kind === 'or'); assert.deepEqual(draft.value.terms[0], partner('removed'));
+ select(draft.tree(), '1.1', 'partner', 'a'); select(draft.tree(), '1.2', 'partner', 'r');
+ assert.equal(allianceExpressionInputError(draft.value, partners), null);
+ for (const invalid of [nested(5), { kind: 'and', terms: [partner()] }, { kind: 'or', terms: Array.from({ length: 17 }, () => partner()) },
+ { kind: 'partner', partner: '' }, { kind: 'partner', partner: ' r ' }, { kind: 'partner', partner: 'r', source: 'hidden' },
+ { kind: 'or', terms: [partner(), partner('a')], binding: true }, { kind: 'alliance', partner: 'r' }])
+ assert.ok(allianceExpressionInputError(invalid, partners));
+ assert.ok(allianceExpressionInputError(partner(), []));
+});
+
+void test('disabled callbacks and unlisted partner selections preserve the complete controlled expression', () => {
+ const frozen = editor({ kind: 'and', terms: [partner(), partner('a')] }, true), before = structuredClone(frozen.value);
+ for (const key of ['add-1', 'nested-and-1', 'or-1', 'remove-1.2']) click(control(frozen.tree(), key));
+ select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'partner', 'r');
+ assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
+ const e = editor(partner());
+ for (const id of ['unknown', '', ' r ', 'p']) select(e.tree(), '1', 'partner', id);
+ assert.equal(e.changes, 0); select(e.tree(), '1', 'partner', 'a'); assert.deepEqual(e.value, partner('a'));
+ const empty = editor(partner(), false, []); select(empty.tree(), '1', 'partner', 'r'); assert.equal(empty.changes, 0);
+});
+
+void test('own panel retains raw alternatives, whole No and passive settled history without exposing private sources', () => {
+ const game = projected(); game.alliancePromiseGuide = null;
+ game.allianceExpressionPromises.push(
+ { ...game.allianceExpressionPromises[0], event: 'no-event', answer: false, claim: { kind: 'and', terms: [partner(), partner('a')] } },
+ { ...game.allianceExpressionPromises[0], event: 'done-event', fulfilled: true },
+ { ...game.allianceExpressionPromises[0], event: 'released-event', released: true },
+ { ...game.allianceExpressionPromises[0], event: 'foreign-event', player: 'a', claim: partner('foreign-partner') },
+ );
+ for (const promise of game.allianceExpressionPromises) Object.defineProperty(promise, 'source', {
+ get() { throw Error('No private Nexus source'); }, configurable: true,
+ });
+ const html = markup(game);
+ assert.match(html, /You answered Yes/); assert.match(html, /You answered No/); assert.match(html, / OR /); assert.match(html, / AND /);
+ assert.match(html, /No negates the whole expression, not each condition separately/);
+ assert.match(html, /Legal alternatives remain available; no fixed branch is selected for you/);
+ assert.match(html, /Yes and No settle only at the actual Nexus close/);
+ assert.match(html, /Forming an alliance does not fulfill the answer early/); assert.match(html, /Offers alone are not alliances/);
+ assert.match(html, /Fulfilled at the Nexus close/); assert.match(html, /Released: this commitment is no longer possible/);
+ assert.match(html, /href="\/rules#truthtrance-alliance-expression"/);
+ assert.doesNotMatch(html, /foreign-partner|controlled-pure-alliance|foreign-event|data-testid="alliance-promise-guide"/);
+ assert.equal(AllianceExpressionPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
+});
+
+void test('exact canonical guides accept either OR alternative, break as preparation and require visible scalar and pure outcomes', () => {
+ for (const advanced of [false, true]) for (const kind of ['accept', 'break', 'ready'] as const) {
+ const game = projected(kind, advanced), sent: Action[] = [];
+ const button = guide({ game, busy: false, send(action) { sent.push(action); } });
+ assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.alliancePromiseGuide!.action);
+ assert.notEqual(game.active, game.me);
+ }
+ const alternative = projected(); alternative.allianceOffers = { a: 'p' }; alternative.alliancePromiseGuide!.action.target = 'a';
+ const sent: Action[] = []; press(guide({ game: alternative, busy: false, send(action) { sent.push(action); } }));
+ assert.equal(sent[0], alternative.alliancePromiseGuide!.action);
+ for (const game of [projected(), projected('ready')]) {
+ game.allianceExpressionPromises[0].claim = partner('a');
+ assert.equal(guide({ game, busy: false, send() { assert.fail('Visible pure outcome must match'); } }).props.disabled, true);
+ }
+ const scalar = projected(); scalar.alliancePromises.push({ event: 'earlier-scalar', player: 'p', asker: 'a', turn: 2,
+ partner: 'r', answer: false });
+ assert.equal(guide({ game: scalar, busy: false, send() {} }).props.disabled, true);
+ const preparation = projected('ready'); preparation.alliancePromiseGuide!.action = { type: 'alliance', target: null,
+ alliancePromiseEvent: 'controlled-pure-alliance' };
+ assert.equal(guide({ game: preparation, busy: false, send() {} }).props.disabled, false);
+ const noReady = projected('ready'); noReady.allianceExpressionPromises[0].answer = false;
+ assert.equal(guide({ game: noReady, busy: false, send() {} }).props.disabled, true);
+});
+
+void test('stale pure callbacks retain exact event, action, public consent context, room and Nexus guards', () => {
+ const changes: ((game: GameView) => void)[] = [
+ game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 2; }, game => { game.status = 'setup'; },
+ game => { game.nexus = false; }, game => { game.ready = ['p']; }, game => { game.alliancePromiseNexus = null; },
+ game => { game.alliancePromiseNexus!.turn = 1; }, game => { game.allianceOffers = {}; },
+ game => { game.allianceOffers.a = 'p'; }, game => { game.players.find(player => player.id === 'a')!.ally = 'r'; },
+ game => { game.alliancePromiseGuide = { action: { ...game.alliancePromiseGuide!.action } }; },
+ game => { game.alliancePromiseGuide = null; }, game => { game.alliancePromiseGuide!.action.target = null; },
+ game => { game.allianceExpressionPromises[0].event = 'expired'; }, game => { game.allianceExpressionPromises[0].player = 'a'; },
+ game => { game.allianceExpressionPromises[0].turn = 1; }, game => { game.allianceExpressionPromises[0].fulfilled = true; },
+ game => { game.allianceExpressionPromises[0].released = true; }, game => { game.allianceExpressionPromises = []; },
+ game => { game.players = game.players.filter(player => player.id !== 'r'); },
+ game => { game.spiceWindow = { territory: 'hagga_basin', sector: 12, amount: 6, harvested: false }; },
+ game => { game.phaseOpening = { passed: [] }; },
+ game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
+ game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
+ game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
+ game => { game.automaticContinuationPending = true; }, game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
+ game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
+ game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
+ game => { game.techTokens = createTechTokens(); },
+ ];
+ for (const change of changes) {
+ const game = projected(), sent: Action[] = [], button = guide({ game, busy: false, send(action) { sent.push(action); } });
+ assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
+ }
+ for (const target of ['p', 'missing']) {
+ const game = projected(); game.alliancePromiseGuide!.action.target = target;
+ assert.equal(guide({ game, busy: false, send() {} }).props.disabled, true);
+ }
+ for (const event of ['', 'unrelated']) {
+ const game = projected(); game.alliancePromiseGuide!.action.alliancePromiseEvent = event;
+ const button = guide({ game, busy: false, send() { assert.fail('Unknown event'); } }); assert.equal(button.props.disabled, true); press(button);
+ }
+ const busy = { game: projected(), busy: false, send() { assert.fail('Busy guide'); } };
+ const oldBusy = guide(busy); busy.busy = true; press(oldBusy);
+ const replaced = { game: projected(), busy: false, send() { assert.fail('Replaced projection'); } };
+ const oldReplaced = guide(replaced); replaced.game = { ...replaced.game }; press(oldReplaced);
+});
+
+void test('one live scalar or pure panel owns the shared guide while private card, spice and Nexus sources are untouched', () => {
+ const game = projected(), sent: Action[] = [];
+ const both = () => renderToStaticMarkup(createElement('div', null,
+ createElement(AlliancePromises, { game, busy: false, send() {} }),
+ createElement(AllianceExpressionPromises, { game, busy: false, send() {} })));
+ const one = () => assert.equal((both().match(/data-testid="alliance-promise-guide"/g) ?? []).length, 1);
+ game.alliancePromises.push({ event: 'scalar-history', player: 'p', asker: 'a', turn: 1, partner: 'r', answer: true, fulfilled: true });
+ one(); assert.match(markup(game), /data-testid="alliance-promise-guide"/);
+ game.alliancePromises.push({ event: 'live-scalar', player: 'p', asker: 'a', turn: 2, partner: 'r', answer: true });
+ one(); assert.doesNotMatch(markup(game), /data-testid="alliance-promise-guide"/);
+ game.alliancePromises[1].fulfilled = true; one();
+ for (const seat of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'bribes', 'reserves', 'tanks'])
+ Object.defineProperty(seat, key, { get() { throw Error(`No private ${key}`); }, configurable: true });
+ for (const promise of game.allianceExpressionPromises) Object.defineProperty(promise, 'source', { get() { throw Error('No Nexus source'); } });
+ assert.match(markup(game), /Accept Partner’s alliance invitation/);
+ press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.equal(sent[0], game.alliancePromiseGuide!.action);
+ game.allianceExpressionPromises[0].fulfilled = true; assert.doesNotMatch(both(), /data-testid="alliance-promise-guide"/);
+});
diff --git a/tests/truth-alliance-expression-funding.test.ts b/tests/truth-alliance-expression-funding.test.ts
new file mode 100644
index 00000000..9832b672
--- /dev/null
+++ b/tests/truth-alliance-expression-funding.test.ts
@@ -0,0 +1,95 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame } from '../game/engine';
+import { truthFactAnswer } from '../game/truthtrance';
+import {
+ actTruthAllianceExpression as act, answerTruthAllianceExpression as answer,
+ askTruthAllianceExpression as ask, askTruthMixedAlliance as askMixed,
+ closeTruthAllianceExpressionNexus as close, createTruthAllianceExpressionFixture as fixture,
+ createTruthMixedAllianceFixture as mixedFixture, declareTruthAllianceExpression as declare,
+ declareTruthMixedAlliance as declareMixed, truthAllianceExpressionInventory as inventory,
+ truthAllianceExpressionPlayer as player, truthAllianceExpressionPromise as promise,
+ truthMixedAlliancePromise as mixedPromise,
+} from './fixture-truth-alliance-expression';
+
+void test('two original Truths preserve prior scalar alliance No while a later whole OR Yes takes the other real partner', () => {
+ const f = fixture({ secondOffer: true }); let game = declare(f, f.game, { copies: 2 }).game;
+ game = act(game, f.asker, { type: 'truthAsk', question: {
+ kind: 'alliance', target: f.target, turn: game.turn, partner: f.partner,
+ } });
+ game = answer(game, f.target, 'no'); game = ask(game, f.asker, f.target, f.claim);
+ assert.deepEqual(viewGame(game, f.target).truthAllianceExpressionAnswers, ['yes', 'no']);
+ game = answer(game, f.target, 'yes');
+ assert.equal(viewGame(game, f.target).alliancePromises.length, 1);
+ assert.equal(viewGame(game, f.target).allianceExpressionPromises.length, 1);
+ assert.notEqual(viewGame(game, f.target).alliancePromises[0].event, promise(game).event);
+ const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide);
+ assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, f.asker);
+ game = act(game, f.target, guide.action); assert.equal(player(game, f.target).ally, f.asker);
+ assert.ok(!game.alliancePromises![0].fulfilled && !promise(game).fulfilled);
+ game = close(game); assert.equal(game.alliancePromises![0].fulfilled, true); assert.equal(promise(game).fulfilled, true);
+ for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('two original Truths share one real final partner between earlier scalar Yes and later mixed OR Yes at actual close', () => {
+ const f = mixedFixture({ secondOffer: true }); let game = declareMixed(f, f.game, { copies: 2 }).game;
+ game = act(game, f.asker, { type: 'truthAsk', question: {
+ kind: 'alliance', target: f.target, turn: game.turn, partner: f.partner,
+ } });
+ game = answer(game, f.target, 'yes'); game = askMixed(game, f.asker, f.target, f.mixed);
+ assert.deepEqual(viewGame(game, f.target).truthMixedAllianceAnswers, ['yes']); game = answer(game, f.target, 'yes');
+ assert.equal(viewGame(game, f.target).alliancePromises.length, 1);
+ assert.deepEqual(viewGame(game, f.target).allianceExpressionPromises, []);
+ assert.notEqual(viewGame(game, f.target).alliancePromises[0].event, mixedPromise(game).event);
+ const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide);
+ assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, f.partner);
+ game = act(game, f.target, guide.action); assert.equal(player(game, f.target).ally, f.partner);
+ assert.ok(!game.alliancePromises![0].fulfilled); assert.equal(mixedPromise(game).fulfilled, false);
+ game = close(game); assert.equal(game.alliancePromises![0].fulfilled, true); assert.equal(mixedPromise(game).fulfilled, true);
+ for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('two original Truths pay the earlier native bribe before a later mixed OR accepts its manual alternative without thawing the spice fact', () => {
+ const f = mixedFixture({ secondOffer: true }); let game = declareMixed(f, f.game, { copies: 2 }).game;
+ game = act(game, f.asker, { type: 'truthAsk', question: {
+ kind: 'bribe', target: f.target, turn: game.turn, recipient: f.partner, minimum: 3,
+ } });
+ assert.ok(viewGame(game, f.target).truthBribeAnswers?.includes('yes')); game = answer(game, f.target, 'yes');
+ game = askMixed(game, f.asker, f.target, f.mixed);
+ assert.ok(viewGame(game, f.target).truthMixedAllianceAnswers?.includes('yes')); game = answer(game, f.target, 'yes');
+ assert.equal(player(game, f.target).spice, 10); assert.equal(mixedPromise(game).fulfilled, false);
+ const payment = viewGame(game, f.target).bribePromiseGuide; assert.ok(payment);
+ const recipientSpice = player(game, f.partner).spice;
+ game = act(game, f.target, payment.action); assert.equal(player(game, f.target).spice, 7);
+ assert.equal(player(game, f.partner).spice, recipientSpice); assert.equal(player(game, f.partner).bribes, 3);
+ assert.equal(game.bribePromises![0].fulfilled, true);
+ assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.equal(mixedPromise(game).fulfilled, false);
+ const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'alliance');
+ const alternative = guide.action.target === f.asker ? f.partner : f.asker;
+ game = act(game, f.target, { type: 'alliance', target: alternative }); assert.equal(player(game, f.target).ally, alternative);
+ assert.equal(mixedPromise(game).fulfilled, false); game = close(game); assert.equal(mixedPromise(game).fulfilled, true);
+ assert.equal(game.bribePromises![0].fulfilled, true); assert.equal(player(game, f.partner).bribes, 3);
+ for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('two original Truths preserve an earlier whole Yes against a later contradictory scalar alliance No', () => {
+ const f = fixture({ secondOffer: true }); let game = declare(f, f.game, { copies: 2 }).game;
+ game = ask(game, f.asker, f.target, { kind: 'partner', partner: f.partner });
+ game = answer(game, f.target, 'yes');
+ game = act(game, f.asker, { type: 'truthAsk', question: {
+ kind: 'alliance', target: f.target, turn: game.turn, partner: f.partner,
+ } });
+ assert.deepEqual(viewGame(game, f.target).truthAllianceAnswers, ['yes']);
+ const before = structuredClone(game);
+ assert.throws(() => answer(game, f.target, 'no')); assert.deepEqual(game, before);
+ game = answer(game, f.target, 'yes');
+ const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide);
+ assert.equal(guide.action.target, f.partner); game = act(game, f.target, guide.action);
+ assert.ok(!promise(game).fulfilled && !game.alliancePromises![0].fulfilled);
+ game = close(game); assert.equal(promise(game).fulfilled, true); assert.equal(game.alliancePromises![0].fulfilled, true);
+ for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
diff --git a/tests/truth-alliance-expression-runtime.test.ts b/tests/truth-alliance-expression-runtime.test.ts
new file mode 100644
index 00000000..4bcca517
--- /dev/null
+++ b/tests/truth-alliance-expression-runtime.test.ts
@@ -0,0 +1,204 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { viewGame, type Action, type Game } from '../game/engine';
+import {
+ actTruthAllianceExpression as act, answerTruthAllianceExpression as answer,
+ askTruthAllianceExpression as ask, bindTruthAllianceExpression as bind,
+ closeTruthAllianceExpressionNexus as close, createTruthAllianceExpressionFixture as fixture,
+ declareTruthAllianceExpression as declare, finishTruthAllianceExpression as finish,
+ truthAllianceExpressionGroup as group, truthAllianceExpressionInventory as inventory,
+ truthAllianceExpressionNexus as nexus, truthAllianceExpressionPartner as partner,
+ truthAllianceExpressionPlayer as player, truthAllianceExpressionPromise as promise,
+} from './fixture-truth-alliance-expression';
+
+function rejected(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
+}
+function truthOnce(game: Game, truth: string) {
+ assert.equal(game.discard.filter(card => card.id === truth).length, 1);
+}
+
+for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} whole alliance OR uses two real invitations and settles only at actual Nexus close`, () => {
+ const f = fixture({ advanced, secondOffer: true });
+ assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); assert.ok(nexus(f.game));
+ assert.equal(f.game.turn, 2); assert.equal(f.originalCards.length, 33); assert.deepEqual(f.staging, []);
+ assert.equal(f.sourceActionCount, f.sourceActions.length);
+ assert.equal(f.sourceActionCount, f.sourceCounts.inherited + 1);
+ assert.equal(f.offer!.steps[0].actor, f.partner); assert.equal(f.secondInvitation!.steps[0].actor, f.asker);
+ assert.equal(f.game.allianceOffers[f.partner], f.target); assert.equal(f.game.allianceOffers[f.asker], f.target);
+ assert.equal(player(f.game, f.target).ally, null);
+ const before = structuredClone(f.game); let game = bind(f).game; assert.deepEqual(f.game, before);
+ assert.deepEqual(promise(game).claim, f.claim); assert.equal(promise(game).answer, true);
+ assert.ok(!promise(game).fulfilled); rejected(game, f.target, { type: 'ready' });
+ const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'alliance');
+ assert.ok(guide.action.target === f.asker || guide.action.target === f.partner);
+ assert.equal(guide.action.alliancePromiseEvent, promise(game).event);
+ game = act(game, f.target, guide.action); assert.ok(!promise(game).fulfilled);
+ const chosen = player(game, f.target).ally; assert.ok(chosen === f.asker || chosen === f.partner);
+ game = close(game); assert.equal(game.nexus, false); assert.equal(promise(game).fulfilled, true);
+ assert.equal(player(game, f.target).ally, chosen); assert.equal(promise(game).released ?? false, false);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game)), f.originalCards);
+});
+
+void test('native whole OR accepts the other real invitation manually without pinning its guide route', () => {
+ const f = fixture({ secondOffer: true }); let game = bind(f).game;
+ const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'alliance');
+ const alternative = guide.action.target === f.asker ? f.partner : f.asker;
+ assert.equal(game.allianceOffers[alternative], f.target);
+ game = act(game, f.target, { type: 'alliance', target: alternative });
+ assert.equal(player(game, f.target).ally, alternative); assert.ok(!promise(game).fulfilled);
+ game = close(game); assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native distinct-partner whole AND has only No and may retain a real single partnership until close', () => {
+ const f = fixture({ secondOffer: true }), claim = group('and', partner(f.asker), partner(f.partner));
+ const asked = ask(declare(f).game, f.asker, f.target, claim);
+ assert.deepEqual(viewGame(asked, f.target).truthAllianceExpressionAnswers, ['no']);
+ rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); let game = answer(asked, f.target, 'no');
+ assert.ok(!promise(game).fulfilled); game = act(game, f.target, { type: 'alliance', target: f.partner });
+ assert.equal(player(game, f.target).ally, f.partner); assert.ok(!promise(game).fulfilled);
+ game = close(game); assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).ally, f.partner);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native whole OR No breaks an existing named pair and then readies without treating one false leaf as the whole answer', () => {
+ const f = fixture({ consent: 'allied' }); let game = bind(f, 'no').game;
+ assert.equal(player(game, f.target).ally, f.partner); rejected(game, f.target, { type: 'ready' });
+ const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide);
+ assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, null);
+ game = act(game, f.target, guide.action); assert.equal(player(game, f.target).ally, null);
+ assert.ok(!promise(game).fulfilled); const ready = viewGame(game, f.target).alliancePromiseGuide;
+ assert.ok(ready); assert.equal(ready.action.type, 'ready'); game = close(game);
+ assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).ally, null);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native whole OR No permits temporary real membership but does not settle until its own break and actual close', () => {
+ const f = fixture(); let game = bind(f, 'no').game;
+ game = act(game, f.target, { type: 'alliance', target: f.partner });
+ assert.equal(player(game, f.target).ally, f.partner); assert.ok(!promise(game).fulfilled);
+ rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, { type: 'alliance', target: null });
+ assert.ok(!promise(game).fulfilled); game = close(game); assert.equal(promise(game).fulfilled, true);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native whole OR without foreign consent preserves unknown save and reasks the same physical Truth after a real invitation', () => {
+ const f = fixture({ consent: 'none' }); let game = ask(declare(f).game, f.asker, f.target, f.claim);
+ assert.deepEqual(viewGame(game, f.target).truthAllianceExpressionAnswers, ['no', 'unknown']);
+ rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'unknown');
+ assert.equal(game.truthtrance!.stage, 'unknown'); assert.equal(game.discard.filter(card => card.id === f.truth).length, 0);
+ game = act(game, f.asker, { type: 'truthSave' }); assert.equal(game.truthtrance, null);
+ assert.equal(player(game, f.asker).hand.filter(card => card.effect === 'truthtrance').length, 2);
+ game = act(game, f.partner, { type: 'alliance', target: f.target }); game = bind(f, 'yes', f.claim, game).game;
+ game = close(game); assert.equal(player(game, f.target).ally, f.partner); assert.equal(promise(game).fulfilled, true);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native definite whole alliance answer reopens only the respondent existing Ready', () => {
+ const f = fixture(); let game = act(f.game, f.target, { type: 'ready' });
+ game = act(game, f.partner, { type: 'ready' }); assert.ok(game.ready.includes(f.target) && game.ready.includes(f.partner));
+ game = bind(f, 'yes', f.claim, game).game;
+ assert.ok(!game.ready.includes(f.target)); assert.ok(game.ready.includes(f.partner));
+ rejected(game, f.target, { type: 'ready' }); game = close(game); assert.equal(promise(game).fulfilled, true);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native whole alliance question and callback retain current context owner turn event and JSON semantics', () => {
+ const f = fixture(), declared = declare(f);
+ assert.ok(viewGame(declared.game, f.asker).truthAllianceExpressionTargets.includes(f.target));
+ rejected(declared.game, f.partner, f.questionAction);
+ rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
+ rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, claim: partner(f.target) } });
+ let game = ask(declared.game, f.asker, f.target, f.claim);
+ assert.deepEqual(viewGame(game, f.target).truthAllianceExpressionAnswers, ['yes', 'no']);
+ assert.equal(viewGame(game, f.asker).truthAllianceExpressionAnswers, null);
+ assert.equal(viewGame(game, f.partner).truthAllianceExpressionAnswers, null);
+ rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes');
+ const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide);
+ rejected(game, f.asker, guide.action);
+ rejected(game, f.target, { ...guide.action, alliancePromiseEvent: 'stale-whole-alliance-event' });
+ const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, wire);
+ rejected(game, f.target, wire); game = close(game); rejected(game, f.target, wire);
+ game = act(game, f.asker, { type: 'card', card: f.truths[1] });
+ for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
+ const next = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(next);
+ game = act(game, next.id, { type: 'truthPass' });
+ }
+ assert.equal(game.truthtrance?.stage, 'ask'); rejected(game, f.asker, f.questionAction);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native whole alliance projection exposes only own original expression and aggregate status', () => {
+ const f = fixture(), game = bind(f).game;
+ for (const actor of [f.target, f.asker, f.partner]) {
+ const view = viewGame(game, actor); assert.equal(view.truthAllianceExpressionAnswers, null);
+ assert.deepEqual(view.truthHistory.at(-1)?.question, f.question); assert.equal(view.truthHistory.at(-1)?.answer, 'yes');
+ if (actor === f.target) {
+ const row = promise(game); assert.equal(row.player, f.target); assert.equal(row.asker, f.asker);
+ assert.equal(row.turn, f.game.turn); assert.deepEqual(row.claim, f.claim); assert.equal(row.answer, true);
+ assert.ok(Object.keys(row).every(key => ['event', 'player', 'asker', 'turn', 'claim', 'answer', 'fulfilled', 'released'].includes(key)));
+ assert.equal(view.alliancePromiseGuide?.action.alliancePromiseEvent, row.event);
+ } else {
+ assert.deepEqual(view.allianceExpressionPromises, []); assert.equal(view.alliancePromiseGuide, null);
+ }
+ }
+ assert.equal(game.allianceExpressionPromises!.length, 1); assert.ok(game.allianceExpressionPromises![0].source);
+});
+
+void test('native whole OR survives one opposing invitation withdrawal, releases after the last, and never reactivates on reoffer', () => {
+ const f = fixture({ secondOffer: true }); let game = bind(f).game;
+ game = act(game, f.partner, { type: 'alliance', target: null }); assert.ok(!promise(game).released);
+ const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide);
+ assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, f.asker);
+ game = act(game, f.asker, { type: 'alliance', target: null });
+ assert.equal(promise(game).released, true); assert.ok(!promise(game).fulfilled);
+ game = act(game, f.asker, { type: 'alliance', target: f.target }); rejected(game, f.target, guide.action);
+ assert.equal(viewGame(game, f.target).alliancePromiseGuide, null);
+ game = act(game, f.target, { type: 'alliance', target: f.asker }); game = close(game);
+ assert.equal(promise(game).released, true); assert.ok(!promise(game).fulfilled);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native whole OR retains opposing actual departure rights and does not bind a later renewed consent', () => {
+ const f = fixture({ consent: 'allied' }); let game = bind(f).game;
+ rejected(game, f.target, { type: 'alliance', target: null });
+ game = act(game, f.partner, { type: 'alliance', target: null });
+ assert.equal(player(game, f.target).ally, null); assert.equal(promise(game).released, true);
+ game = act(game, f.partner, { type: 'alliance', target: f.target }); game = close(game);
+ assert.equal(promise(game).released, true); assert.ok(!promise(game).fulfilled);
+ assert.equal(player(game, f.target).ally, null); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native nested whole OR and AND permits each complete matching final-partner outcome', () => {
+ const f = fixture({ secondOffer: true });
+ const claim = group('or', group('and', partner(f.asker), partner(f.asker)), partner(f.partner));
+ for (const selected of [f.asker, f.partner]) {
+ let game = bind(f, 'yes', claim).game; game = act(game, f.target, { type: 'alliance', target: selected });
+ assert.ok(!promise(game).fulfilled); game = close(game); assert.equal(player(game, f.target).ally, selected);
+ assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
+ }
+});
+
+void test('all four minimal policies answer whole alliance questions and use their real acceptance and Ready controls legally', () => {
+ const f = fixture({ secondOffer: true }), asked = ask(declare(f).game, f.asker, f.target, f.claim);
+ for (const difficulty of DIFFICULTIES) {
+ const view = viewGame(asked, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
+ const action = botActions(view).find(action => action.type === 'truthAnswer'); assert.ok(action, difficulty);
+ assert.ok(action.answer === 'yes' || action.answer === 'no' || action.answer === 'unknown');
+ assert.ok(view.truthAllianceExpressionAnswers!.includes(action.answer));
+ const game = act(structuredClone(asked), f.target, action); truthOnce(game, f.truth);
+ assert.deepEqual(inventory(game), f.originalCards);
+ }
+ for (const difficulty of DIFFICULTIES) {
+ let game = bind(f).game; const view = viewGame(game, f.target);
+ view.players.find(seat => seat.id === f.target)!.bot = difficulty;
+ const action = botActions(view).find(action => action.type === 'alliance'); assert.ok(action, difficulty);
+ game = act(game, f.target, action); assert.ok(player(game, f.target).ally); assert.ok(!promise(game).fulfilled);
+ const readyView = viewGame(game, f.target); readyView.players.find(seat => seat.id === f.target)!.bot = difficulty;
+ const ready = botActions(readyView).find(action => action.type === 'ready'); assert.ok(ready, difficulty);
+ game = act(game, f.target, ready); game = close(game); assert.equal(promise(game).fulfilled, true);
+ assert.deepEqual(inventory(game), f.originalCards);
+ }
+});
diff --git a/tests/truth-mixed-alliance-controls.test.tsx b/tests/truth-mixed-alliance-controls.test.tsx
new file mode 100644
index 00000000..c5f766ed
--- /dev/null
+++ b/tests/truth-mixed-alliance-controls.test.tsx
@@ -0,0 +1,263 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { MixedAllianceFields, mixedAllianceInputError } from '../components/mixed-alliance-fields';
+import { MixedAlliancePromises } from '../components/mixed-alliance-promises';
+import { TruthFactFields } from '../components/mixed-shipment-fields';
+import { AlliancePromises, AlliancePromiseGuideButton } from '../components/alliance-promises';
+import { AllianceExpressionPromises } from '../components/alliance-expression-promises';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
+import type { MixedActionExpression } from '../game/mixed-action-question';
+import type { AllianceActionClaim } from '../game/mixed-alliance-question';
+import type { TruthFact } from '../game/truthtrance';
+import { createTechTokens } from '../game/tech-tokens';
+
+type Expression = MixedActionExpression;
+type ElementProps = { children?: ReactNode; [key: string]: unknown };
+type Element = ReactElement;
+const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value });
+const partner = (id = 'r'): Expression => ({ kind: 'action', action: { partner: id } });
+const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [partner(), partner('a')] }] });
+function elements(node: ReactNode): Element[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!isValidElement(node)) return [];
+ return [node, ...elements(node.props.children)];
+}
+function find(tree: ReactNode, key: string, value: string): Element {
+ const found = elements(tree).find(element => element.props[key] === value); assert.ok(found, `${key}=${value}`); return found;
+}
+const control = (tree: ReactNode, key: string) => find(tree, 'data-mixed-alliance-control', key);
+const click = (button: Element) => (button.props.onClick as () => void)();
+function select(tree: ReactNode, path: string, field: string, value: string) {
+ const input = find(tree, 'id', `truth-mixed-alliance-${path}-${field}`);
+ (input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
+}
+function editFact(tree: ReactNode, path: string, next: TruthFact) {
+ const scope = find(tree, 'data-mixed-alliance-fact-path', path);
+ const fields = elements(scope).find(element => element.type === TruthFactFields); assert.ok(fields, `Current fact fields ${path}`);
+ (fields.props.onChange as (fact: TruthFact) => void)(next);
+}
+
+/** These personalized projections exercise controlled UI and canonical guide
+ * ownership, without claiming native answer-time snapshots or consent. */
+function projected(kind: 'accept' | 'break' | 'ready' = 'accept', advanced = false): GameView {
+ const source = createGame('TRUTHMIXEDALLIANCECONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced);
+ joinGame(source, newPlayer('a', 'Asker', 'harkonnen'));
+ joinGame(source, newPlayer('r', 'Partner', 'atreides'));
+ const game = viewGame(source, 'p');
+ game.status = 'playing'; game.turn = 2; game.phase = 1; game.nexus = true; game.spiceWindow = null;
+ game.active = 'a'; game.ready = []; game.alliancePromiseNexus = { turn: 2 };
+ game.alliancePromises = []; game.allianceExpressionPromises = [];
+ game.mixedAlliancePromises = [{ event: 'controlled-mixed-alliance', player: 'p', asker: 'a', turn: 2,
+ mixed: claim(), answer: kind !== 'break', fulfilled: false, released: false }];
+ game.allianceOffers = kind === 'accept' ? { r: 'p' } : {};
+ if (kind !== 'accept') {
+ game.players.find(player => player.id === 'p')!.ally = 'r';
+ game.players.find(player => player.id === 'r')!.ally = 'p';
+ }
+ game.alliancePromiseGuide = { action: { type: kind === 'ready' ? 'ready' : 'alliance',
+ ...(kind === 'ready' ? {} : { target: kind === 'accept' ? 'r' : null }), alliancePromiseEvent: 'controlled-mixed-alliance' } };
+ return game;
+}
+function editor(initial: Expression, disabled = false, game = projected()) {
+ let value = initial, changes = 0;
+ return {
+ get value() { return value; }, get changes() { return changes; },
+ tree: () => MixedAllianceFields({ value, game, target: 'p', disabled, onChange(next) { value = next; changes++; } }),
+ html: () => renderToStaticMarkup(createElement(MixedAllianceFields, { value, game, target: 'p', disabled, onChange() {} })),
+ error: () => mixedAllianceInputError(value, game, 'p'),
+ };
+}
+function guide(props: Parameters[0]) {
+ const button = AlliancePromiseGuideButton(props); assert.ok(button); return button;
+}
+const press = (button: ReturnType) => button.props.onClick();
+const markup = (game: GameView) => renderToStaticMarkup(createElement(MixedAlliancePromises, { game, busy: false, send() {} }));
+
+void test('shared mixed editor preserves sixteen clauses and offers every public other partner without predicting consent', () => {
+ const e = editor(fact()); click(control(e.tree(), 'or-1')); select(e.tree(), '1.2', 'partner', 'r');
+ for (let count = 3; count <= 16; count++) {
+ click(control(e.tree(), 'add-action-1')); select(e.tree(), `1.${count}`, 'partner', count % 2 ? 'r' : 'a');
+ }
+ assert.ok(e.value.kind === 'or'); assert.equal(e.value.terms.length, 16); assert.equal(e.error(), null);
+ const html = e.html(), ids = [...html.matchAll(/ id="([^"]+)"/g)].map(match => match[1]);
+ assert.equal(new Set(ids).size, ids.length); assert.match(html, /16 \/ 16 conditions/);
+ assert.equal((html.match(/Named alliance partner/g) ?? []).length, 15);
+ const before = structuredClone(e.value), changes = e.changes;
+ for (const key of ['add-fact-1', 'add-action-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'fact-or-1.1', 'or-1']) {
+ assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
+ }
+ assert.equal(e.changes, changes); assert.deepEqual(e.value, before);
+ const game = projected(); game.allianceOffers = {};
+ game.players.find(player => player.id === 'a')!.ally = 'r'; game.players.find(player => player.id === 'r')!.ally = 'a';
+ const allied = editor({ kind: 'and', terms: [fact(), partner()] }, false, game);
+ assert.equal(allied.error(), null); assert.match(allied.html(), / {
+ const e = editor({ kind: 'and', terms: [fact(), partner()] });
+ click(control(e.tree(), 'or-1.2')); select(e.tree(), '1.2', 'meaning', 'and');
+ click(control(e.tree(), 'nested-or-1')); select(e.tree(), '1.3.2', 'partner', 'a');
+ click(control(e.tree(), 'fact-or-1.1')); editFact(e.tree(), '1.1.2', { kind: 'stormForecast', compare: 'gte', value: 4 });
+ select(e.tree(), '1.1', 'fact-meaning', 'and'); click(control(e.tree(), 'fact-add-1.1'));
+ editFact(e.tree(), '1.1.3', { kind: 'spice', compare: 'gte', value: 7 }); assert.equal(e.error(), null);
+ click(control(e.tree(), 'fact-remove-1.1.3')); click(control(e.tree(), 'fact-remove-1.1.2'));
+ click(control(e.tree(), 'remove-1.2.2')); click(control(e.tree(), 'remove-1.3'));
+ assert.deepEqual(e.value, { kind: 'and', terms: [fact(), partner()] });
+ select(e.tree(), '1.2', 'timing', 'fact'); assert.ok(e.error());
+ select(e.tree(), '1.2', 'timing', 'action'); select(e.tree(), '1.2', 'partner', 'r'); assert.equal(e.error(), null);
+ click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, partner('r')); assert.ok(e.error());
+ let nested = fact();
+ for (let depth = 0; depth < 3; depth++) nested = { kind: 'and', terms: [nested, partner()] };
+ const deep = editor(nested); click(control(deep.tree(), 'fact-or-1.1.1.1'));
+ assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
+ for (const key of ['and-1', 'or-1.1.1.1', 'fact-and-1.1.1.1.1', 'fact-or-1.1.1.1']) {
+ assert.equal(control(deep.tree(), key).props.disabled, true); click(control(deep.tree(), key));
+ }
+ assert.equal(deep.changes, 1);
+});
+
+void test('incomplete partner and fact drafts remain editable while strict envelopes and disabled callbacks are enforced', () => {
+ const e = editor({ kind: 'and', terms: [fact(), partner()] });
+ select(e.tree(), '1.2', 'partner', ''); assert.ok(e.error()); click(control(e.tree(), 'or-1.2'));
+ assert.ok(e.value.kind === 'and'); assert.deepEqual(e.value.terms[1], { kind: 'or', terms: [partner(''), fact()] });
+ assert.match(e.html(), /role="alert"/); select(e.tree(), '1.2.1', 'partner', 'a'); assert.equal(e.error(), null);
+ for (const invalid of [NaN, -1, 0.5, Number.MAX_SAFE_INTEGER + 1, Infinity]) {
+ editFact(e.tree(), '1.1', { kind: 'spice', compare: 'gte', value: invalid }); assert.ok(e.error());
+ editFact(e.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 0 }); assert.equal(e.error(), null);
+ }
+ const game = projected();
+ for (const invalid of [fact(), partner(), { ...claim(), binding: true },
+ { kind: 'and', terms: [fact(), { kind: 'constant', value: true }, partner()] },
+ { kind: 'and', terms: [fact(), { kind: 'action', action: { partner: 'p' } }] },
+ { kind: 'and', terms: [fact(), { kind: 'action', action: { partner: 'r', source: 'hidden' } }] },
+ { kind: 'and', terms: [fact(), { kind: 'action', action: { partner: ' r ' } }] }])
+ assert.ok(mixedAllianceInputError(invalid, game, 'p'));
+ const frozen = editor({ kind: 'and', terms: [fact(), partner()] }, true), before = structuredClone(frozen.value);
+ for (const key of ['and-1', 'add-action-1', 'nested-or-1', 'remove-1.2', 'fact-or-1.1']) click(control(frozen.tree(), key));
+ select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.1', 'timing', 'action');
+ select(frozen.tree(), '1.2', 'partner', 'a'); editFact(frozen.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 4 });
+ assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
+});
+
+void test('editor and shared guide use public choices without private custody, balances, sources or compiled mixed facts', () => {
+ const game = projected(), value: Expression = { kind: 'and', terms: [fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), partner()] };
+ for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves', 'bribes'])
+ Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true });
+ for (const promise of game.mixedAlliancePromises) for (const key of ['source', 'binding', 'residual', 'factAnswers', 'compiled', 'frozenFact'])
+ Object.defineProperty(promise, key, { get() { throw Error(`No private ${key}`); }, configurable: true });
+ const html = renderToStaticMarkup(createElement(MixedAllianceFields, { value, game, target: 'p', disabled: false, onChange() {} }));
+ assert.match(html, /Current facts are evaluated when the player answers/); assert.match(html, /Only the aggregate answer is shared/);
+ assert.match(html, /unknown current fact can still allow a definite whole answer/); assert.match(html, /Offers alone are not alliances/);
+ assert.equal(mixedAllianceInputError(value, game, 'p'), null);
+ const sent: Action[] = []; press(guide({ game, busy: false, send(action) { sent.push(action); } }));
+ assert.equal(sent[0], game.alliancePromiseGuide!.action);
+});
+
+void test('own mixed panel retains only raw questions, aggregate outcomes and passive constant or terminal records', () => {
+ const game = projected(); game.alliancePromiseGuide = null;
+ game.mixedAlliancePromises.push(
+ { ...game.mixedAlliancePromises[0], event: 'no-event', answer: false },
+ { ...game.mixedAlliancePromises[0], event: 'constant-event', fulfilled: true },
+ { ...game.mixedAlliancePromises[0], event: 'released-event', released: true },
+ { ...game.mixedAlliancePromises[0], event: 'foreign-event', player: 'a', mixed: { kind: 'and', terms: [
+ fact({ kind: 'hand', name: 'Poison Blade' }), partner('foreign-partner'),
+ ] } },
+ );
+ for (const promise of game.mixedAlliancePromises) for (const key of ['source', 'binding', 'residual', 'factAnswers', 'compiled', 'frozenFact'])
+ Object.defineProperty(promise, key, { get() { throw Error(`No private ${key}`); }, configurable: true });
+ const html = markup(game);
+ assert.match(html, /you hold Shield/); assert.match(html, /You answered Yes/); assert.match(html, /You answered No/);
+ assert.match(html, / OR /); assert.match(html, / AND /); assert.match(html, /Individual fact answers and any reduced future branch remain private/);
+ assert.match(html, /No negates the whole expression, not each condition separately/);
+ assert.match(html, /A remaining alliance obligation settles only at the actual Nexus close/);
+ assert.match(html, /forming an alliance does not fulfill it early/); assert.match(html, /Offers alone are not alliances/);
+ assert.match(html, /constant whole answer can already be complete without an alliance obligation/);
+ assert.match(html, /Fulfilled: this whole commitment is complete/); assert.match(html, /Released: this whole commitment is no longer possible/);
+ assert.match(html, /href="\/rules#truthtrance-mixed-alliance"/);
+ assert.doesNotMatch(html, /Poison Blade|foreign-partner|controlled-mixed-alliance|constant-event|factAnswers|data-testid="alliance-promise-guide"/);
+ assert.equal(MixedAlliancePromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
+});
+
+void test('exact mixed guide forwards owned reciprocal acceptance, preparation and Ready without inferring raw mixed residuals', () => {
+ for (const advanced of [false, true]) for (const kind of ['accept', 'break', 'ready'] as const) {
+ const game = projected(kind, advanced), sent: Action[] = [], button = guide({ game, busy: false, send(action) { sent.push(action); } });
+ assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.alliancePromiseGuide!.action);
+ assert.notEqual(game.active, game.me);
+ }
+ // The same raw OR question can have a private constant-true or an owned
+ // alliance residual. The client forwards the canonical server-owned guide.
+ const game = projected('ready'); game.players.find(player => player.id === 'p')!.ally = null;
+ game.players.find(player => player.id === 'r')!.ally = null;
+ game.mixedAlliancePromises[0].mixed = { kind: 'or', terms: [fact({ kind: 'spice', compare: 'gte', value: 9 }), partner()] };
+ Object.defineProperty(game.players.find(player => player.id === 'p')!, 'spice', { get() { throw Error('No current-fact reevaluation'); } });
+ const sent: Action[] = []; const button = guide({ game, busy: false, send(action) { sent.push(action); } });
+ assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.alliancePromiseGuide!.action);
+ const noConsent = projected(); noConsent.allianceOffers = {};
+ assert.equal(guide({ game: noConsent, busy: false, send() {} }).props.disabled, true);
+ const scalar = projected(); scalar.alliancePromises.push({ event: 'earlier-scalar', player: 'p', asker: 'a', turn: 2,
+ partner: 'r', answer: false }); assert.equal(guide({ game: scalar, busy: false, send() {} }).props.disabled, true);
+});
+
+void test('stale mixed callbacks preserve exact owner, event, public context, Nexus, quiet-window and room guards', () => {
+ const changes: ((game: GameView) => void)[] = [
+ game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 2; }, game => { game.status = 'setup'; },
+ game => { game.nexus = false; }, game => { game.ready = ['p']; }, game => { game.alliancePromiseNexus = null; },
+ game => { game.alliancePromiseNexus!.turn = 1; }, game => { game.allianceOffers = {}; }, game => { game.allianceOffers.a = 'p'; },
+ game => { game.alliancePromiseGuide = { action: { ...game.alliancePromiseGuide!.action } }; },
+ game => { game.alliancePromiseGuide = null; }, game => { game.alliancePromiseGuide!.action.target = null; },
+ game => { game.alliancePromiseGuide!.action.alliancePromiseEvent = 'other'; },
+ game => { game.mixedAlliancePromises[0].event = 'expired'; }, game => { game.mixedAlliancePromises[0].player = 'a'; },
+ game => { game.mixedAlliancePromises[0].turn = 1; }, game => { game.mixedAlliancePromises[0].fulfilled = true; },
+ game => { game.mixedAlliancePromises[0].released = true; }, game => { game.mixedAlliancePromises = []; },
+ game => { game.players.find(player => player.id === 'a')!.ally = 'r'; },
+ game => { game.players = game.players.filter(player => player.id !== 'r'); },
+ game => { game.spiceWindow = { territory: 'hagga_basin', sector: 12, amount: 6, harvested: false }; },
+ game => { game.phaseOpening = { passed: [] }; },
+ game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
+ game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
+ game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
+ game => { game.automaticContinuationPending = true; }, game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
+ game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
+ game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
+ game => { game.techTokens = createTechTokens(); },
+ ];
+ for (const change of changes) {
+ const game = projected(), sent: Action[] = [], button = guide({ game, busy: false, send(action) { sent.push(action); } });
+ assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
+ }
+ const busy = { game: projected(), busy: false, send() { assert.fail('Busy guide'); } };
+ const oldBusy = guide(busy); busy.busy = true; press(oldBusy);
+ const replaced = { game: projected(), busy: false, send() { assert.fail('Replaced projection'); } };
+ const oldReplaced = guide(replaced); replaced.game = { ...replaced.game }; press(oldReplaced);
+ for (const event of ['', 'unrelated']) {
+ const game = projected(); game.alliancePromiseGuide!.action.alliancePromiseEvent = event;
+ const button = guide({ game, busy: false, send() { assert.fail('Unknown event'); } }); assert.equal(button.props.disabled, true); press(button);
+ }
+});
+
+void test('one live scalar, pure or mixed panel owns the common guide while settled histories remain passive', () => {
+ const game = projected();
+ const all = () => renderToStaticMarkup(createElement('div', null,
+ createElement(AlliancePromises, { game, busy: false, send() {} }),
+ createElement(AllianceExpressionPromises, { game, busy: false, send() {} }),
+ createElement(MixedAlliancePromises, { game, busy: false, send() {} })));
+ const one = () => assert.equal((all().match(/data-testid="alliance-promise-guide"/g) ?? []).length, 1);
+ assert.match(markup(game), /data-testid="alliance-promise-guide"/); one();
+ game.alliancePromises.push({ event: 'scalar-history', player: 'p', asker: 'a', turn: 1, partner: 'r', answer: true, fulfilled: true });
+ game.allianceExpressionPromises.push({ event: 'pure-history', player: 'p', asker: 'a', turn: 1,
+ claim: { kind: 'partner', partner: 'r' }, answer: true, fulfilled: true });
+ assert.match(markup(game), /data-testid="alliance-promise-guide"/); one();
+ game.allianceExpressionPromises.push({ event: 'live-pure', player: 'p', asker: 'a', turn: 2,
+ claim: { kind: 'partner', partner: 'r' }, answer: true });
+ assert.doesNotMatch(markup(game), /data-testid="alliance-promise-guide"/); one();
+ game.alliancePromises.push({ event: 'live-scalar', player: 'p', asker: 'a', turn: 2, partner: 'r', answer: true });
+ assert.doesNotMatch(renderToStaticMarkup(createElement(AllianceExpressionPromises, { game, busy: false, send() {} })), /data-testid="alliance-promise-guide"/);
+ one(); game.alliancePromises[1].fulfilled = true; one();
+ game.allianceExpressionPromises[1].fulfilled = true; assert.match(markup(game), /data-testid="alliance-promise-guide"/); one();
+ game.mixedAlliancePromises[0].fulfilled = true; assert.doesNotMatch(all(), /data-testid="alliance-promise-guide"/);
+});
diff --git a/tests/truth-mixed-alliance-runtime.test.ts b/tests/truth-mixed-alliance-runtime.test.ts
new file mode 100644
index 00000000..647c0376
--- /dev/null
+++ b/tests/truth-mixed-alliance-runtime.test.ts
@@ -0,0 +1,170 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { truthFactAnswer } from '../game/truthtrance';
+import { truthKnowledgeOf } from '../game/truthtrance-knowledge';
+import {
+ actTruthMixedAlliance as act, answerTruthMixedAlliance as answer, askTruthMixedAlliance as ask,
+ bindTruthMixedAlliance as bind, closeTruthMixedAllianceNexus as close,
+ createTruthMixedAllianceFixture as fixture, declareTruthMixedAlliance as declare,
+ finishTruthMixedAlliance as finish, truthMixedAllianceFact as fact,
+ truthMixedAllianceGroup as group, truthMixedAllianceInventory as inventory,
+ truthMixedAllianceNexus as nexus, truthMixedAlliancePartner as partner,
+ truthMixedAlliancePlayer as player, truthMixedAlliancePromise as promise,
+} from './fixture-truth-alliance-expression';
+
+function rejected(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
+}
+function truthOnce(game: Game, truth: string) {
+ assert.equal(game.discard.filter(card => card.id === truth).length, 1);
+}
+
+for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} mixed final-alliance OR freezes genuine ten spice before a real debit and either consent path`, () => {
+ const f = fixture({ advanced, secondOffer: true });
+ assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); assert.ok(nexus(f.game));
+ assert.deepEqual(f.staging, []); assert.equal(f.originalCards.length, 33);
+ assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceActionCount, f.sourceCounts.inherited + 1);
+ assert.equal(truthFactAnswer(player(f.game, f.target), f.spiceFact), 'yes');
+ assert.equal(truthFactAnswer(player(f.game, f.target), f.shieldFact), 'yes');
+ const before = structuredClone(f.game); let game = bind(f).game; assert.deepEqual(f.game, before);
+ assert.deepEqual(promise(game).mixed, f.mixed); assert.equal(promise(game).fulfilled, false);
+ rejected(game, f.target, { type: 'ready' });
+ game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
+ assert.equal(player(game, f.target).spice, 7); assert.equal(player(game, f.asker).bribes, 3);
+ assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
+ assert.equal(promise(game).fulfilled, false); rejected(game, f.target, { type: 'ready' });
+ const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'alliance');
+ const selected = guide.action.target === f.asker ? f.partner : f.asker;
+ game = act(game, f.target, { type: 'alliance', target: selected });
+ assert.equal(player(game, f.target).ally, selected); assert.equal(promise(game).fulfilled, false);
+ game = close(game); assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
+ assert.equal(player(game, f.target).spice, 7); truthOnce(game, f.truth);
+ assert.deepEqual(inventory(finish(game)), f.originalCards);
+});
+
+void test('native true current fact OR final alliance is a fulfilled constant that still reopens only its respondent Ready', () => {
+ const f = fixture(), mixed = group('or', fact(f.shieldFact), partner(f.partner));
+ let game = act(f.game, f.target, { type: 'ready' }); game = act(game, f.partner, { type: 'ready' });
+ game = ask(declare(f, game).game, f.asker, f.target, mixed);
+ assert.deepEqual(viewGame(game, f.target).truthMixedAllianceAnswers, ['yes']);
+ rejected(game, f.target, { type: 'truthAnswer', answer: 'no' }); game = answer(game, f.target, 'yes');
+ assert.ok(!game.ready.includes(f.target)); assert.ok(game.ready.includes(f.partner));
+ assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
+ assert.equal(game.allianceExpressionPromises?.length ?? 0, 0);
+ assert.equal(viewGame(game, f.target).alliancePromiseGuide, null);
+ game = act(game, f.target, { type: 'alliance', target: f.partner }); game = close(game);
+ assert.equal(player(game, f.target).ally, f.partner); assert.equal(promise(game).fulfilled, true);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native false current fact AND final alliance has only constant No and keeps an actual later acceptance legal', () => {
+ const f = fixture(), mixed = group('and', fact(f.hajrFact), partner(f.partner));
+ assert.equal(truthFactAnswer(player(f.game, f.target), f.hajrFact), 'no');
+ let game = act(f.game, f.target, { type: 'ready' });
+ game = ask(declare(f, game).game, f.asker, f.target, mixed);
+ assert.deepEqual(viewGame(game, f.target).truthMixedAllianceAnswers, ['no']);
+ rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no');
+ assert.ok(!game.ready.includes(f.target)); assert.equal(promise(game).fulfilled, true);
+ assert.equal(game.allianceExpressionPromises?.length ?? 0, 0);
+ game = act(game, f.target, { type: 'alliance', target: f.partner }); game = close(game);
+ assert.equal(player(game, f.target).ally, f.partner); assert.equal(promise(game).fulfilled, true);
+ assert.equal(promise(game).released, false); truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+for (const answerValue of ['yes', 'no'] as const) void test(`native unknown forecast takes the conservative whole mixed alliance ${answerValue} obligation`, () => {
+ const f = fixture(), respondent = player(f.game, f.target), knowledge = truthKnowledgeOf(f.game, respondent);
+ assert.equal(knowledge.stormForecast, null); assert.equal(truthFactAnswer(respondent, f.forecastFact, knowledge), 'unknown');
+ const mixed = group(answerValue === 'yes' ? 'or' : 'and', fact(f.forecastFact), partner(f.partner));
+ let game = ask(declare(f).game, f.asker, f.target, mixed);
+ assert.deepEqual(viewGame(game, f.target).truthMixedAllianceAnswers, [answerValue]);
+ rejected(game, f.target, { type: 'truthAnswer', answer: answerValue === 'yes' ? 'no' : 'yes' });
+ game = answer(game, f.target, answerValue); assert.equal(promise(game).fulfilled, false);
+ if (answerValue === 'yes') {
+ rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, { type: 'alliance', target: f.partner });
+ } else {
+ game = act(game, f.target, { type: 'alliance', target: f.partner });
+ rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, { type: 'alliance', target: null });
+ }
+ assert.equal(promise(game).fulfilled, false); game = close(game); assert.equal(promise(game).fulfilled, true);
+ assert.equal(player(game, f.target).ally, answerValue === 'yes' ? f.partner : null);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native mixed alliance whole No negates every OR route and waits for the real final membership', () => {
+ const f = fixture({ consent: 'allied' }); let game = bind(f, 'no').game;
+ rejected(game, f.target, { type: 'ready' }); const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide);
+ assert.equal(guide.action.type, 'alliance'); assert.equal(guide.action.target, null); game = act(game, f.target, guide.action);
+ assert.equal(promise(game).fulfilled, false); game = act(game, f.asker, { type: 'alliance', target: f.target });
+ game = act(game, f.target, { type: 'alliance', target: f.asker }); rejected(game, f.target, { type: 'ready' });
+ game = act(game, f.target, { type: 'alliance', target: null }); game = close(game);
+ assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).ally, null);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native mixed alliance question projection and JSON guide retain owner turn event and hide frozen facts and residuals', () => {
+ const f = fixture(), declared = declare(f);
+ assert.ok(viewGame(declared.game, f.asker).truthMixedAllianceTargets.includes(f.target));
+ rejected(declared.game, f.partner, f.questionAction);
+ rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
+ rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question,
+ mixed: group('and', fact(f.spiceFact), partner(f.target)) } });
+ let game = ask(declared.game, f.asker, f.target, f.mixed);
+ assert.deepEqual(viewGame(game, f.target).truthMixedAllianceAnswers, ['yes', 'no']);
+ assert.equal(viewGame(game, f.asker).truthMixedAllianceAnswers, null);
+ assert.equal(viewGame(game, f.partner).truthMixedAllianceAnswers, null);
+ rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes');
+ for (const actor of [f.target, f.asker, f.partner]) {
+ const view = viewGame(game, actor); assert.deepEqual(view.allianceExpressionPromises, []);
+ assert.deepEqual(view.alliancePromises, []); assert.deepEqual(view.truthHistory.at(-1)?.question, f.question);
+ assert.equal(view.truthHistory.at(-1)?.answer, 'yes'); assert.equal(view.truthMixedAllianceAnswers, null);
+ if (actor === f.target) {
+ const row = promise(game); assert.deepEqual(Object.keys(row).sort(),
+ ['answer', 'asker', 'event', 'fulfilled', 'mixed', 'player', 'released', 'turn']);
+ assert.deepEqual(row.mixed, f.mixed); assert.equal(row.player, f.target); assert.equal(row.asker, f.asker);
+ assert.equal(row.turn, f.game.turn); assert.equal(row.answer, true); assert.equal(row.fulfilled, false);
+ assert.equal(row.released, false); assert.equal(view.alliancePromiseGuide?.action.alliancePromiseEvent, row.event);
+ } else {
+ assert.deepEqual(view.mixedAlliancePromises, []); assert.equal(view.alliancePromiseGuide, null);
+ }
+ }
+ const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide);
+ rejected(game, f.asker, guide.action);
+ rejected(game, f.target, { ...guide.action, alliancePromiseEvent: 'stale-mixed-alliance-event' });
+ const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, wire);
+ assert.equal(promise(game).fulfilled, false); rejected(game, f.target, wire); game = close(game);
+ assert.equal(promise(game).fulfilled, true); rejected(game, f.target, wire);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native mixed OR retains one surviving consent and releases only after the last real withdrawal without reactivation', () => {
+ const f = fixture({ secondOffer: true }); let game = bind(f).game;
+ game = act(game, f.partner, { type: 'alliance', target: null }); assert.equal(promise(game).released, false);
+ const guide = viewGame(game, f.target).alliancePromiseGuide; assert.ok(guide); assert.equal(guide.action.target, f.asker);
+ game = act(game, f.asker, { type: 'alliance', target: null }); assert.equal(promise(game).released, true);
+ assert.equal(promise(game).fulfilled, false); game = act(game, f.asker, { type: 'alliance', target: f.target });
+ rejected(game, f.target, guide.action); assert.equal(viewGame(game, f.target).alliancePromiseGuide, null);
+ game = close(game); assert.equal(promise(game).released, true); assert.equal(promise(game).fulfilled, false);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('all four minimal policies answer mixed alliance and perform legal real acceptance and Ready', () => {
+ const f = fixture({ secondOffer: true }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
+ for (const difficulty of DIFFICULTIES) {
+ const view = viewGame(asked, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
+ const answerAction = botActions(view).find(action => action.type === 'truthAnswer'); assert.ok(answerAction, difficulty);
+ assert.ok(answerAction.answer === 'yes' || answerAction.answer === 'no' || answerAction.answer === 'unknown');
+ assert.ok(view.truthMixedAllianceAnswers!.includes(answerAction.answer));
+ const answered = act(structuredClone(asked), f.target, answerAction); truthOnce(answered, f.truth);
+ let game = bind(f).game; const boundView = viewGame(game, f.target);
+ boundView.players.find(seat => seat.id === f.target)!.bot = difficulty;
+ const acceptance = botActions(boundView).find(action => action.type === 'alliance'); assert.ok(acceptance, difficulty);
+ game = act(game, f.target, acceptance); assert.ok(player(game, f.target).ally); assert.equal(promise(game).fulfilled, false);
+ const readyView = viewGame(game, f.target); readyView.players.find(seat => seat.id === f.target)!.bot = difficulty;
+ const ready = botActions(readyView).find(action => action.type === 'ready'); assert.ok(ready, difficulty);
+ game = act(game, f.target, ready); game = close(game); assert.equal(promise(game).fulfilled, true);
+ assert.deepEqual(inventory(game), f.originalCards);
+ }
+});