Bind Truthtrance alliance promises to actual Nexus closure

This commit is contained in:
Quintine committed 2026-10-26 14:22:46 +11:00
1 parent 335b4ce638
commit 5627512d16
19 files changed
+1485 -30

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
'use client';
import { parseAllianceClaim, type AllianceClaim } from '@/game/alliance-promises';
type Partner = { id: string; name: string };
export function allianceClaimInputError(value: unknown, partners?: readonly Partner[]): string | null {
try {
const claim = parseAllianceClaim(value);
return partners && !partners.some(partner => partner.id === claim.partner)
? 'Choose one of the current alliance partners.' : null;
} catch (error) { return error instanceof Error ? error.message : 'Choose the current turn and another player.'; }
}
/** The parent supplies this Nexus turn and its current named-other choices. */
export function AllianceClaimFields(props: {
value: AllianceClaim;
onChange: (claim: AllianceClaim) => void;
partners: readonly Partner[];
disabled?: boolean;
}) {
const error = allianceClaimInputError(props.value, props.partners);
return <fieldset className="grid gap-2" disabled={props.disabled} aria-label="Final alliance at this Nexus close">
<legend>Final alliance at this Nexus close</legend>
<p>Turn {props.value.turn} · Current Nexus</p>
<label htmlFor="truth-alliance-partner">Named alliance partner</label>
<select id="truth-alliance-partner" disabled={props.disabled}
value={props.partners.some(partner => partner.id === props.value.partner) ? props.value.partner : ''}
onChange={event => {
const next = { ...props.value, partner: event.currentTarget.value };
if (!props.disabled && !allianceClaimInputError(next, props.partners)) props.onChange(parseAllianceClaim(next));
}}>
<option value="">Choose another player</option>
{props.partners.map(partner => <option key={partner.id} value={partner.id}>{partner.name}</option>)}
</select>
{error && <p role="alert">{error}</p>}
<p className="fine">
This asks about actual membership when this Nexus closes. Offers alone are not alliances.
A Yes needs an existing alliance or a real reciprocal invitation; it cannot promise the other player’s consent.
Otherwise No or unknown may apply, and the holder can ask again or save the card.
</p>
</fieldset>;
}
+81
View File
@@ -0,0 +1,81 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { allianceClaimText, alliancePromiseModeSupported } from '@/game/alliance-promises';
import { quoteNexusAlliance } from '@/game/nexus-alliance';
import { Button } from './ui/button';
type AlliancePromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
const publicContext = (game: GameView) => ({
players: game.players.map(player => ({ id: player.id, ally: player.ally })), offers: game.allianceOffers,
});
/** A guide may end a pair, accept real consent or confirm current membership.
* It never sends a proposal on another player's behalf. */
export function AlliancePromiseGuideButton(props: AlliancePromiseProps) {
const game = props.game, guide = game.alliancePromiseGuide, nexus = game.alliancePromiseNexus;
if (!guide || !nexus) return null;
const actor = game.me, turn = nexus.turn, action = guide.action;
const event = action.alliancePromiseEvent, signature = JSON.stringify(action);
const contextSignature = JSON.stringify(publicContext(game));
const canAct = () => {
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);
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);
} catch { ownedAction = false; }
}
}
return !props.busy && current === game && alliancePromiseModeSupported(current) && current.status === 'playing' &&
current.phase === 1 && current.nexus && !current.spiceWindow && !current.ready.includes(actor) &&
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 &&
!current.truthtrance && !current.response && !current.decision && !current.phaseOpening &&
!current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
const me = game.players.find(player => player.id === actor);
const partner = game.players.find(player => player.id === action.target)?.name ?? 'the named partner';
return <Button className="game-action min-h-11 whitespace-normal" data-testid="alliance-promise-guide"
disabled={!canAct()} onClick={() => { if (canAct()) props.send(action); }}>
{action.type === 'ready' ? 'Confirm Ready for this Nexus while honoring your answers'
: action.target === null ? me?.ally ? 'Break your current alliance while honoring your answers'
: 'Withdraw your alliance offer while honoring your answers'
: `Accept ${partner}’s alliance invitation while honoring your answers`}
</Button>;
}
export function AlliancePromises(props: AlliancePromiseProps) {
const own = props.game.alliancePromises.filter(promise => promise.player === props.game.me);
if (!own.length) return null;
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>
<ul>{own.map(promise => <li key={promise.event}>
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {allianceClaimText(promise, name)}.
{promise.released ? ' Released: this commitment is no longer possible.'
: promise.fulfilled ? ' Fulfilled at the Nexus close.' : ' This answer stays binding until this Nexus closes.'}
</li>)}</ul>
<p>
The answer concerns your final actual alliance membership at this Nexus close.
Yes remains live after forming an alliance and settles only at the close.
No requires ending without the named partner; a temporary alliance can still be ended before the close.
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 no owned route exists, No or 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">Alliance promise rules</a>.
</p>
<AlliancePromiseGuideButton {...props} />
</section>;
}
+2
View File
@@ -24,6 +24,7 @@ import {
liveShipmentPromises,
} from '@/game/shipment-promises';
import { ShipmentPromises } from './shipment-promises';
import { AlliancePromises } from './alliance-promises';
import { BribePromises } from './bribe-promises';
import { RevivalPromises } from './revival-promises';
import { BidPromises } from './bid-promises';
@@ -2034,6 +2035,7 @@ export function GameTable({
<ShipmentPromises game={g} act={act} busy={busy} />
)}
{g.status === 'playing' && <BribePromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <AlliancePromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <RevivalPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <BidPromises game={g} send={act} busy={busy} />}
{g.guildBetrayalReaction ? (
+26 -3
View File
@@ -1,5 +1,7 @@
'use client';
import { MovementClaimFields } from './movement-claim-fields';
import { AllianceClaimFields, allianceClaimInputError } from './alliance-claim-fields';
import type { AllianceClaim } from '@/game/alliance-promises';
import { BribeClaimFields, bribeClaimInputError } from './bribe-claim-fields';
import type { BribeClaim } from '@/game/bribe-promises';
import { RevivalClaimFields, revivalClaimInputError } from './revival-claim-fields';
@@ -59,8 +61,11 @@ 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'
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance'
>('fact');
const [alliancePartner, setAlliancePartner] = useState(g.me);
const allianceClaim: AllianceClaim = { turn: g.turn, partner: alliancePartner };
const alliancePartners = g.players.filter(player => player.id !== 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);
@@ -108,6 +113,7 @@ export function Truthtrance({
}
: kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' }
: kind === 'movement' ? { kind, target, ...movement }
: kind === 'alliance' ? { kind, target, ...allianceClaim }
: kind === 'bribe' ? { kind, target, ...bribeClaim }
: kind === 'revival' ? { kind, target, ...revivalClaim }
: kind === 'bid' ? { kind, target, ...bidClaim }
@@ -218,6 +224,13 @@ export function Truthtrance({
</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>
{(g.truthAllianceAnswers ?? []).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 === 'bribe' ? (
<>
<p className="notice">Yes requires cumulative ordinary bribe payments to this named recipient while possible. No keeps all later payments below the minimum. Earlier payments, gifts and allied aid do not count. You pay now; the recipient spends the receipt at Mentat Pause.</p>
@@ -356,6 +369,7 @@ export function Truthtrance({
Verified current facts
</option>
<option value="movement">Bind ground movement this turn</option>
<option value="alliance">Bind alliance membership at this Nexus close</option>
<option value="bribe">Bind ordinary bribe payments this turn</option>
<option value="revival">Bind ordinary force revivals this turn</option>
<option value="bid">Bind a bid in this ordinary lot</option>
@@ -374,7 +388,15 @@ export function Truthtrance({
)}
</select>
</label>
{kind === 'bribe' ? (
{kind === 'alliance' ? (
<>
<AllianceClaimFields value={allianceClaim} partners={alliancePartners} disabled={busy || !g.truthAllianceTargets.includes(target)}
onChange={claim => setAlliancePartner(claim.partner)} />
<p className="fine">The final actual partnership in the current ordinary Nexus is binding. Yes uses an already held partnership or an actual incoming invitation the respondent can accept; another player’s future consent is not invented. No may require breaking a temporary alliance before readiness.</p>
{!g.truthAllianceTargets.includes(target) && <p className="notice">Choose a supported participant in a quiet open ordinary Nexus. Optional modules and broader alliance questions remain unfinished.</p>}
<p className="notice" aria-label="Public alliance question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'bribe' ? (
<>
<BribeClaimFields value={bribeClaim} recipients={bribeRecipients} disabled={busy || !g.truthBribeTargets.includes(target)}
onChange={claim => setBribeSelection({ recipient: claim.recipient, minimum: claim.minimum })} />
@@ -496,6 +518,7 @@ export function Truthtrance({
'Ask publicly',
{ type: 'truthAsk', question },
(kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) ||
(kind === 'alliance' && (!!allianceClaimInputError(allianceClaim, alliancePartners) || !g.truthAllianceTargets.includes(target))) ||
(kind === 'bribe' && (!!bribeClaimInputError(bribeClaim, bribeRecipients) || !g.truthBribeTargets.includes(target))) ||
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
@@ -519,7 +542,7 @@ export function TruthHistory({ game: g }: { game: GameView }) {
<details className="notice truthtrance-history">
<summary>Truthtrance answers · Turn {g.turn}</summary>
<p className="fine">
Supported battle-plan, bid, revival, bribe, movement and reserve-shipment
Supported battle-plan, Nexus-alliance, bid, revival, bribe, movement and reserve-shipment
answers are enforced. Current-turn freeform promises remain binding when possible,
but their automatic enforcement is unfinished.
</p>