92 lines
6.0 KiB
TypeScript
92 lines
6.0 KiB
TypeScript
'use client';
|
||
|
||
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';
|
||
|
||
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);
|
||
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 && matches(quote.allies[actor]);
|
||
} 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, ...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;
|
||
};
|
||
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 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>
|
||
<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>
|
||
{live && <AlliancePromiseGuideButton {...props} />}
|
||
</section>;
|
||
}
|