Honor group alliances in Truthtrance
Verify and publish checkpoint container / container (push) Successful in 11m22s

This commit is contained in:
Quintine committed 2026-10-29 00:41:56 +11:00
1 parent 9cb65a21f5
commit 3e0fa2a8a2
11 files changed
+592 -24

No files matched your search

+30 -7
View File
@@ -2,15 +2,25 @@
import type { Action, GameView } from '@/game/engine';
import { allianceClaimText, alliancePromiseModeSupported } from '@/game/alliance-promises';
import { evaluateAllianceExpression } from '@/game/alliance-expression-promises';
import { evaluateAllianceExpression, type AllianceExpression } from '@/game/alliance-expression-promises';
import { coalitionAllies, coalitionOf } from '@/game/coalition-alliance';
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,
players: game.players.map(player => ({ id: player.id, ally: player.ally, allySinceTurn: player.allySinceTurn })),
offers: game.allianceOffers, coalitionState: game.coalitionState,
});
/** Group membership is public. Evaluate only pure expressions here: mixed
* questions retain their privately compiled facts on the server. */
function expressionMatchesMembers(expression: AllianceExpression, allies: readonly string[]): boolean {
if (expression.kind === 'partner') return allies.includes(expression.partner);
return expression.kind === 'and' ? expression.terms.every(term => expressionMatchesMembers(term, allies))
: expression.terms.some(term => expressionMatchesMembers(term, allies));
}
/** 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) {
@@ -29,15 +39,27 @@ export function AlliancePromiseGuideButton(props: AlliancePromiseProps) {
!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);
const matches = (allies: readonly string[]) => live.every(promise => allies.includes(promise.partner) === promise.answer) &&
pure.every(promise => (allies.length <= 1 ? evaluateAllianceExpression(promise.claim, allies[0] ?? null)
: expressionMatchesMembers(promise.claim, allies)) === promise.answer);
let group: ReturnType<typeof coalitionOf> = null, allies = me?.ally ? [me.ally] : [];
try {
if (current.coalitionState && me) {
group = coalitionOf(current.players, current.coalitionState, actor);
allies = coalitionAllies(current.players, current.coalitionState, actor);
}
} catch { return false; }
let ownedAction = action.type === 'ready' && !!me && matches(allies);
if (action.type === 'coalitionLeave')
ownedAction = !!group && group.members.length >= 3 && !!current.coalitionState &&
current.coalitionState.proposal === null && action.expectedRevision === current.coalitionState.revision && matches([]);
if (action.type === 'alliance') {
if (action.target === null) ownedAction = true;
if ((group?.members.length ?? 0) >= 3) ownedAction = false;
else 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]);
ownedAction = !!quote.formed && matches(quote.allies[actor] ? [quote.allies[actor]!] : []);
} catch { ownedAction = false; }
}
}
@@ -55,6 +77,7 @@ export function AlliancePromiseGuideButton(props: AlliancePromiseProps) {
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.type === 'coalitionLeave' ? 'Leave your group alliance 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`}