Honor group alliances in Truthtrance
Verify and publish checkpoint container / container (push) Successful in 11m22s
Verify and publish checkpoint container / container (push) Successful in 11m22s
This commit is contained in:
1 parent
9cb65a21f5
commit
3e0fa2a8a2
11 files changed
+592
-24
No files matched your search
+45
-6
@@ -47,7 +47,7 @@ import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShi
|
||||
import { mixedTraitorRequirement, type MixedTraitorClaim } from './mixed-traitor-question';
|
||||
import type { MixedBattleActionClaim, MixedBattleClaim } from './mixed-battle-question';
|
||||
import { AllianceExpressionPromiseError, allianceExpressionOutcomes, findAllianceExpressionPlans,
|
||||
allianceExpressionPromisesMatch, evaluateAllianceExpression, validateAllianceExpressionPromises,
|
||||
allianceExpressionPromisesMatch, 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';
|
||||
@@ -16049,8 +16049,7 @@ function coalitionNexusAvailable(g: Game): boolean {
|
||||
g.expansions.length === 0 && g.players.every(seat =>
|
||||
['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction)) &&
|
||||
Object.values(g.aid).every(credit => credit.amount === 0) &&
|
||||
![g.alliancePromises, g.allianceExpressionPromises, g.bribePromises,
|
||||
g.bribeExpressionPromises, g.turnActionPromises].some(unsettledPromises);
|
||||
![g.bribePromises, g.bribeExpressionPromises, g.turnActionPromises].some(unsettledPromises);
|
||||
}
|
||||
function coalitionRule<T>(work: () => T): T {
|
||||
try { return work(); }
|
||||
@@ -16139,6 +16138,12 @@ function coalitionBidPaymentQuote(g: Game, p: Player, cost: number, contribution
|
||||
}
|
||||
/** The original native reciprocal quote owns identity and pairing legality. */
|
||||
function normalNexusAllianceQuote(g: Game, p: Player, target: string | null) {
|
||||
if (g.coalitionState) {
|
||||
const own = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
|
||||
const other = target ? coalitionRule(() => coalitionOf(g.players, g.coalitionState, target)) : null;
|
||||
requireRule(!(own && own.members.length >= 3) && !(other && other.members.length >= 3),
|
||||
'Group members must use the group alliance controls.');
|
||||
}
|
||||
if (target !== null) {
|
||||
const other = getPlayer(g, target);
|
||||
requireRule(other.id !== p.id, 'Choose another player.');
|
||||
@@ -16167,10 +16172,45 @@ 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 }));
|
||||
}
|
||||
/** A larger alliance has multiple allies, so final-Nexus answers must read
|
||||
* membership rather than the legacy single-partner scalar. */
|
||||
function allianceMember(g: Game, player: Player, partner: string): boolean {
|
||||
return g.coalitionState
|
||||
? coalitionRule(() => coalitionAllies(g.players, g.coalitionState, player.id)).includes(partner)
|
||||
: player.ally === partner;
|
||||
}
|
||||
function allianceExpressionValue(g: Game, player: Player, expression: AllianceExpression): boolean {
|
||||
if (expression.kind === 'partner') return allianceMember(g, player, expression.partner);
|
||||
return expression.kind === 'and'
|
||||
? expression.terms.every(term => allianceExpressionValue(g, player, term))
|
||||
: expression.terms.some(term => allianceExpressionValue(g, player, term));
|
||||
}
|
||||
function groupAllianceAnswersMatch(g: Game, player: Player, opportunity: AllianceOpportunity,
|
||||
promises: readonly AlliancePromise[], expressions: readonly AllianceExpressionPromise[]): boolean {
|
||||
const live = (row: AllianceCommitment) => row.player === player.id && row.turn === opportunity.turn &&
|
||||
row.source === opportunity.source && !row.fulfilled && !row.released;
|
||||
return promises.filter(live).every(row => allianceMember(g, player, row.partner) === row.answer) &&
|
||||
expressions.filter(live).every(row => allianceExpressionValue(g, player, row.claim) === row.answer);
|
||||
}
|
||||
function alliancePromisePlan(g: Game, p: Player, promises = g.alliancePromises ?? [],
|
||||
expressions = g.allianceExpressionPromises ?? []) {
|
||||
const opportunity = allianceOpportunity(g);
|
||||
if (!opportunity) return null;
|
||||
const group = g.coalitionState && coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
|
||||
if (group && group.members.length >= 3) {
|
||||
if (groupAllianceAnswersMatch(g, p, opportunity, promises, expressions))
|
||||
return {actions: [] as Action[], bribePreparation:false};
|
||||
if (g.coalitionState!.proposal || [g.bribePromises, g.bribeExpressionPromises, g.turnActionPromises].some(unsettledPromises))
|
||||
return null;
|
||||
const trial = structuredClone(g);
|
||||
try {
|
||||
applyCoalitionTransition(trial, coalitionRule(() => leaveCoalition(trial.players, trial.coalitionState,
|
||||
{actor:p.id, expectedRevision:trial.coalitionState!.revision})));
|
||||
if (groupAllianceAnswersMatch(trial, getPlayer(trial,p.id), opportunity, promises, expressions))
|
||||
return {actions: [{type:'coalitionLeave', expectedRevision:g.coalitionState!.revision} as Action], bribePreparation:false};
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
return null;
|
||||
}
|
||||
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
|
||||
@@ -16336,7 +16376,7 @@ function reconcileAlliancePromises(g: Game, voluntary?: { actor: string; action:
|
||||
if (promise.fulfilled || promise.released) continue;
|
||||
const same = opportunity && promise.turn === opportunity.turn && promise.source === opportunity.source;
|
||||
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 matches = (whole ? allianceExpressionValue(g, player, promise.claim) : allianceMember(g, player, 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; }
|
||||
@@ -16353,8 +16393,7 @@ 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) &&
|
||||
allianceExpressionPromisesMatch(g.allianceExpressionPromises ?? [], p.id, opportunity, p.ally),
|
||||
requireRule(groupAllianceAnswersMatch(g, p, opportunity, g.alliancePromises ?? [], g.allianceExpressionPromises ?? []),
|
||||
'Match your Truthtrance alliance answers before finishing this Nexus.');
|
||||
if (liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn)
|
||||
.some(promise => promise.alliance?.source === opportunity.source && !promise.alliance.closed))
|
||||
|
||||
+1
-1
@@ -4505,7 +4505,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. Strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'The printed option and provisional two-controller precedence are recorded.', evidence: ['docs/OPTIONAL_ALLIANCE_VICTORY.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, four-member unlimited admission, funding and victory, staged larger-group faction helpers, funding after settled Truthtrance records, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-unlimited-engine.test.ts', 'tests/coalition-unlimited-powers.test.ts', 'tests/coalition-unlimited-emperor.test.ts', 'tests/coalition-nexus-engine.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, four-member unlimited admission, funding and victory, staged larger-group faction helpers and alliance Truthtrance, funding after settled Truthtrance records, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-unlimited-engine.test.ts', 'tests/coalition-unlimited-powers.test.ts', 'tests/coalition-unlimited-emperor.test.ts', 'tests/coalition-truth-alliance.test.ts', 'tests/coalition-truth-alliance-controls.test.tsx', 'tests/coalition-truth-bribe-targets.test.ts', 'tests/coalition-unlimited-worm-engine.test.ts', 'tests/coalition-nexus-engine.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
|
||||
],
|
||||
related: [
|
||||
'mentat',
|
||||
|
||||
+13
-7
@@ -62,6 +62,7 @@ import { leaders } from './cards';
|
||||
import { combatLocationName } from './combat-location';
|
||||
import { parseForceCountFact, forceCountFactMatches, forceCountFactText, forceCountCounterError, type ForceCountFact, type ForceCountPlayer } from './truthtrance-force-count';
|
||||
import type { Action, Game, Player } from './engine';
|
||||
import { coalitionAllies } from './coalition-alliance';
|
||||
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
|
||||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||||
import {
|
||||
@@ -258,6 +259,11 @@ function battleLeaderIdentities(g: Game) {
|
||||
...(g.dukeVidal?.leader.id === DUKE_VIDAL_ID ? [g.dukeVidal.leader] : []),
|
||||
];
|
||||
}
|
||||
function nonAlliedBribeRecipient(g: Game, respondent: Player, recipient: string): boolean {
|
||||
return recipient !== respondent.id && recipient !== respondent.ally &&
|
||||
g.players.some(player => player.id === recipient) &&
|
||||
(!g.coalitionState || !coalitionAllies(g.players, g.coalitionState, respondent.id).includes(recipient));
|
||||
}
|
||||
function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
const v = record(value);
|
||||
check(
|
||||
@@ -341,8 +347,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
const respondent = g.players.find(player => player.id === v.target)!;
|
||||
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), input => {
|
||||
const action = parseBribePaymentClaim(input);
|
||||
check(action.recipient !== respondent.id && action.recipient !== respondent.ally &&
|
||||
g.players.some(player => player.id === action.recipient), 'Choose another current non-allied ordinary bribe recipient.');
|
||||
check(nonAlliedBribeRecipient(g, respondent, action.recipient), 'Choose another current non-allied ordinary bribe recipient.');
|
||||
return action;
|
||||
});
|
||||
return { kind: 'mixedBribe', target: v.target as string, turn: g.turn, mixed };
|
||||
@@ -354,7 +359,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
const respondent = g.players.find(player => player.id === v.target)!;
|
||||
check(bribePromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 8 &&
|
||||
claim.turn === g.turn && bribeExpressionRecipients(claim.claim).every(recipient =>
|
||||
recipient !== respondent.id && recipient !== respondent.ally && g.players.some(player => player.id === recipient)) &&
|
||||
nonAlliedBribeRecipient(g, respondent, recipient)) &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama,
|
||||
'Choose current-turn ordinary bribe statements to non-allied participants in a supported table.');
|
||||
return { kind: 'bribeExpression', target: v.target, ...claim };
|
||||
@@ -364,8 +369,8 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
'Supply the current turn, named bribe recipient, minimum and respondent.');
|
||||
const claim = parseBribeClaim(v), respondent = g.players.find(player => player.id === v.target)!;
|
||||
check(bribePromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 8 &&
|
||||
claim.turn === g.turn && claim.recipient !== respondent.id && claim.recipient !== respondent.ally &&
|
||||
g.players.some(player => player.id === claim.recipient) && !g.response && !g.decision && !g.phaseOpening &&
|
||||
claim.turn === g.turn && nonAlliedBribeRecipient(g, respondent, claim.recipient) &&
|
||||
!g.response && !g.decision && !g.phaseOpening &&
|
||||
!g.pendingTreacheryDiscard && !g.pendingKarama,
|
||||
'Choose a current-turn ordinary bribe to another non-allied participant in a supported table.');
|
||||
return { kind: 'bribe', target: v.target, ...claim };
|
||||
@@ -482,8 +487,8 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
'Choose the original owned native Voice or Prescience declaration opportunity.');
|
||||
}
|
||||
}
|
||||
if (action.kind === 'payment') check(action.recipient !== respondent.id && action.recipient !== respondent.ally &&
|
||||
g.players.some(player => player.id === action.recipient), 'Choose another current non-allied ordinary bribe recipient.');
|
||||
if (action.kind === 'payment') check(nonAlliedBribeRecipient(g, respondent, action.recipient),
|
||||
'Choose another current non-allied ordinary bribe recipient.');
|
||||
if (action.kind === 'bid') check(bidPromiseModeSupported(g) && g.phase === 3 && g.auction &&
|
||||
!g.currentAuctionSale && !g.richeseAuction && !g.ixAuction && action.lot === g.auction.index + 1,
|
||||
'Choose the current ordinary bid lot in a supported native auction.');
|
||||
@@ -491,6 +496,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
action.leader === 'kwisatz' && g.advanced && respondent.faction === 'atreides',
|
||||
'Choose a personal native faction leader, or the Advanced Atreides Kwisatz Haderach.');
|
||||
if (action.kind === 'alliance') check(alliancePromiseModeSupported(g) && g.phase === 1 && g.nexus &&
|
||||
!g.coalitionState?.groups.some(group => group.members.length >= 3) &&
|
||||
!g.spiceWindow && !g.spiceResolution && action.partner !== respondent.id &&
|
||||
g.players.some(player => player.id === action.partner),
|
||||
'Choose another seated final alliance partner in the current quiet native Nexus.');
|
||||
|
||||
Reference in new issue
Block a user