Bind Truthtrance answers to native battle power declarations
This commit is contained in:
1 parent
c93b693137
commit
09a81a9b65
17 files changed
+1598
-28
No files matched your search
@@ -0,0 +1,132 @@
|
||||
import { VOICE_KINDS } from './battle-cards';
|
||||
|
||||
export type NativeBattlePower = 'voice' | 'prescience';
|
||||
export type BattlePowerClaim =
|
||||
| { kind: 'voice'; use: boolean }
|
||||
| { kind: 'voiceCommand'; cardType: string; must: boolean }
|
||||
| { kind: 'prescience'; use: boolean }
|
||||
| { kind: 'prescienceField'; field: 'leader' | 'weapon' | 'defense' | 'dial' };
|
||||
export type BattlePowerDeclaration =
|
||||
| { power: 'voice'; use: false }
|
||||
| { power: 'voice'; use: true; cardType: string; must: boolean }
|
||||
| { power: 'prescience'; use: false }
|
||||
| { power: 'prescience'; use: true; field: 'leader' | 'weapon' | 'defense' | 'dial' };
|
||||
export type NativeBattlePowerOpportunity = {
|
||||
event: string; territory: string; power: NativeBattlePower; owner: string;
|
||||
beneficiary: string; target: string; ready: boolean;
|
||||
};
|
||||
export type NativeBattlePowerGame = {
|
||||
status: string;
|
||||
phase: number;
|
||||
players: readonly { id: string; faction: string; ally?: string | null }[];
|
||||
battle?: {
|
||||
event?: string | null;
|
||||
territory: string;
|
||||
attacker: string;
|
||||
defender: string;
|
||||
revealed: boolean;
|
||||
prepared?: boolean;
|
||||
preparation?: { kind: string; owner: string; beneficiary: string; blocked?: string } | null;
|
||||
} | null;
|
||||
response?: unknown;
|
||||
decision?: unknown;
|
||||
phaseOpening?: unknown;
|
||||
truthtrance?: unknown;
|
||||
pendingTreacheryDiscard?: unknown;
|
||||
};
|
||||
|
||||
export class BattlePowerQuestionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'BattlePowerQuestionError';
|
||||
}
|
||||
}
|
||||
function check(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new BattlePowerQuestionError(message);
|
||||
}
|
||||
function object(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||
const keys = (value: Record<string, unknown>, expected: string): boolean =>
|
||||
Object.keys(value).sort().join(',') === expected;
|
||||
const cardType = (value: unknown): value is string => typeof value === 'string' && VOICE_KINDS.includes(value);
|
||||
const field = (value: unknown): value is 'leader' | 'weapon' | 'defense' | 'dial' =>
|
||||
value === 'leader' || value === 'weapon' || value === 'defense' || value === 'dial';
|
||||
|
||||
/** Declaration/request predicates only; never a successful effect or inspected value. */
|
||||
export function parseBattlePowerClaim(value: unknown): BattlePowerClaim {
|
||||
check(object(value), 'Choose one native Voice or Prescience declaration predicate.');
|
||||
if (value.kind === 'voice' || value.kind === 'prescience') {
|
||||
check(keys(value, 'kind,use') && typeof value.use === 'boolean', 'Choose actual use or explicit Decline of this native power.');
|
||||
return { kind: value.kind, use: value.use };
|
||||
}
|
||||
if (value.kind === 'voiceCommand') {
|
||||
check(keys(value, 'cardType,kind,must') && cardType(value.cardType) && typeof value.must === 'boolean',
|
||||
'Choose one native Voice category and require or forbid command.');
|
||||
return { kind: 'voiceCommand', cardType: value.cardType, must: value.must };
|
||||
}
|
||||
check(value.kind === 'prescienceField' && keys(value, 'field,kind') && field(value.field),
|
||||
'Choose only the native Prescience field requested, without an answer value.');
|
||||
return { kind: 'prescienceField', field: value.field };
|
||||
}
|
||||
|
||||
export function battlePowerOf(claim: BattlePowerClaim): NativeBattlePower {
|
||||
const parsed = parseBattlePowerClaim(claim);
|
||||
return parsed.kind === 'voice' || parsed.kind === 'voiceCommand' ? 'voice' : 'prescience';
|
||||
}
|
||||
|
||||
function validateDeclaration(value: unknown): asserts value is BattlePowerDeclaration {
|
||||
check(object(value) && (value.power === 'voice' || value.power === 'prescience') && typeof value.use === 'boolean',
|
||||
'Retain an actual native battle-power declaration.');
|
||||
check(!value.use ? keys(value, 'power,use') : value.power === 'voice'
|
||||
? keys(value, 'cardType,must,power,use') && cardType(value.cardType) && typeof value.must === 'boolean'
|
||||
: keys(value, 'field,power,use') && field(value.field),
|
||||
'Retain only the declared native command or requested field, or explicit Decline.');
|
||||
}
|
||||
|
||||
export function battlePowerMatches(claim: BattlePowerClaim, declaration: BattlePowerDeclaration): boolean {
|
||||
const parsed = parseBattlePowerClaim(claim);
|
||||
validateDeclaration(declaration);
|
||||
if (battlePowerOf(parsed) !== declaration.power) return false;
|
||||
if (parsed.kind === 'voice' || parsed.kind === 'prescience') return parsed.use === declaration.use;
|
||||
if (!declaration.use) return false;
|
||||
return parsed.kind === 'voiceCommand' ? declaration.power === 'voice' &&
|
||||
parsed.cardType === declaration.cardType && parsed.must === declaration.must :
|
||||
declaration.power === 'prescience' && parsed.field === declaration.field;
|
||||
}
|
||||
|
||||
/** Public native owner/side/order only. Command legality, No-Field restrictions
|
||||
* and inspected hidden values remain with the real engine quotation. */
|
||||
export function nativeBattlePowerOpportunity(game: NativeBattlePowerGame, actor: string,
|
||||
power: NativeBattlePower): NativeBattlePowerOpportunity | null {
|
||||
if (!identifier(actor) || (power !== 'voice' && power !== 'prescience') || game.status !== 'playing' || game.phase !== 6) return null;
|
||||
const battle = game.battle, preparation = battle?.preparation;
|
||||
if (!battle || battle.revealed || !identifier(battle.event) || !identifier(battle.territory) ||
|
||||
!preparation || battle.attacker === battle.defender || !Array.isArray(game.players) ||
|
||||
game.players.some(player => !identifier(player.id)) || new Set(game.players.map(player => player.id)).size !== game.players.length ||
|
||||
!game.players.some(player => player.id === battle.attacker) || !game.players.some(player => player.id === battle.defender)) return null;
|
||||
const side = (kind: NativeBattlePower) => {
|
||||
const owners = game.players.filter(player => player.faction === (kind === 'voice' ? 'beneGesserit' : 'atreides'));
|
||||
if (owners.length !== 1) return null;
|
||||
const owner = owners[0], combatants = [battle.attacker, battle.defender];
|
||||
const beneficiary = combatants.includes(owner.id) ? owner.id : owner.ally && combatants.includes(owner.ally) &&
|
||||
game.players.some(player => player.id === owner.ally && player.ally === owner.id) ? owner.ally : null;
|
||||
return beneficiary ? { owner: owner.id, beneficiary,
|
||||
target: battle.attacker === beneficiary ? battle.defender : battle.attacker } : null;
|
||||
};
|
||||
const native = side(power);
|
||||
if (!native || native.owner !== actor) return null;
|
||||
const current = preparation.kind === power;
|
||||
if (current) {
|
||||
if (preparation.owner !== native.owner || preparation.beneficiary !== native.beneficiary) return null;
|
||||
} else {
|
||||
if (power !== 'prescience' || preparation.kind !== 'voice') return null;
|
||||
const voice = side('voice');
|
||||
if (!voice || preparation.owner !== voice.owner || preparation.beneficiary !== voice.beneficiary) return null;
|
||||
}
|
||||
return { event: battle.event, territory: battle.territory, power, ...native,
|
||||
ready: current && !game.response && !game.decision && !game.phaseOpening &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard };
|
||||
}
|
||||
+82
-6
@@ -2,10 +2,13 @@ import { AuctionClockError, AUCTION_BID_MS, quoteAuctionClock, viewAuctionClock,
|
||||
type AuctionClock, type AuctionClockView } from './auction-clock';
|
||||
import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay,
|
||||
type PrintedCardEffect, type PrintedCardPlayCounts } from './printed-card-play';
|
||||
import { battlePowerOf, battlePowerMatches, nativeBattlePowerOpportunity,
|
||||
type NativeBattlePower, type BattlePowerDeclaration } from './battle-power-question';
|
||||
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
|
||||
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, closeTurnActionPayments, finishTurnActionTurn,
|
||||
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival, recordTurnActionCardPlay,
|
||||
closeTurnActionRevival, settleTurnActionAlliance, recordTurnActionPlan, recordTurnActionTraitor, closeTurnActionCombat,
|
||||
recordTurnActionBattlePower, closeTurnActionBattlePower,
|
||||
type TurnActionPlanValue, type TurnActionCombat, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation } from './turn-action-promises';
|
||||
import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShipment, recordTravelMovement,
|
||||
finishTravelTurn, validateTravelPromises, type TravelClaim, type TravelPromise, type TravelBranchLeaf } from './travel-expression-promises';
|
||||
@@ -18959,6 +18962,51 @@ function recordTurnActionRevealedPlans(g: Game) {
|
||||
g.turn, battle.event!, battle.territory, values));
|
||||
}
|
||||
}
|
||||
type TurnActionPowerRequirement = Extract<TurnActionBranchLeaf, { kind: 'battlePower' }>;
|
||||
/** These declarations cost no resource and affect the other combatant's plan,
|
||||
* never the provider's own plan. Quote the original choice, not a speculative
|
||||
* inspected answer or an invented foreign Voice/Prescience continuation. */
|
||||
function turnActionPowerCompletion(g: Game, actor: string, requirements: readonly TurnActionPowerRequirement[]):
|
||||
{ action?: Action } | null {
|
||||
let action: Action | undefined;
|
||||
for (const power of ['voice', 'prescience'] as const) {
|
||||
const required = requirements.filter(leaf => battlePowerOf(leaf.claim) === power);
|
||||
if (!required.length) continue;
|
||||
const source = nativeBattlePowerOpportunity(g, actor, power);
|
||||
if (!source || required.some(leaf => leaf.battle !== source.event || leaf.territory !== source.territory ||
|
||||
leaf.owner !== source.owner || leaf.beneficiary !== source.beneficiary || leaf.target !== source.target)) return null;
|
||||
const choices: { declaration: BattlePowerDeclaration; action: Action }[] = [
|
||||
{ declaration: { power, use: false }, action: { type: 'declineBattlePower' } },
|
||||
];
|
||||
if (power === 'voice') {
|
||||
for (const kind of ['poison', ...VOICE_KINDS.filter(kind => kind !== 'poison')]) for (const must of [false, true]) {
|
||||
try {
|
||||
quoteVoiceCommand(g, source.target, kind, must);
|
||||
choices.push({ declaration: { power, use: true, cardType: kind, must }, action: { type: 'voice', kind, must } });
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
} else for (const field of ['leader', 'weapon', 'defense', 'dial'] as PlanField[])
|
||||
if (field !== 'dial' || !battleNoFieldPlan(g, source.target))
|
||||
choices.push({ declaration: { power, use: true, field }, action: { type: 'prescience', field } });
|
||||
const choice = choices.find(choice => required.every(leaf => battlePowerMatches(leaf.claim, choice.declaration) === leaf.answer));
|
||||
if (!choice) return null;
|
||||
if (source.ready) action ??= choice.action;
|
||||
}
|
||||
return action ? { action } : {};
|
||||
}
|
||||
function recordNativeTurnActionPower(g: Game, actor: string, declaration: BattlePowerDeclaration) {
|
||||
const battle = g.battle;
|
||||
if (!g.turnActionPromises || !battle?.event) return;
|
||||
const previous = g.turnActionPromises;
|
||||
const recorded = turnActionRule(() => recordTurnActionBattlePower(previous, actor,
|
||||
g.turn, battle.event!, battle.territory, declaration));
|
||||
// Check this accepted declaration before its next foreign response or
|
||||
// inspection can temporarily suspend the ordinary joint reconciler.
|
||||
if (recorded.some((promise, index) => promise !== previous[index]))
|
||||
requireRule(!!turnActionCompletion({ ...g, turnActionPromises: recorded }, actor),
|
||||
'This native declaration would remove the last legal completion of your whole Truthtrance answer.');
|
||||
g.turnActionPromises = recorded;
|
||||
}
|
||||
/** A disappeared original opportunity is absence, never a synthetic zero plan
|
||||
* or Decline. Declaration effects can later change; only the native hook
|
||||
* records a declaration receipt. */
|
||||
@@ -18975,8 +19023,18 @@ function reconcileTurnActionCombat(g: Game) {
|
||||
const lostTraitor = combat.traitor && !combat.traitor.closed && (!context ||
|
||||
context.submitted !== undefined || context.beneficiary !== combat.traitor.beneficiary ||
|
||||
context.target !== combat.traitor.target || context.leader !== combat.traitor.leader);
|
||||
return !current || lostTraitor
|
||||
? turnActionRule(() => closeTurnActionCombat([promise], promise.turn, combat.event))[0] : promise;
|
||||
if (!current || lostTraitor) return turnActionRule(() => closeTurnActionCombat([promise], promise.turn, combat.event))[0];
|
||||
let updated = promise;
|
||||
for (const power of ['voice', 'prescience'] as const) {
|
||||
const stage = updated.combat?.powers?.[power];
|
||||
if (!stage || stage.closed) continue;
|
||||
const source = nativeBattlePowerOpportunity(g, promise.player, power);
|
||||
if (!source || source.event !== combat.event || source.territory !== combat.territory ||
|
||||
source.beneficiary !== stage.beneficiary || source.target !== stage.target)
|
||||
updated = turnActionRule(() => closeTurnActionBattlePower([updated], promise.player,
|
||||
promise.turn, combat.event, combat.territory, power))[0];
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
function turnActionTraitorChoices(g: Game, actor: string, choices: boolean[]): boolean[] {
|
||||
@@ -19085,7 +19143,7 @@ function turnActionAllianceCanClose(g: Game, player: Player): boolean {
|
||||
return !!turnActionCompletion(source, player.id);
|
||||
} catch (error) { if (error instanceof RuleError) return false; throw error; }
|
||||
}
|
||||
type TurnActionCompletion = MovementCompletion & { payments: Action[]; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action; cardPreparations?: Action[] };
|
||||
type TurnActionCompletion = MovementCompletion & { payments: Action[]; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action; cardPreparations?: Action[]; battlePower?: Action };
|
||||
/** Whole-answer alternatives stay private. Only detached native rows quote a
|
||||
* route, sharing actual interval progress and the remaining physical purse. */
|
||||
function turnActionCompletion(source: Game, actor: string, unknownConsent?: { value: boolean }): TurnActionCompletion | null {
|
||||
@@ -19155,6 +19213,9 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
|
||||
}
|
||||
});
|
||||
if (invalidCombat) return null;
|
||||
const power = turnActionPowerCompletion(trial, actor,
|
||||
leaves.filter((leaf): leaf is TurnActionPowerRequirement => leaf.kind === 'battlePower'));
|
||||
if (!power) return null;
|
||||
const alliancePlans = turnActionAlliancePlans(trial, actor, !!unknownConsent);
|
||||
for (const required of bribePromiseRule(() => bribeCompletionCandidates(
|
||||
trial.bribePromises!, trial.bribeExpressionPromises ?? [], actor, source.turn))) {
|
||||
@@ -19239,6 +19300,7 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
|
||||
if (alliancePlan?.uncertain) { unknownConsent!.value = true; continue; }
|
||||
return { ...completion, ...(cardSeed.actions.length ? { cardPreparations: cardSeed.actions } : {}), ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
|
||||
...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}),
|
||||
...(power.action ? { battlePower: power.action } : {}),
|
||||
...(alliancePlan ? { alliance: alliancePlan.steps.map(step => ({ type: 'alliance', target: step.target })) } : {}),
|
||||
payments: required.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount })) };
|
||||
}
|
||||
@@ -19257,12 +19319,14 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
|
||||
const open = turnActionTransportOpen(g, p.id);
|
||||
let bid = false, revival = false, alliance = false, plan = false, traitor = false, cardHajr = false, cardGhola = false;
|
||||
const powers = new Set<NativeBattlePower>();
|
||||
const visit = (node: TurnActionClaim['mixed']): void => {
|
||||
if (node.kind === 'action') {
|
||||
if (node.action.kind === 'bid') bid = true;
|
||||
if (node.action.kind === 'alliance') alliance = true;
|
||||
if (node.action.kind === 'battlePlan') plan = true;
|
||||
if (node.action.kind === 'traitorCall') traitor = true;
|
||||
if (node.action.kind === 'battlePower') powers.add(battlePowerOf(node.action.claim));
|
||||
if (node.action.kind === 'cardPlay' && node.action.effect === 'hajr') cardHajr = true;
|
||||
if (node.action.kind === 'cardPlay' && node.action.effect === 'ghola') cardGhola = true;
|
||||
if (node.action.kind === 'forces' || node.action.kind === 'leader') revival = true;
|
||||
@@ -19278,12 +19342,20 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
revival: revival ? { total: 0, normal: 0, elite: 0, leaders: [], closed: g.phase > 4 ||
|
||||
g.phase === 4 && !!g.revivalOrder && !g.revivalOrder.remaining.includes(p.id) } : null,
|
||||
} : undefined;
|
||||
const battle = plan || traitor ? g.battle : null;
|
||||
requireRule(!plan && !traitor || !!battle?.event, 'Retain the original current battle.');
|
||||
const battle = plan || traitor || powers.size > 0 ? g.battle : null;
|
||||
requireRule(!plan && !traitor && powers.size === 0 || !!battle?.event, 'Retain the original current battle.');
|
||||
const context = traitor ? traitorTruthContext(g, p.id) : null;
|
||||
requireRule(!traitor || context && context.submitted === undefined, 'Retain the original unsubmitted Traitor decision.');
|
||||
const powerStage = (power: NativeBattlePower) => {
|
||||
if (!powers.has(power)) return null;
|
||||
const source = nativeBattlePowerOpportunity(g, p.id, power);
|
||||
requireRule(source && battle && source.event === battle.event && source.territory === battle.territory,
|
||||
'Retain your original owned Voice or Prescience opportunity.');
|
||||
return { beneficiary: source.beneficiary, target: source.target, closed: false, values: null };
|
||||
};
|
||||
const combat: TurnActionCombat | undefined = battle?.event ? { event: battle.event, territory: battle.territory,
|
||||
plan: plan ? { closed: false, values: null } : null,
|
||||
...(powers.size ? { powers: { voice: powerStage('voice'), prescience: powerStage('prescience') } } : {}),
|
||||
traitor: context ? { beneficiary: context.beneficiary, target: context.target, leader: context.leader,
|
||||
closed: false, call: null } : null } : undefined;
|
||||
let promise: TurnActionPromise = { turn: claim.turn, mixed: structuredClone(claim.mixed), event, player: p.id, asker, answer, compiled,
|
||||
@@ -19444,7 +19516,8 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
||||
}
|
||||
if (!action && g.phase === 6 && g.battle) {
|
||||
const battle = g.battle;
|
||||
if (completion.battlePlan && !completion.battlePlan.waitingForIncome && !battle.revealed && !battle.plans[player.id] &&
|
||||
if (completion.battlePower) action = completion.battlePower;
|
||||
if (!action && completion.battlePlan && !completion.battlePlan.waitingForIncome && !battle.revealed && !battle.plans[player.id] &&
|
||||
(!battle.preLeader || battle.preLeader.closed) && !battle.preparation &&
|
||||
(!battle.fullPlan || battle.plans[battle.fullPlan.target] || battle.fullPlan.target === player.id))
|
||||
action = completion.battlePlan.actions[0] ?? { type: 'battlePlan', ...completion.battlePlan.plan };
|
||||
@@ -41317,6 +41390,7 @@ function applyActionInner(
|
||||
'You do not own this battle power decision.',
|
||||
);
|
||||
const kind = b.preparation.kind;
|
||||
recordNativeTurnActionPower(g, id, { power: kind === 'voice' ? 'voice' : 'prescience', use: false });
|
||||
if (kind === 'voice') battlePreparation(g, 'prescience');
|
||||
else finishBattlePreparation(g);
|
||||
log(
|
||||
@@ -41341,6 +41415,7 @@ function applyActionInner(
|
||||
action.field !== 'dial' || !battleNoFieldPlan(g, target),
|
||||
'Atreides may not inspect the number dialed in a No-Field battle.',
|
||||
);
|
||||
recordNativeTurnActionPower(g, id, { power: 'prescience', use: true, field: action.field as PlanField });
|
||||
b.prescience = { player: beneficiary, field: action.field as PlanField };
|
||||
b.preparation = {
|
||||
kind: 'prescienceAnswer',
|
||||
@@ -41409,6 +41484,7 @@ function applyActionInner(
|
||||
);
|
||||
const target = b.attacker === b.preparation.beneficiary ? b.defender : b.attacker;
|
||||
b.voice = quoteVoiceCommand(g, target, action.kind, action.must);
|
||||
recordNativeTurnActionPower(g, id, { power: 'voice', use: true, cardType: b.voice.kind, must: b.voice.must });
|
||||
battlePreparation(g, 'prescience');
|
||||
g.response = { kind: 'voice', owner: id, passed: [] };
|
||||
return g;
|
||||
|
||||
+7
-5
@@ -3212,9 +3212,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
id: 'truthtrance-turn-actions',
|
||||
title: 'Truthtrance: combined card plays, battle and turn actions',
|
||||
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
|
||||
summary: 'Combine future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
|
||||
summary: 'Combine native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
|
||||
steps: [
|
||||
'Choose Combine Hajr/Ghola plays, the current battle and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of printed Hajr/Ghola use, native battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
|
||||
'Choose Combine native battle powers, Hajr/Ghola plays and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
|
||||
'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.',
|
||||
'Whole No can permit a temporary paid pair and later require a break. Own Ready must preserve a completion after final membership is fixed; remaining payment or physical-action alternatives keep their later deadlines. Definite answers, including constants, uniformly reopen own Nexus readiness; later ordinary payments require confirmation again.',
|
||||
'Missing foreign consent can offer I don’t know and save or change the question; it never invents a definite Yes. Unknown requires an otherwise funded native completion, so a known unaffordable branch stays No. Foreign withdrawal can release impossibility without vetoing the other player’s rights.',
|
||||
@@ -3224,6 +3224,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Basic Revival No lasts to phase close; ordered Advanced finishes at the respondent’s actual own turn end. Own Ready must retain a whole completion, including a funded later payment alternative. An owned Karama invoice guide appears only at the actual payment decision, never while another bidder is active.',
|
||||
'A battle-plan condition names an atomic part of your actual plan in this current battle. An open condition settles when both original plans reveal; a plan already sealed fixes its value at the answer. The human guide fills your existing plan for review before sealing. Native leaders, physical cards, Voice, costs and ordered preparation still apply.',
|
||||
'A Traitor condition describes the original eligible voter’s Reveal or Decline declaration in this revealed battle, before any later Karama prevention. A prevented Reveal stays a declared Reveal. No missing battle or declaration is invented as a zero plan or Decline, and an absent matching Traitor never creates one.',
|
||||
'A native-power condition names your original Voice or Prescience opportunity in this battle. Ask about actual use or explicit Decline, an exact Voice category and require/forbid command, or the requested Prescience field. A requested field is not the inspected answer. The declaration counts before later Karama prevention.',
|
||||
'Native owners can provide their eligible ally’s power. Voice finishes before Prescience; a pending Prescience condition does not advance its turn. Original command and No-Field restrictions remain. All predicates concern the same one native choice; source expiry is absence and never becomes a synthetic Decline. Borrowed Nexus and special powers retain separate scope.',
|
||||
'A printed-card condition requires an actual Hajr or Tleilaxu Ghola play in its printed role after this answer. Earlier plays, discards, sales, Karama roles and recovery alone do not count; a genuine later Box recovery followed by replay can count. No hidden card is needed merely to ask the public named condition.',
|
||||
'Hajr counts even if its extra move is unused, closes at your own Movement end and must earn its actual Advanced timing. Ghola remains available through this turn’s Mentat and uses real Tanks, eligible leader or Kwisatz and typed selection; it does not spend or satisfy ordinary Revival quotas. Ordinary returns and Ghola share the actual remaining Tanks and purse.',
|
||||
'Shipment clauses describe the same one future original reserve shipment. Each ground-movement minimum requires one actual physical group, never the sum of smaller moves. Existing allowances, cards, pieces and preparation remain authoritative.',
|
||||
@@ -3233,11 +3235,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse printed Hajr/Ghola, native battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse native power declarations, printed Hajr/Ghola, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed Hajr/Ghola; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed Hajr/Ghola and original power declarations; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/truth-turn-power-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Current funding, physical event meaning, private alternatives, separate deadlines and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TURN_ACTIONS.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays and shared Revival resources; broader composition and final acceptance remain.', evidence: ['tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, shared Revival resources and original power declarations; broader composition and final acceptance remain.', evidence: ['tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+13
-2
@@ -1,6 +1,7 @@
|
||||
import { parseTravelActionClaim, type TravelClaim } from './travel-expression-promises';
|
||||
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES,
|
||||
type TurnActionClaim } from './turn-action-promises';
|
||||
import { battlePowerOf, nativeBattlePowerOpportunity } from './battle-power-question';
|
||||
import { parseTraitorActionClaim, type MixedTraitorClaim } from './mixed-traitor-question';
|
||||
import { parseBattleActionClaim, MIXED_BATTLE_MAX_NODES, MIXED_BATTLE_MAX_TERMS, MIXED_BATTLE_MAX_DEPTH,
|
||||
MIXED_BATTLE_MAX_LEAVES, type MixedBattleClaim } from './mixed-battle-question';
|
||||
@@ -384,7 +385,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 = parseTurnActionLeaf(input);
|
||||
if (action.kind === 'battlePlan' || action.kind === 'traitorCall') {
|
||||
if (action.kind === 'battlePlan' || action.kind === 'traitorCall' || action.kind === 'battlePower') {
|
||||
const battle = g.battle;
|
||||
check(g.phase === 6 && battle && typeof battle.event === 'string' && battle.event.length > 0 &&
|
||||
action.battle === battle.event && action.territory === battle.territory,
|
||||
@@ -392,7 +393,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
if (action.kind === 'battlePlan') {
|
||||
check([battle.attacker, battle.defender].includes(respondent.id), 'Ask an actual combatant about their own current native plan.');
|
||||
action.claim = parseBattleActionClaim(action.claim, battleLeaderIdentities(g).map(leader => leader.id));
|
||||
} else {
|
||||
} else if (action.kind === 'traitorCall') {
|
||||
const voters = [battle.attacker, battle.defender];
|
||||
if (battle.ecazOccupy?.lead) for (const id of [battle.ecazOccupy.ecaz, battle.ecazOccupy.ally])
|
||||
if (!voters.includes(id)) voters.push(id);
|
||||
@@ -400,6 +401,10 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
if (harkonnen?.ally && voters.includes(harkonnen.ally) && !voters.includes(harkonnen.id)) voters.push(harkonnen.id);
|
||||
check(battle.revealed && voters.includes(respondent.id) && battle.traitorCalls[respondent.id] === undefined,
|
||||
'Choose an actual current revealed, unsubmitted native Traitor voter.');
|
||||
} else {
|
||||
const opportunity = nativeBattlePowerOpportunity(g, respondent.id, battlePowerOf(action.claim));
|
||||
check(opportunity && opportunity.event === action.battle && opportunity.territory === action.territory,
|
||||
'Choose the original owned native Voice or Prescience declaration opportunity.');
|
||||
}
|
||||
}
|
||||
if (action.kind === 'payment') check(action.recipient !== respondent.id && action.recipient !== respondent.ally &&
|
||||
@@ -730,6 +735,12 @@ export function truthQuestionText(
|
||||
if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
||||
action => {
|
||||
if (action.kind === 'cardPlay') return `you play ${action.effect === 'hajr' ? 'Hajr' : 'Tleilaxu Ghola'} in its printed role after this answer this turn`;
|
||||
if (action.kind === 'battlePower') {
|
||||
const claim = action.claim, where = `in this original battle in ${locationName(action.territory)}`;
|
||||
if (claim.kind === 'voiceCommand') return `you actually declare native Voice ${claim.must ? 'requiring' : 'forbidding'} ${claim.cardType} ${where} (later prevention does not change the declaration)`;
|
||||
if (claim.kind === 'prescienceField') return `you actually request the ${claim.field} field through native Prescience ${where} (this describes the requested field, not its answer value)`;
|
||||
return `you actually ${claim.use ? 'declare use of' : 'explicitly decline'} native ${claim.kind === 'voice' ? 'Voice' : 'Prescience'} ${where}`;
|
||||
}
|
||||
if (action.kind === 'payment') return `you ${bribeClaimText({ turn: q.turn, ...action }, playerName)}`;
|
||||
if (action.kind === 'bid') return `you ${bidClaimText({ turn: q.turn, ...action })}`;
|
||||
if (action.kind === 'battlePlan') return `in your own native plan in this original battle in ${locationName(action.territory)}, ${planClaimText(action.claim, leaderName)}`;
|
||||
|
||||
@@ -7,6 +7,8 @@ import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './r
|
||||
import { parseBattleActionClaim, type MixedBattleActionClaim } from './mixed-battle-question';
|
||||
import { PlanClaimError } from './battle-promises';
|
||||
import type { PrintedCardEffect } from './printed-card-play';
|
||||
import { BattlePowerQuestionError, battlePowerMatches, battlePowerOf, parseBattlePowerClaim,
|
||||
type BattlePowerClaim, type BattlePowerDeclaration, type NativeBattlePower } from './battle-power-question';
|
||||
import {
|
||||
MixedActionQuestionError,
|
||||
parseMixedActionExpression,
|
||||
@@ -29,7 +31,8 @@ export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: s
|
||||
{ kind: 'bid'; lot: number; minimum: number } | RevivalActionClaim | { kind: 'alliance'; partner: string } |
|
||||
{ kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim } |
|
||||
{ kind: 'traitorCall'; battle: string; territory: string; call: boolean } |
|
||||
{ kind: 'cardPlay'; effect: PrintedCardEffect };
|
||||
{ kind: 'cardPlay'; effect: PrintedCardEffect } |
|
||||
{ kind: 'battlePower'; battle: string; territory: string; claim: BattlePowerClaim };
|
||||
export type TurnActionClaim = { turn: number; mixed: MixedActionExpression<TurnActionLeaf> };
|
||||
export type TurnActionPayment = { recipient: string; amount: number };
|
||||
export type TurnActionBranchLeaf = TravelBranchLeaf |
|
||||
@@ -41,7 +44,9 @@ export type TurnActionBranchLeaf = TravelBranchLeaf |
|
||||
{ kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim; answer: boolean } |
|
||||
{ kind: 'traitorCall'; battle: string; territory: string; call: boolean; answer: boolean;
|
||||
beneficiary: string; target: string; leader: string | null } |
|
||||
{ kind: 'cardPlay'; effect: PrintedCardEffect; answer: boolean };
|
||||
{ kind: 'cardPlay'; effect: PrintedCardEffect; answer: boolean } |
|
||||
{ kind: 'battlePower'; battle: string; territory: string; claim: BattlePowerClaim;
|
||||
owner: string; beneficiary: string; target: string; answer: boolean };
|
||||
export type TurnActionPreparation = {
|
||||
bid: { lot: number; source: string; maximum: number; closed: boolean } | null;
|
||||
revival: { total: number; normal: number; elite: number; leaders: string[]; closed: boolean } | null;
|
||||
@@ -49,11 +54,16 @@ export type TurnActionPreparation = {
|
||||
export type TurnActionBid = { lot: number; source: string; amount: number };
|
||||
export type TurnActionAlliance = { source: string; partner: string | null; closed: boolean };
|
||||
export type TurnActionPlanValue = { claim: MixedBattleActionClaim; value: boolean };
|
||||
export type TurnActionPowerStage = {
|
||||
beneficiary: string; target: string; closed: boolean;
|
||||
values: { claim: BattlePowerClaim; value: boolean }[] | null;
|
||||
};
|
||||
export type TurnActionCombat = {
|
||||
event: string;
|
||||
territory: string;
|
||||
plan: { closed: boolean; values: TurnActionPlanValue[] | null } | null;
|
||||
traitor: { beneficiary: string; target: string; leader: string | null; closed: boolean; call: boolean | null } | null;
|
||||
powers?: Record<NativeBattlePower, TurnActionPowerStage | null>;
|
||||
};
|
||||
export type TurnActionCardPlays = Record<PrintedCardEffect, { played: boolean; closed: boolean } | null>;
|
||||
export type TurnActionPromise = TurnActionClaim & {
|
||||
@@ -113,11 +123,25 @@ function parseCombatClaim(value: unknown): MixedBattleActionClaim {
|
||||
} catch (error) { if (error instanceof PlanClaimError) throw new TurnActionError(error.message); throw error; }
|
||||
}
|
||||
const planKey = (claim: MixedBattleActionClaim): string => JSON.stringify(parseCombatClaim(claim));
|
||||
function parsePowerClaim(value: unknown): BattlePowerClaim {
|
||||
try { return parseBattlePowerClaim(value); }
|
||||
catch (error) { if (error instanceof BattlePowerQuestionError) throw new TurnActionError(error.message); throw error; }
|
||||
}
|
||||
const powerKey = (claim: BattlePowerClaim): string => JSON.stringify(parsePowerClaim(claim));
|
||||
function powerMatches(claim: BattlePowerClaim, declaration: BattlePowerDeclaration): boolean {
|
||||
try { return battlePowerMatches(claim, declaration); }
|
||||
catch (error) { if (error instanceof BattlePowerQuestionError) throw new TurnActionError(error.message); throw error; }
|
||||
}
|
||||
|
||||
/** Public leaves carry only their native destination/recipient and minimum. */
|
||||
export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
|
||||
check(object(value), 'Choose one future native action, printed-card play or ordinary bribe payment.');
|
||||
try {
|
||||
if (value.kind === 'battlePower') {
|
||||
check(keys(value, 'battle,claim,kind,territory') && identifier(value.battle) && identifier(value.territory),
|
||||
'Retain the original battle and native Voice or Prescience declaration clause.');
|
||||
return { kind: 'battlePower', battle: value.battle, territory: value.territory, claim: parsePowerClaim(value.claim) };
|
||||
}
|
||||
if (value.kind === 'cardPlay') {
|
||||
check(keys(value, 'effect,kind') && (value.effect === 'hajr' || value.effect === 'ghola'),
|
||||
'Choose only the printed Hajr or Tleilaxu Ghola play after this answer.');
|
||||
@@ -169,6 +193,7 @@ function storedFact(value: unknown, depth = 0): TruthFact {
|
||||
}
|
||||
|
||||
function actionKey(action: TurnActionLeaf): string {
|
||||
if (action.kind === 'battlePower') return JSON.stringify([action.kind, action.battle, action.territory, powerKey(action.claim)]);
|
||||
if (action.kind === 'cardPlay') return JSON.stringify([action.kind, action.effect]);
|
||||
if (action.kind === 'battlePlan') return JSON.stringify([action.kind, action.battle, action.territory, planKey(action.claim)]);
|
||||
if (action.kind === 'traitorCall') return JSON.stringify([action.kind, action.battle, action.territory, action.call]);
|
||||
@@ -238,6 +263,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
let hasAlliance = false;
|
||||
const combatSources = new Set<string>();
|
||||
const planClaims = new Set<string>();
|
||||
const powerClaims: Record<NativeBattlePower, Set<string>> = { voice: new Set(), prescience: new Set() };
|
||||
let hasTraitor = false;
|
||||
const cardEffects = new Set<PrintedCardEffect>();
|
||||
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
|
||||
@@ -248,11 +274,12 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
if (node.action.kind === 'bid') lots.add(node.action.lot);
|
||||
if (node.action.kind === 'forces' || node.action.kind === 'leader') hasRevival = true;
|
||||
if (node.action.kind === 'alliance') hasAlliance = true;
|
||||
if (node.action.kind === 'battlePlan' || node.action.kind === 'traitorCall')
|
||||
if (node.action.kind === 'battlePlan' || node.action.kind === 'traitorCall' || node.action.kind === 'battlePower')
|
||||
combatSources.add(JSON.stringify([node.action.battle, node.action.territory]));
|
||||
if (node.action.kind === 'battlePlan') planClaims.add(planKey(node.action.claim));
|
||||
if (node.action.kind === 'traitorCall') hasTraitor = true;
|
||||
if (node.action.kind === 'cardPlay') cardEffects.add(node.action.effect);
|
||||
if (node.action.kind === 'battlePower') powerClaims[battlePowerOf(node.action.claim)].add(powerKey(node.action.claim));
|
||||
} else if (node.kind !== 'fact') node.terms.forEach(visit);
|
||||
};
|
||||
visit(mixed);
|
||||
@@ -261,6 +288,8 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
check(!hasAlliance || !!promise.alliance, 'Retain the original private Nexus binding for every final-alliance leaf.');
|
||||
check(!planClaims.size || !!promise.combat?.plan, 'Retain the original private plan stage for every battle-plan clause.');
|
||||
check(!hasTraitor || !!promise.combat?.traitor, 'Retain the original private Traitor declaration stage.');
|
||||
for (const power of ['voice', 'prescience'] as const) check(!powerClaims[power].size || !!promise.combat?.powers?.[power],
|
||||
'Retain the original private native battle-power stage, including folded original clauses.');
|
||||
for (const effect of cardEffects) check(!!promise.cardPlays?.[effect], 'Retain the original future interval for each printed-card play clause.');
|
||||
if ('cardPlays' in promise) {
|
||||
check(object(promise.cardPlays) && keys(promise.cardPlays, 'ghola,hajr'), 'Retain only the native printed-card play stages.');
|
||||
@@ -272,9 +301,30 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
}
|
||||
if ('combat' in promise) {
|
||||
const combat = promise.combat;
|
||||
check(object(combat) && keys(combat, 'event,plan,territory,traitor') && identifier(combat.event) &&
|
||||
check(object(combat) && (keys(combat, 'event,plan,territory,traitor') || keys(combat, 'event,plan,powers,territory,traitor')) && identifier(combat.event) &&
|
||||
identifier(combat.territory) && [...combatSources].every(source => source === JSON.stringify([combat.event, combat.territory])),
|
||||
'Retain one original current combat event and territory.');
|
||||
if ('powers' in combat) {
|
||||
check(object(combat.powers) && keys(combat.powers, 'prescience,voice'), 'Retain only the two native battle-power stages.');
|
||||
for (const power of ['voice', 'prescience'] as const) {
|
||||
const stage = combat.powers[power];
|
||||
if (stage === null) continue;
|
||||
check(object(stage) && keys(stage, 'beneficiary,closed,target,values') && identifier(stage.beneficiary) &&
|
||||
identifier(stage.target) && stage.beneficiary !== stage.target && typeof stage.closed === 'boolean' &&
|
||||
(stage.closed || stage.values === null), 'Retain the public native power side and actual closed declaration receipt.');
|
||||
if (stage.values === null) continue;
|
||||
check(Array.isArray(stage.values) && stage.values.length === powerClaims[power].size,
|
||||
'Retain every distinct original predicate for this native power exactly once.');
|
||||
const seen = new Set<string>();
|
||||
for (const entry of stage.values) {
|
||||
check(object(entry) && keys(entry, 'claim,value') && typeof entry.value === 'boolean', 'Retain definite actual battle-power predicate values.');
|
||||
const claim = parsePowerClaim(entry.claim), key = powerKey(claim);
|
||||
check(battlePowerOf(claim) === power && powerClaims[power].has(key) && !seen.has(key),
|
||||
'Retain only unique original predicates of this native power in the actual receipt.');
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (combat.plan !== null) {
|
||||
check(object(combat.plan) && keys(combat.plan, 'closed,values') && typeof combat.plan.closed === 'boolean' &&
|
||||
(combat.plan.closed || combat.plan.values === null), 'Retain an open plan or an actual closed plan receipt.');
|
||||
@@ -354,6 +404,8 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
}
|
||||
|
||||
function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
||||
if (action.kind === 'battlePower') return promise.combat?.powers?.[battlePowerOf(action.claim)]?.values?.find(entry =>
|
||||
powerKey(entry.claim) === powerKey(action.claim))?.value ?? false;
|
||||
if (action.kind === 'cardPlay') return promise.cardPlays?.[action.effect]?.played ?? false;
|
||||
if (action.kind === 'battlePlan') return promise.combat?.plan?.values?.find(entry =>
|
||||
planKey(entry.claim) === planKey(action.claim))?.value ?? false;
|
||||
@@ -378,6 +430,7 @@ function evaluate(node: CompiledMixedAction<TurnActionLeaf>, promise: TurnAction
|
||||
}
|
||||
|
||||
function actionClosed(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
||||
if (action.kind === 'battlePower') return promise.combat!.powers![battlePowerOf(action.claim)]!.closed;
|
||||
if (action.kind === 'cardPlay') return promise.cardPlays![action.effect]!.closed;
|
||||
if (action.kind === 'battlePlan') return promise.combat!.plan!.closed;
|
||||
if (action.kind === 'traitorCall') return promise.combat!.traitor!.closed;
|
||||
@@ -418,6 +471,9 @@ export function liveTurnActionPromises<T extends TurnActionPromise>(promises: re
|
||||
|
||||
function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): boolean {
|
||||
if (left.kind !== right.kind) return false;
|
||||
if (left.kind === 'battlePower') return right.kind === 'battlePower' && left.battle === right.battle &&
|
||||
left.territory === right.territory && left.owner === right.owner && left.beneficiary === right.beneficiary &&
|
||||
left.target === right.target && powerKey(left.claim) === powerKey(right.claim);
|
||||
if (left.kind === 'cardPlay') return right.kind === 'cardPlay' && left.effect === right.effect;
|
||||
if (left.kind === 'leader') return right.kind === 'leader' && left.leader === right.leader;
|
||||
if (left.kind === 'alliance') return right.kind === 'alliance' && left.partner === right.partner && left.source === right.source;
|
||||
@@ -443,7 +499,7 @@ function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf
|
||||
if (!prior) merged.push({ ...leaf });
|
||||
continue;
|
||||
}
|
||||
if (leaf.kind === 'leader' || leaf.kind === 'alliance' || leaf.kind === 'battlePlan' || leaf.kind === 'cardPlay') {
|
||||
if (leaf.kind === 'leader' || leaf.kind === 'alliance' || leaf.kind === 'battlePlan' || leaf.kind === 'cardPlay' || leaf.kind === 'battlePower') {
|
||||
const prior = merged.find(other => sameAction(other, leaf));
|
||||
if (prior && prior.answer !== leaf.answer) return null;
|
||||
if (leaf.kind === 'alliance' && leaf.answer && merged.some(other => other.kind === 'alliance' &&
|
||||
@@ -476,7 +532,10 @@ export function* turnActionBranches(promise: TurnActionPromise): Generator<TurnA
|
||||
const achieved = actionValue(action, promise);
|
||||
if (achieved || actionClosed(action, promise)) { if (achieved === answer) yield []; return; }
|
||||
if (action.kind === 'bid') yield [{ ...action, source: promise.preparation!.bid!.source, answer }];
|
||||
else if (action.kind === 'traitorCall') {
|
||||
else if (action.kind === 'battlePower') {
|
||||
const stage = promise.combat!.powers![battlePowerOf(action.claim)]!;
|
||||
yield [{ ...action, owner: promise.player, beneficiary: stage.beneficiary, target: stage.target, answer }];
|
||||
} else if (action.kind === 'traitorCall') {
|
||||
const { beneficiary, target, leader } = promise.combat!.traitor!;
|
||||
yield [{ ...action, beneficiary, target, leader, answer }];
|
||||
} else if (action.kind === 'alliance') yield [{ ...action, source: promise.alliance!.source, answer }];
|
||||
@@ -512,6 +571,50 @@ function settle<T extends TurnActionPromise>(promise: T, update: T): T {
|
||||
? { ...update, fulfilled: true } : update;
|
||||
}
|
||||
|
||||
/** Record the actual owned declaration before any later Karama prevention.
|
||||
* Its exact predicates stay true/false even if the power's effect is denied. */
|
||||
export function recordTurnActionBattlePower<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
turn: number, event: string, territory: string, declaration: BattlePowerDeclaration): T[] {
|
||||
check(identifier(event) && identifier(territory) && object(declaration) &&
|
||||
(declaration.power === 'voice' || declaration.power === 'prescience'), 'Retain the actual original native battle-power declaration.');
|
||||
powerMatches({ kind: declaration.power, use: false }, declaration);
|
||||
const power = declaration.power;
|
||||
const updates = new Map<T, T>();
|
||||
for (const promise of liveTurnActionPromises(promises, player, turn)) {
|
||||
readPromise(promise);
|
||||
const combat = promise.combat, stage = combat?.powers?.[power];
|
||||
if (!combat || !stage || stage.closed || combat.event !== event || combat.territory !== territory) continue;
|
||||
const claims = new Map<string, BattlePowerClaim>();
|
||||
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
|
||||
if (node.kind === 'action' && node.action.kind === 'battlePower' && battlePowerOf(node.action.claim) === power)
|
||||
claims.set(powerKey(node.action.claim), parsePowerClaim(node.action.claim));
|
||||
else if (node.kind !== 'fact' && node.kind !== 'action') node.terms.forEach(visit);
|
||||
};
|
||||
visit(promise.mixed);
|
||||
const values = [...claims.values()].map(claim => ({ claim, value: powerMatches(claim, declaration) }));
|
||||
updates.set(promise, settle(promise, { ...promise, combat: { ...combat,
|
||||
powers: { ...combat.powers!, [power]: { ...stage, closed: true, values } } } }));
|
||||
}
|
||||
return promises.map(promise => updates.get(promise) ?? promise);
|
||||
}
|
||||
|
||||
/** Losing only this native opportunity records absence, never Decline, and
|
||||
* preserves the plan, other power and every already closed receipt. */
|
||||
export function closeTurnActionBattlePower<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
turn: number, event: string, territory: string, power: NativeBattlePower): T[] {
|
||||
check(identifier(event) && identifier(territory) && (power === 'voice' || power === 'prescience'),
|
||||
'Choose the original native battle-power opportunity.');
|
||||
const updates = new Map<T, T>();
|
||||
for (const promise of liveTurnActionPromises(promises, player, turn)) {
|
||||
readPromise(promise);
|
||||
const combat = promise.combat, stage = combat?.powers?.[power];
|
||||
if (!combat || !stage || stage.closed || combat.event !== event || combat.territory !== territory) continue;
|
||||
updates.set(promise, settle(promise, { ...promise, combat: { ...combat,
|
||||
powers: { ...combat.powers!, [power]: { ...stage, closed: true, values: null } } } }));
|
||||
}
|
||||
return promises.map(promise => updates.get(promise) ?? promise);
|
||||
}
|
||||
|
||||
/** Accepted printed-role play only. Native callers separately own effect
|
||||
* success, disposal, recovery and real card custody; none count as this event. */
|
||||
export function recordTurnActionCardPlay<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
@@ -587,10 +690,15 @@ export function closeTurnActionCombat<T extends TurnActionPromise>(promises: rea
|
||||
if (promise.turn !== turn || promise.fulfilled || promise.released) return promise;
|
||||
readPromise(promise);
|
||||
const combat = promise.combat;
|
||||
if (!combat || combat.event !== event || (!combat.plan || combat.plan.closed) && (!combat.traitor || combat.traitor.closed)) return promise;
|
||||
if (!combat || combat.event !== event || (!combat.plan || combat.plan.closed) && (!combat.traitor || combat.traitor.closed) &&
|
||||
(!combat.powers?.voice || combat.powers.voice.closed) && (!combat.powers?.prescience || combat.powers.prescience.closed)) return promise;
|
||||
return settle(promise, { ...promise, combat: { ...combat,
|
||||
plan: combat.plan && !combat.plan.closed ? { closed: true, values: null } : combat.plan,
|
||||
traitor: combat.traitor && !combat.traitor.closed ? { ...combat.traitor, closed: true, call: null } : combat.traitor,
|
||||
...(combat.powers ? { powers: {
|
||||
voice: combat.powers.voice && !combat.powers.voice.closed ? { ...combat.powers.voice, closed: true, values: null } : combat.powers.voice,
|
||||
prescience: combat.powers.prescience && !combat.powers.prescience.closed ? { ...combat.powers.prescience, closed: true, values: null } : combat.powers.prescience,
|
||||
} } : {}),
|
||||
} });
|
||||
});
|
||||
}
|
||||
@@ -792,7 +900,8 @@ export function finishTurnActionTurn<T extends TurnActionPromise>(promises: read
|
||||
ghola: promise.cardPlays.ghola ? { ...promise.cardPlays.ghola, closed: true } : null,
|
||||
} } : {}) };
|
||||
const unresolvedCombat = promise.combat && (promise.combat.plan && !promise.combat.plan.closed ||
|
||||
promise.combat.traitor && !promise.combat.traitor.closed);
|
||||
promise.combat.traitor && !promise.combat.traitor.closed || promise.combat.powers?.voice && !promise.combat.powers.voice.closed ||
|
||||
promise.combat.powers?.prescience && !promise.combat.powers.prescience.closed);
|
||||
const fulfilled = !(promise.alliance && !promise.alliance.closed) && !unresolvedCombat && turnActionValue(closed) === promise.answer;
|
||||
return { ...closed, fulfilled, released: !fulfilled };
|
||||
});
|
||||
@@ -811,6 +920,8 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
|
||||
(!promise.preparation?.revival || promise.preparation.revival.closed) &&
|
||||
(!promise.alliance || promise.alliance.closed) && (!promise.combat?.plan || promise.combat.plan.closed) &&
|
||||
(!promise.combat?.traitor || promise.combat.traitor.closed) &&
|
||||
(!promise.combat?.powers?.voice || promise.combat.powers.voice.closed) &&
|
||||
(!promise.combat?.powers?.prescience || promise.combat.powers.prescience.closed) &&
|
||||
(!promise.cardPlays?.hajr || promise.cardPlays.hajr.closed) && (!promise.cardPlays?.ghola || promise.cardPlays.ghola.closed);
|
||||
const recipientsValid = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
|
||||
? node.action.kind === 'alliance' ? players.includes(node.action.partner) && node.action.partner !== promise.player :
|
||||
@@ -821,6 +932,10 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
|
||||
(!promise.alliance || promise.alliance.partner === null || players.includes(promise.alliance.partner)) &&
|
||||
(!promise.combat?.traitor || players.includes(promise.combat.traitor.beneficiary) &&
|
||||
players.includes(promise.combat.traitor.target) && promise.combat.traitor.target !== promise.player) &&
|
||||
(['voice', 'prescience'] as const).every(power => {
|
||||
const stage = promise.combat?.powers?.[power];
|
||||
return !stage || players.includes(stage.beneficiary) && players.includes(stage.target) && stage.target !== promise.player;
|
||||
}) &&
|
||||
(promise.released || (promise.answer || !satisfied) &&
|
||||
(!promise.answer || promise.fulfilled === satisfied) &&
|
||||
(!promise.fulfilled || satisfied === promise.answer && (promise.answer || relevantClosed(compiled, promise))) &&
|
||||
|
||||
Reference in new issue
Block a user