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