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))) ||
|
||||
|
||||
Reference in new issue
Block a user