Support whole and mixed final Nexus alliance questions

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

No files matched your search

+116
View File
@@ -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 <fieldset key={path} data-alliance-expression-path={path} className="grid min-w-0 gap-2 p-2">
<legend>{group ? `Alliance group ${path} · ${node.kind.toUpperCase()}` : `Alliance condition ${path}`}</legend>
{group ? <>
<label htmlFor={`${id}-meaning`}>Alliance group {path} meaning</label>
<select id={`${id}-meaning`} value={node.kind} disabled={disabled} onChange={event => {
const kind = event.currentTarget.value;
if (kind === 'and' || kind === 'or') change({ ...node, kind });
}}><option value="and">All conditions · AND</option><option value="or">At least one condition · OR</option></select>
{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 });
}))}
<div className="flex flex-wrap gap-2">
<Button type="button" data-alliance-expression-control={`add-${path}`}
disabled={!allowed({ ...node, terms: [...node.terms, newLeaf()] })}
onClick={() => change({ ...node, terms: [...node.terms, newLeaf()] })}>Add alliance condition</Button>
{(['and', 'or'] as const).map(kind => {
const next: AllianceExpression = { ...node, terms: [...node.terms, { kind, terms: [newLeaf(), newLeaf()] }] };
return <Button key={kind} type="button" data-alliance-expression-control={`nested-${kind}-${path}`}
disabled={!allowed(next)} onClick={() => change(next)}>Add nested {kind.toUpperCase()} group</Button>;
})}
</div>
</> : <>
<label htmlFor={`${id}-partner`}>Named alliance partner</label>
<select id={`${id}-partner`} value={partners.some(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 });
}}>
<option value="">Choose a partner</option>
{partners.map(partner => <option key={partner.id} value={partner.id}>{partner.name}</option>)}
</select>
</>}
<div className="flex flex-wrap gap-2">
{(['and', 'or'] as const).map(kind => <Button key={kind} type="button" data-alliance-expression-control={`${kind}-${path}`}
disabled={!allowed(wrap(kind))} onClick={() => change(wrap(kind))}>Group with {kind.toUpperCase()}</Button>)}
{remove && <Button type="button" data-alliance-expression-control={`remove-${path}`} disabled={disabled}
onClick={() => commit(remove())}>Remove {group ? 'group' : 'condition'}</Button>}
</div>
</fieldset>;
};
return <fieldset disabled={disabled} data-testid="alliance-expression"
className="grid min-w-0 gap-2 [&_select]:min-w-0 [&_select]:max-w-full [&_button]:max-w-full [&_button]:min-h-11 [&_button]:h-auto [&_button]:py-2 [&_button]:whitespace-normal">
<legend>Final alliance expression · Current Nexus</legend>
<p className="fine">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.</p>
<p className="fine">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.</p>
<p className="fine">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.</p>
<p className="fine">{size.leaves} / {ALLIANCE_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {ALLIANCE_EXPRESSION_MAX_DEPTH} levels of grouping</p>
{error && <p role="alert">{error}</p>}
{render(value, '1', next => next)}
</fieldset>;
}
@@ -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 <section className="notice space-y-3" aria-label="Your Truthtrance alliance expression promises" id="alliance-expression-promise-guidance">
<h3>Your alliance expression promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
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.'}
</li>)}</ul>
<p>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.</p>
<p className="fine">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.
{' '}<a href="/rules#truthtrance-alliance-expression">Alliance expression rules</a>.</p>
{live && !scalarOwnsGuide && <AlliancePromiseGuideButton {...props} />}
</section>;
}
+14 -4
View File
@@ -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 <section className="notice space-y-3" aria-label="Your Truthtrance alliance promises" id="alliance-promise-guidance">
<h3>Your alliance promises</h3>
@@ -76,6 +86,6 @@ export function AlliancePromises(props: AlliancePromiseProps) {
Honor any earlier promises first; their own controls handle required payments.
{' '}<a href="/rules#truthtrance-alliance">Alliance promise rules</a>.
</p>
<AlliancePromiseGuideButton {...props} />
{live && <AlliancePromiseGuideButton {...props} />}
</section>;
}
+4
View File
@@ -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' && <BribeExpressionPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <MixedBribePromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <AlliancePromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <AllianceExpressionPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <MixedAlliancePromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <RevivalExpressionPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <MixedRevivalPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <LeaderRevivalPromises game={g} send={act} busy={busy} />}
+58
View File
@@ -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<AllianceActionClaim> = {
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 <>
<label htmlFor={`${id}-partner`}>Named alliance partner</label>
<select id={`${id}-partner`} value={valid ? value.partner : ''} disabled={disabled} aria-invalid={!valid}
onChange={event => {
const partner = event.currentTarget.value;
if (partner === '' || choices.some(choice => choice.id === partner)) onChange({ ...value, partner });
}}>
<option value="">Choose a partner</option>
{choices.map(partner => <option key={partner.id} value={partner.id}>{partner.name}</option>)}
</select>
</>;
},
};
export function mixedAllianceInputError(value: unknown, game: GameView, target: string): string | null {
return mixedActionInputError(value, game, target, config);
}
export function MixedAllianceFields(props: {
value: MixedActionExpression<AllianceActionClaim>;
game: GameView;
target: string;
disabled: boolean;
onChange: (expression: MixedActionExpression<AllianceActionClaim>) => void;
}) {
return MixedActionFields({ ...props, config });
}
+41
View File
@@ -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 <section className="notice space-y-3" aria-label="Your Truthtrance mixed alliance promises" id="mixed-alliance-promise-guidance">
<h3>Your mixed alliance promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
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.'}
</li>)}</ul>
<p>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.</p>
<p className="fine">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.
{' '}<a href="/rules#truthtrance-mixed-alliance">Mixed alliance rules</a>.</p>
{live && !sharedGuideElsewhere && <AlliancePromiseGuideButton {...props} />}
</section>;
}
+42 -2
View File
@@ -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<AllianceExpression>({ kind: 'or', terms: [
{ kind: 'partner', partner: g.me }, { kind: 'partner', partner: '' },
] });
const allianceExpressionError = allianceExpressionInputError(allianceExpression, alliancePartners);
const [mixedAlliance, setMixedAlliance] = useState<MixedActionExpression<AllianceActionClaim>>({ 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<Pick<BribeClaim, 'recipient' | 'minimum'>>({ 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 })}
</div>)}
</>
) : w.question!.kind === 'allianceExpression' || w.question!.kind === 'mixedAlliance' ? (
<>
<p className="notice">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.</p>
{((w.question!.kind === 'allianceExpression' ? g.truthAllianceExpressionAnswers : g.truthMixedAllianceAnswers) ?? []).map(answer => <div key={answer}>
{button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
</div>)}
</>
) : w.question!.kind === 'alliance' ? (
<>
<p className="notice">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.</p>
@@ -497,6 +519,8 @@ export function Truthtrance({
<option value="movementExpression">Combine ground moves with AND/OR</option>
<option value="mixedMovement">Combine current facts and future ground moves</option>
<option value="alliance">Bind alliance membership at this Nexus close</option>
<option value="allianceExpression">Combine final alliance partners with AND/OR</option>
<option value="mixedAlliance">Combine current facts and final alliance partners</option>
<option value="bribe">Bind ordinary bribe payments this turn</option>
<option value="bribeExpression">Combine ordinary bribe payments with AND/OR</option>
<option value="mixedBribe">Combine current facts and ordinary payments</option>
@@ -521,7 +545,21 @@ export function Truthtrance({
)}
</select>
</label>
{kind === 'mixedBid' ? (
{kind === 'allianceExpression' ? (
<>
<AllianceExpressionFields value={allianceExpression} partners={alliancePartners}
disabled={busy || !g.truthAllianceExpressionTargets.includes(target)} onChange={setAllianceExpression} />
{!g.truthAllianceExpressionTargets.includes(target) && <p className="notice">Choose a supported participant in a quiet ordinary Nexus.</p>}
<p className="notice" aria-label="Public whole alliance question preview">{allianceExpressionError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'mixedAlliance' ? (
<>
<MixedAllianceFields value={mixedAlliance} game={g} target={target}
disabled={busy || !g.truthMixedAllianceTargets.includes(target)} onChange={setMixedAlliance} />
{!g.truthMixedAllianceTargets.includes(target) && <p className="notice">Choose a supported participant in a quiet ordinary Nexus.</p>}
<p className="notice" aria-label="Public mixed alliance question preview">{mixedAllianceError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'mixedBid' ? (
<>
<MixedBidFields value={mixedBid} game={g} target={target}
disabled={busy || !g.truthMixedBidTargets.includes(target)} onChange={setMixedBid} />
@@ -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))) ||