Combine current facts with native battle plans
This commit is contained in:
1 parent
05330ca7c9
commit
812f77e348
24 files changed
+1615
-42
No files matched your search
@@ -14,6 +14,8 @@ export type PlanClaim =
|
||||
| { kind: 'kwisatz'; use: boolean }
|
||||
| { kind: 'and' | 'or'; terms: PlanClaim[] };
|
||||
export type BattlePromise = {
|
||||
/** Optional link for new mixed questions; older pure records retain their shape. */
|
||||
event?: string;
|
||||
player: string;
|
||||
asker: string;
|
||||
claim: PlanClaim;
|
||||
|
||||
+10
-1
@@ -1143,7 +1143,12 @@ function plans(g: GameView): Action[] {
|
||||
);
|
||||
if (b.compliantPlan && commitments.every(
|
||||
(element) => fixed(element.field, b.compliantPlan![element.field]),
|
||||
)) ranked.push({ type: 'battlePlan', ...b.compliantPlan });
|
||||
)) {
|
||||
const action: Action = { type: 'battlePlan', ...b.compliantPlan };
|
||||
if ((g.mixedBattlePromises ?? []).some(promise => promise.player === me.id && promise.turn === g.turn &&
|
||||
promise.battle === b.event && !promise.fulfilled && !promise.released)) ranked.unshift(action);
|
||||
else ranked.push(action);
|
||||
}
|
||||
return ecazOccupyPolicyActions(g, ranked);
|
||||
}
|
||||
|
||||
@@ -1320,6 +1325,10 @@ function policyActions(g: GameView): Action[] {
|
||||
return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }];
|
||||
if (w.stage === 'answer') {
|
||||
if (w.question?.target !== me.id) return [];
|
||||
if (w.question.kind === 'mixedBattlePlan') {
|
||||
const answer = g.truthMixedBattleAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'mixedBid') {
|
||||
const answer = g.truthMixedBidAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
|
||||
+73
-1
@@ -1,3 +1,4 @@
|
||||
import type { MixedBattleActionClaim, MixedBattleClaim } from './mixed-battle-question';
|
||||
import { AllianceExpressionPromiseError, allianceExpressionOutcomes, findAllianceExpressionPlans,
|
||||
allianceExpressionPromisesMatch, evaluateAllianceExpression, validateAllianceExpressionPromises,
|
||||
type AllianceExpression, type AllianceExpressionClaim, type AllianceExpressionPromise } from './alliance-expression-promises';
|
||||
@@ -2160,6 +2161,10 @@ export type Game = {
|
||||
};
|
||||
truthtrance?: TruthWindow | null;
|
||||
truthHistory?: TruthRecord[];
|
||||
mixedBattleQuestions?: (Omit<MixedBattleClaim, 'battle'> & {
|
||||
event: string; battle: string; turn: number; player: string; asker: string; answer: boolean;
|
||||
binding: boolean; fulfilled: boolean; released: boolean;
|
||||
})[];
|
||||
shipmentPromises?: ShipmentPromise[];
|
||||
bidPromises?: BidPromise[];
|
||||
/** Raw mixed question linked to one private native current-lot obligation. */
|
||||
@@ -6932,6 +6937,7 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
|
||||
battle: g.battle,
|
||||
shipmentPromises: g.shipmentPromises,
|
||||
movementPromises: g.movementPromises,
|
||||
...(next.record.question.kind === 'mixedBattlePlan' ? { mixedBattleQuestions: g.mixedBattleQuestions } : {}),
|
||||
...(['allianceExpression', 'mixedAlliance'].includes(next.record.question.kind) ? {
|
||||
allianceExpressionPromises: g.allianceExpressionPromises, alliancePromiseSequence: g.alliancePromiseSequence,
|
||||
mixedAllianceQuestions: g.mixedAllianceQuestions,
|
||||
@@ -27559,10 +27565,68 @@ function bindBattleTruth(
|
||||
answer,
|
||||
});
|
||||
}
|
||||
function compileMixedBattle(g: Game, p: Player, claim: MixedBattleClaim, answer: boolean) {
|
||||
const compiled = compileMixedActionExpression(claim.mixed,
|
||||
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
|
||||
if (compiled.kind === 'constant') return { constant: compiled.value };
|
||||
const plan = (node: CompiledMixedAction<MixedBattleActionClaim>): PlanClaim => {
|
||||
if (node.kind === 'action') return { ...node.action };
|
||||
requireRule(node.kind !== 'constant', 'Retain the privately compiled mixed battle-plan obligation.');
|
||||
return { kind: node.kind, terms: node.terms.map(plan) };
|
||||
};
|
||||
return { residual: plan(compiled) };
|
||||
}
|
||||
function mixedBattleTruthAnswers(g: Game, p: Player, claim: MixedBattleClaim): TruthAnswer[] {
|
||||
const b = g.battle;
|
||||
if (g.phase !== 6 || !b || claim.battle !== (b.event ?? null) || claim.territory !== b.territory ||
|
||||
![b.attacker, b.defender].includes(p.id)) return [];
|
||||
const answers: TruthAnswer[] = [];
|
||||
for (const answer of [true, false]) {
|
||||
const compiled = compileMixedBattle(g, p, claim, answer);
|
||||
if (compiled.constant !== undefined ? compiled.constant === answer :
|
||||
battleTruthAnswers(g, p, compiled.residual!).includes(answer ? 'yes' : 'no')) answers.push(answer ? 'yes' : 'no');
|
||||
}
|
||||
return answers.length ? answers : ['unknown'];
|
||||
}
|
||||
function bindMixedBattleTruth(g: Game, p: Player, claim: MixedBattleClaim, answer: boolean, asker: string) {
|
||||
const b = g.battle;
|
||||
requireRule(b && typeof b.event === 'string' && b.event === claim.battle &&
|
||||
mixedBattleTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
|
||||
'Choose a mixed battle-plan answer compatible with this actual battle and earlier commitments.');
|
||||
const compiled = compileMixedBattle(g, p, claim, answer), event = crypto.randomUUID();
|
||||
const before = b.truthPromises?.length ?? 0;
|
||||
if (compiled.residual) bindBattleTruth(g, p, compiled.residual, answer, asker);
|
||||
const bound = b.truthPromises?.[before];
|
||||
if (bound) bound.event = event;
|
||||
(g.mixedBattleQuestions ??= []).push({ event, battle: b.event, territory: claim.territory, turn: g.turn,
|
||||
mixed: structuredClone(claim.mixed), player: p.id, asker, answer,
|
||||
binding: !!bound, fulfilled: !bound, released: false });
|
||||
}
|
||||
function reconcileMixedBattleQuestions(g: Game) {
|
||||
const b = g.battle;
|
||||
for (const question of g.mixedBattleQuestions ?? []) {
|
||||
if (!question.binding || question.fulfilled || question.released) continue;
|
||||
if (!b || b.event !== question.battle) { question.released = true; continue; }
|
||||
const bound = b.truthPromises?.find(promise => promise.event === question.event);
|
||||
if (bound?.released) question.released = true;
|
||||
else if (b.revealed && bound) question.fulfilled = true;
|
||||
}
|
||||
}
|
||||
function isMixedBattleEvent(g: Game, event: string | undefined): boolean {
|
||||
return typeof event === 'string' && !!g.mixedBattleQuestions?.some(question => question.event === event);
|
||||
}
|
||||
function projectedMixedBattlePromises(g: Game, player: string) {
|
||||
return (g.mixedBattleQuestions ?? []).filter(question => question.player === player).map(question => ({
|
||||
event: question.event, battle: question.battle, territory: question.territory, turn: question.turn,
|
||||
player: question.player, asker: question.asker, mixed: structuredClone(question.mixed), answer: question.answer,
|
||||
fulfilled: question.fulfilled, released: question.released,
|
||||
}));
|
||||
}
|
||||
function reconcileBattlePromises(
|
||||
g: Game,
|
||||
voluntary?: { actor: string; action: Action },
|
||||
) {
|
||||
reconcileMixedBattleQuestions(g);
|
||||
if (g.pendingKull || g.pendingRicheseBetrayal) return;
|
||||
const b = g.battle;
|
||||
if (
|
||||
@@ -27597,6 +27661,7 @@ function reconcileBattlePromises(
|
||||
);
|
||||
}
|
||||
}
|
||||
reconcileMixedBattleQuestions(g);
|
||||
}
|
||||
function feasiblePrescience(
|
||||
g: Game,
|
||||
@@ -36778,6 +36843,8 @@ function applyActionInner(
|
||||
bindBid: bindBidTruth,
|
||||
traitorAnswers: traitorTruthAnswers,
|
||||
bindTraitor: bindTraitorTruth,
|
||||
mixedBattleAnswers: mixedBattleTruthAnswers,
|
||||
bindMixedBattle: bindMixedBattleTruth,
|
||||
battleAnswers: battleTruthAnswers,
|
||||
bindBattle: bindBattleTruth,
|
||||
cardBlock: transferCardBlock,
|
||||
@@ -40195,6 +40262,7 @@ function applyActionInner(
|
||||
revealPlayerNoField(g, owner, 'battle');
|
||||
}
|
||||
b.revealed = true;
|
||||
reconcileMixedBattleQuestions(g);
|
||||
for (const plan of Object.values(b.plans)) {
|
||||
const l = g.players
|
||||
.flatMap((p) => p.leaders)
|
||||
@@ -40605,6 +40673,10 @@ export function viewGame(state: Game, id: string) {
|
||||
)
|
||||
? findShipmentCompletion(g, me)
|
||||
: null,
|
||||
truthMixedBattleTargets: g.phase === 6 && g.battle ? [g.battle.attacker, g.battle.defender] : [],
|
||||
truthMixedBattleAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'mixedBattlePlan' ? mixedBattleTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
mixedBattlePromises: projectedMixedBattlePromises(g, id),
|
||||
truthBattleAnswers:
|
||||
g.truthtrance?.stage === 'answer' &&
|
||||
g.truthtrance.question?.target === id &&
|
||||
@@ -41701,7 +41773,7 @@ export function viewGame(state: Game, id: string) {
|
||||
ownForces: [b.attacker, b.defender].includes(id)
|
||||
? planCombatForces(g, me, getPlayer(g, b.attacker === id ? b.defender : b.attacker))
|
||||
: null,
|
||||
truthPromises: b.truthPromises ?? [],
|
||||
truthPromises: (b.truthPromises ?? []).filter(promise => !isMixedBattleEvent(g, promise.event)),
|
||||
compliantPlan:
|
||||
completion &&
|
||||
!completion.actions.length &&
|
||||
|
||||
@@ -12,9 +12,36 @@ export type CompiledMixedAction<A> =
|
||||
| { kind: 'action'; action: A }
|
||||
| { kind: 'and' | 'or'; terms: CompiledMixedAction<A>[] };
|
||||
|
||||
export type MixedActionExpressionLimits = { maxDepth?: number; maxLeaves?: number };
|
||||
export type MixedActionExpressionLimits = {
|
||||
maxDepth?: number;
|
||||
maxLeaves?: number;
|
||||
maxNodes?: number;
|
||||
maxTerms?: number;
|
||||
};
|
||||
export class MixedActionQuestionError extends Error {}
|
||||
|
||||
type ExpressionSize = { leaves: number; depth: number; nodes: number; maxTerms: number };
|
||||
const leafSize = (): ExpressionSize => ({ leaves: 1, depth: 0, nodes: 1, maxTerms: 0 });
|
||||
function groupSize(children: ExpressionSize[]): ExpressionSize {
|
||||
return {
|
||||
leaves: children.reduce((sum, child) => sum + child.leaves, 0),
|
||||
depth: 1 + Math.max(0, ...children.map(child => child.depth)),
|
||||
nodes: 1 + children.reduce((sum, child) => sum + child.nodes, 0),
|
||||
maxTerms: Math.max(children.length, ...children.map(child => child.maxTerms)),
|
||||
};
|
||||
}
|
||||
function factSize(fact: TruthFact): ExpressionSize {
|
||||
return fact.kind === 'and' || fact.kind === 'or'
|
||||
? groupSize(fact.terms.map(factSize)) : leafSize();
|
||||
}
|
||||
|
||||
/** A fact wrapper contributes its actual logical subtree, not a second node. */
|
||||
export function mixedActionExpressionSize<A>(mixed: MixedActionExpression<A>): ExpressionSize {
|
||||
if (mixed.kind === 'fact') return factSize(mixed.fact);
|
||||
if (mixed.kind === 'action') return leafSize();
|
||||
return groupSize(mixed.terms.map(mixedActionExpressionSize));
|
||||
}
|
||||
|
||||
function check(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new MixedActionQuestionError(message);
|
||||
}
|
||||
@@ -36,19 +63,31 @@ export function parseMixedActionExpression<A>(
|
||||
const maxDepth = limits.maxDepth ?? 4;
|
||||
const maxLeaves = limits.maxLeaves ?? 16;
|
||||
check(Number.isSafeInteger(maxDepth) && maxDepth >= 0 &&
|
||||
Number.isSafeInteger(maxLeaves) && maxLeaves >= 1,
|
||||
Number.isSafeInteger(maxLeaves) && maxLeaves >= 1 &&
|
||||
(limits.maxNodes === undefined || Number.isSafeInteger(limits.maxNodes) && limits.maxNodes >= 1) &&
|
||||
(limits.maxTerms === undefined || Number.isSafeInteger(limits.maxTerms) && limits.maxTerms >= 2),
|
||||
'Choose valid logical depth and clause limits.');
|
||||
let leaves = 0;
|
||||
let nodes = 0;
|
||||
let hasFact = false;
|
||||
let hasAction = false;
|
||||
const count = (depth: number, leaf: boolean): void => {
|
||||
check(depth <= maxDepth && (!leaf || ++leaves <= maxLeaves),
|
||||
`Truthtrance supports at most ${maxLeaves} logical clauses and ${maxDepth} levels of grouping.`);
|
||||
check(limits.maxNodes === undefined || ++nodes <= limits.maxNodes,
|
||||
`Truthtrance supports at most ${limits.maxNodes} logical nodes, including groups.`);
|
||||
};
|
||||
const countTerms = (terms: readonly unknown[]): void => {
|
||||
check(limits.maxTerms === undefined || terms.length <= limits.maxTerms,
|
||||
`Truthtrance supports at most ${limits.maxTerms} terms in each AND/OR group.`);
|
||||
};
|
||||
const countFact = (fact: TruthFact, depth: number): void => {
|
||||
const group = fact.kind === 'and' || fact.kind === 'or';
|
||||
count(depth, !group);
|
||||
if (group) for (const term of fact.terms) countFact(term, depth + 1);
|
||||
if (group) {
|
||||
countTerms(fact.terms);
|
||||
for (const term of fact.terms) countFact(term, depth + 1);
|
||||
}
|
||||
};
|
||||
const parse = (input: unknown, depth: number): MixedActionExpression<A> => {
|
||||
const node = record(input);
|
||||
@@ -72,6 +111,7 @@ export function parseMixedActionExpression<A>(
|
||||
(node.kind === 'and' || node.kind === 'or') && Array.isArray(node.terms) &&
|
||||
node.terms.length >= 2 && node.terms.length <= maxLeaves,
|
||||
'Join two or more current facts or future actions with AND or OR.');
|
||||
countTerms(node.terms);
|
||||
return { kind: node.kind, terms: node.terms.map(term => parse(term, depth + 1)) };
|
||||
};
|
||||
const mixed = parse(value, 0);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
BATTLE_CLAIM_MAX_DEPTH, BATTLE_CLAIM_MAX_NODES, BATTLE_CLAIM_MAX_TERMS,
|
||||
} from './battle-claim-expression';
|
||||
import { parsePlanClaim, PlanClaimError, type PlanClaim } from './battle-promises';
|
||||
import type { MixedActionExpression } from './mixed-action-question';
|
||||
|
||||
export const MIXED_BATTLE_MAX_NODES = BATTLE_CLAIM_MAX_NODES;
|
||||
export const MIXED_BATTLE_MAX_TERMS = BATTLE_CLAIM_MAX_TERMS;
|
||||
export const MIXED_BATTLE_MAX_DEPTH = BATTLE_CLAIM_MAX_DEPTH;
|
||||
export const MIXED_BATTLE_MAX_LEAVES = BATTLE_CLAIM_MAX_NODES;
|
||||
|
||||
export type MixedBattleActionClaim = Exclude<PlanClaim, { kind: 'and' | 'or' }>;
|
||||
export type MixedBattleClaim = {
|
||||
battle: string | null;
|
||||
territory: string;
|
||||
mixed: MixedActionExpression<MixedBattleActionClaim>;
|
||||
};
|
||||
|
||||
/** One native plan leaf only; mixed groups share the outer logical budget. */
|
||||
export function parseBattleActionClaim(
|
||||
value: unknown,
|
||||
leaders: readonly string[],
|
||||
): MixedBattleActionClaim {
|
||||
const fail = (): never => {
|
||||
throw new PlanClaimError('Supply one original battle-plan condition without metadata or groups.');
|
||||
};
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return fail();
|
||||
const node = value as Record<string, unknown>;
|
||||
const keys = node.kind === 'dial' || node.kind === 'support' ? 'compare,kind,value'
|
||||
: node.kind === 'weapon' || node.kind === 'defense' ? 'kind,name'
|
||||
: node.kind === 'leader' ? 'kind,leader'
|
||||
: node.kind === 'kwisatz' ? 'kind,use' : null;
|
||||
if (keys === null || Object.keys(node).sort().join(',') !== keys) return fail();
|
||||
const action = parsePlanClaim(node, [...leaders]);
|
||||
if (action.kind === 'dial' || action.kind === 'support' || action.kind === 'weapon' ||
|
||||
action.kind === 'defense' || action.kind === 'leader' || action.kind === 'kwisatz') return action;
|
||||
return fail();
|
||||
}
|
||||
@@ -3699,6 +3699,32 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-mixed-battle',
|
||||
title: 'Truthtrance: current facts and this battle plan',
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Combine answer-time facts with native current-battle plan conditions while keeping legal preparations, fixed choices and private aggregate meaning.',
|
||||
steps: [
|
||||
'Choose Combine current facts and this battle plan, another combatant, and full AND/OR groups containing both fact and plan conditions. Dial, spice support, weapon, defense, leader/hero and Kwisatz use keep their native meanings.',
|
||||
'Current facts freeze at the actual answer. Holding a card or a particular spice amount then does not promise keeping it afterward. A real later payment or played card never creates a new factual answer.',
|
||||
'Yes makes the complete expression true; No negates the whole expression. A definite Yes cannot rely on an unknown fact being true, and No cannot rely on it being false. If neither guaranteed completion is currently available, the unanswered card can be saved or asked differently.',
|
||||
'A fact may already settle the aggregate, needing no plan commitment. Otherwise the remaining conditions use the original legal battle-plan search, earlier promises, Voice, Prescience, inspections, owned preparations and normal funding. Alternatives remain available; no fixed route is chosen by the answer.',
|
||||
'An open obligation stays pending through previews and your own sealing. It fulfills at the actual native plan reveal before battle cleanup. Already submitted or revealed plans are fixed, so a truthful answer about that plan is immediately complete. A later native impossibility releases a binding answer.',
|
||||
'Native controls can fill a compliant draft or complete the next owned preparation; Fill does not seal automatically. The original Seal action still checks the whole authoritative obligation. Raw question/aggregate status stays visible after the battle while reduced clauses and frozen facts stay private.',
|
||||
'One raw logical budget counts fact and plan groups together: sixteen total clauses, at most eight children per group, and four grouping levels. Other mixed question families retain their existing limits. These are input limits rather than printed restrictions.',
|
||||
'The current battle and territory are fixed by the question. Existing older battles lacking an identity receive one only in an actual accepted Ask transaction. Four minimal profiles use native answer and compliant plan/preparation feedback; full strategy and wider composition acceptance remain unfinished.',
|
||||
],
|
||||
related: ['card-truthtrance', phaseRuleId(6), 'truthtrance-mixed-revival', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Shared fact snapshot compiles only a needed native plan residual. Real reveal settles open aggregates before synchronous cleanup; fixed-plan/constant answers need no future binding.', evidence: ['game/mixed-battle-question.ts', 'game/mixed-action-question.ts', 'game/battle-promises.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Shared full editor with native raw budget, exact preview/Ask, private answers, persistent raw aggregate and guarded native preparation/Fill controls are connected.', evidence: ['components/mixed-action-fields.tsx', 'components/mixed-battle-fields.tsx', 'components/mixed-battle-promises.tsx', 'components/battle-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-mixed-battle-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and prioritize the private native compliant fallback when mixed constraints are hidden. Strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-mixed-battle-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Snapshot/native plan interval, input budgets, reveal settlement, older battle identity and remaining source/composition limits are explicit.', evidence: ['docs/TRUTHTRANCE_MIXED_BATTLE.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Pure parser/budget, original Basic battle/payment/Prescience and controlled native assistance cases; wider Advanced/faction/card preparation and final deployed acceptance remain open.', evidence: ['tests/mixed-battle-question.test.ts', 'tests/mixed-action-question.test.ts', 'tests/fixture-truth-mixed-battle.ts', 'tests/truth-mixed-battle-runtime.test.ts', 'tests/truth-mixed-battle-funding.test.ts', 'tests/truth-mixed-battle-controls.test.tsx'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'card-truthtrance',
|
||||
title: 'Truthtrance',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { parseBattleActionClaim, MIXED_BATTLE_MAX_NODES, MIXED_BATTLE_MAX_TERMS, MIXED_BATTLE_MAX_DEPTH,
|
||||
MIXED_BATTLE_MAX_LEAVES, type MixedBattleClaim } from './mixed-battle-question';
|
||||
import { parseAllianceExpressionClaim, allianceExpressionText, type AllianceExpressionClaim } from './alliance-expression-promises';
|
||||
import { parseAllianceActionClaim, type MixedAllianceClaim } from './mixed-alliance-question';
|
||||
import { parseBidActionClaim, MIXED_BID_MAX_DEPTH, MIXED_BID_MAX_LEAVES, type MixedBidClaim } from './mixed-bid-question';
|
||||
@@ -75,6 +77,7 @@ export type TruthFact =
|
||||
| { kind: 'and' | 'or'; terms: TruthFact[] };
|
||||
export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression<MovementClaim> };
|
||||
export type TruthQuestion =
|
||||
| ({ kind: 'mixedBattlePlan'; target: string } & MixedBattleClaim)
|
||||
| ({ kind: 'allianceExpression'; target: string } & AllianceExpressionClaim)
|
||||
| ({ kind: 'mixedAlliance'; target: string } & MixedAllianceClaim)
|
||||
| ({ kind: 'mixedBid'; target: string } & MixedBidClaim)
|
||||
@@ -428,6 +431,22 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (v.kind === 'mixedBattlePlan') {
|
||||
check(Object.keys(v).sort().join(',') === 'battle,kind,mixed,target,territory' &&
|
||||
g.phase === 6 && g.battle && [g.battle.attacker, g.battle.defender].includes(v.target) &&
|
||||
v.territory === g.battle.territory && (typeof v.battle === 'string' && v.battle.length > 0 || v.battle === null) &&
|
||||
v.battle === (g.battle.event ?? null), 'Choose the exact current battle and another seated combatant.');
|
||||
const respondent = g.players.find(player => player.id === v.target)!;
|
||||
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent),
|
||||
value => parseBattleActionClaim(value, battleLeaderIdentities(g).map(leader => leader.id)),
|
||||
{ maxLeaves: MIXED_BATTLE_MAX_LEAVES, maxDepth: MIXED_BATTLE_MAX_DEPTH,
|
||||
maxNodes: MIXED_BATTLE_MAX_NODES, maxTerms: MIXED_BATTLE_MAX_TERMS });
|
||||
// Older active battles lacked an event. Initialize only in this explicit
|
||||
// Ask transaction, preserving passive views and every physical choice.
|
||||
g.battle.event ??= crypto.randomUUID();
|
||||
return { kind: 'mixedBattlePlan', target: v.target as string, battle: g.battle.event,
|
||||
territory: g.battle.territory, mixed };
|
||||
}
|
||||
if (v.kind === 'battlePlan') {
|
||||
check(
|
||||
g.phase === 6 &&
|
||||
@@ -638,6 +657,8 @@ export function truthQuestionText(
|
||||
return `you currently hold ${f.compare === 'eq' ? 'exactly' : f.compare === 'gte' ? 'at least' : 'at most'} ${f.value} spice`;
|
||||
return `(${f.terms.map(clause).join(f.kind === 'and' ? ' AND ' : ' OR ')})`;
|
||||
};
|
||||
if (q.kind === 'mixedBattlePlan') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
||||
action => `${planClaimText(action, leaderName)} in this battle`)}?`;
|
||||
if (q.kind === 'mixedAlliance') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
||||
action => `you ${allianceClaimText({ turn: q.turn, partner: action.partner }, playerName)}`)}?`;
|
||||
if (q.kind === 'mixedBid') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
||||
@@ -704,6 +725,8 @@ export function resolveTruthAction(
|
||||
bindBid?: (g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) => void;
|
||||
traitorAnswers?: (g: Game, p: Player) => TruthAnswer[];
|
||||
bindTraitor?: (g: Game, p: Player, answer: boolean, asker: string) => void;
|
||||
mixedBattleAnswers?: (g: Game, p: Player, claim: MixedBattleClaim) => TruthAnswer[];
|
||||
bindMixedBattle?: (g: Game, p: Player, claim: MixedBattleClaim, answer: boolean, asker: string) => void;
|
||||
battleAnswers: (g: Game, p: Player, claim: PlanClaim) => TruthAnswer[];
|
||||
bindBattle: (
|
||||
g: Game,
|
||||
@@ -820,6 +843,9 @@ export function resolveTruthAction(
|
||||
'Waiting for the Truthtrance holder to ask a question.',
|
||||
);
|
||||
w.question = parseQuestion(g, p.id, a.question);
|
||||
if (w.question.kind === 'mixedBattlePlan')
|
||||
check(effects.mixedBattleAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a whole mixed battle-plan question compatible with this battle.');
|
||||
if (w.question.kind === 'mixedRevival')
|
||||
check(effects.mixedRevivalAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a mixed current-fact and ordinary-return question compatible with the native opportunity.');
|
||||
@@ -988,6 +1014,11 @@ export function resolveTruthAction(
|
||||
'Choose an answer compatible with your current legal Traitor declaration and earlier answers.');
|
||||
if (a.answer !== 'unknown') effects.bindTraitor(g, p, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'mixedBattlePlan') {
|
||||
check(effects.mixedBattleAnswers && effects.bindMixedBattle && effects.mixedBattleAnswers(g, p, q).includes(a.answer),
|
||||
'Choose a mixed battle-plan answer compatible with the complete fact/plan question.');
|
||||
if (a.answer !== 'unknown') effects.bindMixedBattle(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'battlePlan') {
|
||||
check(
|
||||
effects.battleAnswers(g, p, q.claim).includes(a.answer),
|
||||
|
||||
Reference in new issue
Block a user