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