Connect Truthtrance promises to printed Hajr and Ghola plays
This commit is contained in:
1 parent
f548039cd8
commit
c93b693137
17 files changed
+1554
-104
No files matched your search
+274
-76
@@ -1,8 +1,10 @@
|
||||
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 { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
|
||||
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, finishTurnActionTurn,
|
||||
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival,
|
||||
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, closeTurnActionPayments, finishTurnActionTurn,
|
||||
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival, recordTurnActionCardPlay,
|
||||
closeTurnActionRevival, settleTurnActionAlliance, recordTurnActionPlan, recordTurnActionTraitor, closeTurnActionCombat,
|
||||
type TurnActionPlanValue, type TurnActionCombat, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf, type TurnActionPreparation } from './turn-action-promises';
|
||||
import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShipment, recordTravelMovement,
|
||||
@@ -2176,6 +2178,8 @@ export type Game = {
|
||||
truthHistory?: TruthRecord[];
|
||||
travelPromises?: TravelPromise[];
|
||||
turnActionPromises?: TurnActionPromise[];
|
||||
/** Actual accepted printed-role uses; old games need no baseline retrofit. */
|
||||
printedCardPlays?: PrintedCardPlayCounts;
|
||||
/** Accepted order shared by phase-five travel, shipment and movement answers. */
|
||||
travelPromiseOrder?: { kind: 'shipment' | 'movement' | 'movementExpression' | 'travel' | 'bribe' | 'bribeExpression' | 'turnActions'; index: number }[];
|
||||
mixedTraitorQuestions?: (MixedTraitorClaim & {
|
||||
@@ -18804,7 +18808,12 @@ function projectMovementBoxRecovery(source: Game, actor: string, box: string, se
|
||||
try {
|
||||
if (paid) requireRule(game.pendingNullentropy?.player === actor && !nullentropyIntegrity(game),
|
||||
'Retain the original paid search.');
|
||||
else { validateNullentropyBegin(game, owner, box); owner.spice -= 2; }
|
||||
else {
|
||||
validateNullentropyBegin(game, owner, box); owner.spice -= 2;
|
||||
// The real voluntary Box play closes Advanced Hajr's immediate window.
|
||||
// Recovery cannot preserve that window across its own card action.
|
||||
if (game.advanced) delete owner.hajrWindow;
|
||||
}
|
||||
const context = nullentropyContext(game), player = getPlayer(context, actor);
|
||||
let result: ReturnType<typeof resolveNullentropyBox>;
|
||||
try { result = resolveNullentropyBox(player.hand, box, context.discard, selected,
|
||||
@@ -18825,6 +18834,91 @@ function turnActionRule<T>(operation: () => T): T {
|
||||
try { return operation(); }
|
||||
catch (error) { if (error instanceof TurnActionError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
type TurnActionCardRequirement = { effect: PrintedCardEffect; answer: boolean; baseline: number };
|
||||
/** This field exists only on detached completion clones and is never returned
|
||||
* as a real game or installed as a selected whole-answer branch. */
|
||||
type TurnActionCardProbe = Game & { _turnActionCardPolicy?: { player: string; forbidden: PrintedCardEffect[] } };
|
||||
function printedCardRule<T>(operation: () => T): T {
|
||||
try { return operation(); }
|
||||
catch (error) { if (error instanceof PrintedCardPlayError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function nativePrintedCardCount(g: Game, player: string, effect: PrintedCardEffect): number {
|
||||
return printedCardRule(() => printedCardPlayCount(g.printedCardPlays, player, g.turn, effect));
|
||||
}
|
||||
function recordNativePrintedCardPlay(g: Game, player: string, effect: PrintedCardEffect) {
|
||||
const policy = (g as TurnActionCardProbe)._turnActionCardPolicy;
|
||||
requireRule(policy?.player !== player || !policy.forbidden.includes(effect),
|
||||
'Retain a native completion without this forbidden printed-role card play.');
|
||||
g.printedCardPlays = printedCardRule(() => recordPrintedCardPlay(g.printedCardPlays, player, g.turn, effect));
|
||||
if (g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
|
||||
recordTurnActionCardPlay(g.turnActionPromises!, player, g.turn, effect));
|
||||
}
|
||||
function turnActionCardsMatch(g: Game, actor: string, requirements: readonly TurnActionCardRequirement[]): boolean {
|
||||
return requirements.every(required => (nativePrintedCardCount(g, actor, required.effect) > required.baseline) === required.answer);
|
||||
}
|
||||
function* nativePrintedCardActions(g: Game, actor: string, effect: PrintedCardEffect): Generator<Action> {
|
||||
const player = getPlayer(g, actor);
|
||||
if (effect === 'hajr') {
|
||||
if (g.phase === 5 && g.active === actor && hajrAvailable(g, player))
|
||||
for (const card of player.hand.filter(card => card.effect === 'hajr' && !transferCardBlock(g, player, card)))
|
||||
yield { type: 'card', card: card.id };
|
||||
return;
|
||||
}
|
||||
const ghola = gholaOptions(g, player);
|
||||
if (!ghola.available) return;
|
||||
for (const card of ghola.cards) {
|
||||
for (const leader of ghola.leaders) yield { type: 'card', card, leader: leader.id };
|
||||
if (ghola.kwisatz) yield { type: 'card', card, leader: 'kwisatz' };
|
||||
}
|
||||
yield* gholaForcePreparations({ cards: ghola.cards, tanks: player.tanks,
|
||||
eliteTanks: player.elites?.tanks ?? 0, maxForces: ghola.maxForces, eliteRemaining: ghola.eliteRemaining });
|
||||
}
|
||||
/** Current Ghola and known owned Box routes share the original physical cost.
|
||||
* A separate Movement forecast retains the later-play alternative. */
|
||||
function* turnActionCardSeeds(source: Game, actor: string, requirements: readonly TurnActionCardRequirement[]):
|
||||
Generator<{ game: Game; actions: Action[] }> {
|
||||
const gholaDue = requirements.some(required => required.effect === 'ghola' && required.answer &&
|
||||
nativePrintedCardCount(source, actor, 'ghola') <= required.baseline);
|
||||
yield { game: gholaDue ? structuredClone(source) : source, actions: [] };
|
||||
if (!gholaDue) return;
|
||||
const player = getPlayer(source, actor);
|
||||
const plays = function* (game: Game, prefix: Action[]): Generator<{ game: Game; actions: Action[] }> {
|
||||
for (const action of nativePrintedCardActions(game, actor, 'ghola')) {
|
||||
try {
|
||||
const played = applyActionInner(game, actor, action, 'movementPreparation');
|
||||
const owner = getPlayer(played, actor);
|
||||
owner.spice += pendingBattleRevivalIncome(played, owner);
|
||||
yield { game: groundMovementProbe(played, actor), actions: [...prefix, action] };
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
};
|
||||
yield* plays(source, []);
|
||||
const paid = source.pendingNullentropy;
|
||||
if (paid?.player === actor) {
|
||||
if (!nullentropyIntegrity(source)) for (const card of eligibleNullentropyCards(source.discard).filter(card => card.effect === 'ghola')) {
|
||||
const projected = projectMovementBoxRecovery(source, actor, paid.box, card.id, true);
|
||||
if (projected) yield* plays(projected, [{ type: 'decision', event: paid.event, card: card.id }]);
|
||||
}
|
||||
} else for (const box of player.hand.filter(card => card.effect === 'nullentropyBox'))
|
||||
for (const known of knownMovementDiscards(source.movementDiscardKnowledge, actor, source.discard).filter(card => card.effect === 'ghola')) {
|
||||
const projected = projectMovementBoxRecovery(source, actor, box.id, known.id, false);
|
||||
if (projected) yield* plays(projected, [{ type: 'card', card: box.id }]);
|
||||
}
|
||||
}
|
||||
/** Check the actual spent card and returned pieces before a later foreign
|
||||
* income/discard response can hide an own voluntary loss of feasibility. */
|
||||
function guardTurnActionPrintedCardCost(g: Game, player: Player, used: Card) {
|
||||
if (!liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).length) return;
|
||||
const trial = structuredClone(g), owner = getPlayer(trial, player.id);
|
||||
owner.spice += pendingBattleRevivalIncome(trial, owner);
|
||||
trial.truthtrance = null; trial.response = null; trial.pendingKarama = null;
|
||||
// The Ghola effect/cost is complete; forecast beyond its owned interruption
|
||||
// without executing or borrowing the original market's pending income.
|
||||
if (trial.pendingChoamMarketGhola?.player === player.id) trial.pendingChoamMarketGhola = null;
|
||||
rememberMovementDiscards(trial, [{ card: used, discardedBy: player.id, publicFace: true }]);
|
||||
requireRule(!!turnActionCompletion(trial, player.id),
|
||||
'This printed card play would remove the last legal completion of your whole Truthtrance answer.');
|
||||
}
|
||||
function turnActionTargetAvailable(g: Game, _actor: string): boolean {
|
||||
return bribeQuestionWindow(g) && movementPromiseModeSupported(g);
|
||||
}
|
||||
@@ -18991,7 +19085,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 };
|
||||
type TurnActionCompletion = MovementCompletion & { payments: Action[]; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action; cardPreparations?: 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 {
|
||||
@@ -19015,6 +19109,11 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
|
||||
delete trial.travelPromises;
|
||||
delete trial.travelPromiseOrder;
|
||||
const owner = getPlayer(trial, actor);
|
||||
const cardRequirements: TurnActionCardRequirement[] = leaves.flatMap(leaf => leaf.kind === 'cardPlay'
|
||||
? [{ effect: leaf.effect, answer: leaf.answer, baseline: nativePrintedCardCount(source, actor, leaf.effect) }] : []);
|
||||
if (cardRequirements.length) (trial as TurnActionCardProbe)._turnActionCardPolicy = {
|
||||
player: actor, forbidden: cardRequirements.filter(required => !required.answer).map(required => required.effect),
|
||||
};
|
||||
trial.bribePromises ??= [];
|
||||
trial.shipmentPromises ??= [];
|
||||
trial.movementPromises ??= [];
|
||||
@@ -19071,70 +19170,79 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
|
||||
required.some(payment => payment.recipient === actor || payment.recipient === owner.ally ||
|
||||
!trial.players.some(player => player.id === payment.recipient))) continue;
|
||||
for (const alliancePlan of alliancePlans) {
|
||||
const funded = structuredClone(trial), payer = getPlayer(funded, actor);
|
||||
const paid = structuredClone(trial), paidOwner = getPlayer(paid, actor);
|
||||
try {
|
||||
for (const payment of required) recordBribePayment(funded, actor, payment.recipient, payment.amount);
|
||||
payer.spice -= total;
|
||||
for (const payment of required) recordBribePayment(paid, actor, payment.recipient, payment.amount);
|
||||
paidOwner.spice -= total;
|
||||
for (const action of alliancePlan?.steps ?? []) {
|
||||
const quote = normalNexusAllianceQuote(funded, getPlayer(funded, action.player), action.target);
|
||||
for (const player of funded.players) player.ally = quote.allies[player.id];
|
||||
funded.allianceOffers = quote.offers;
|
||||
if (quote.formed) for (const id of quote.formed) getPlayer(funded, id).allySinceTurn = funded.turn;
|
||||
funded.ready = [];
|
||||
const quote = normalNexusAllianceQuote(paid, getPlayer(paid, action.player), action.target);
|
||||
for (const player of paid.players) player.ally = quote.allies[player.id];
|
||||
paid.allianceOffers = quote.offers;
|
||||
if (quote.formed) for (const id of quote.formed) getPlayer(paid, id).allySinceTurn = paid.turn;
|
||||
paid.ready = [];
|
||||
}
|
||||
if (alliancePlan) {
|
||||
const opportunity = allianceOpportunity(funded)!;
|
||||
funded.alliancePromises = (funded.alliancePromises ?? []).map(promise => promise.player === actor &&
|
||||
const opportunity = allianceOpportunity(paid)!;
|
||||
paid.alliancePromises = (paid.alliancePromises ?? []).map(promise => promise.player === actor &&
|
||||
promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.released
|
||||
? { ...promise, fulfilled: true } : promise);
|
||||
funded.allianceExpressionPromises = (funded.allianceExpressionPromises ?? []).map(promise => promise.player === actor &&
|
||||
paid.allianceExpressionPromises = (paid.allianceExpressionPromises ?? []).map(promise => promise.player === actor &&
|
||||
promise.turn === opportunity.turn && promise.source === opportunity.source && !promise.released
|
||||
? { ...promise, fulfilled: true } : promise);
|
||||
}
|
||||
const voluntary = { actor, action: { type: 'bribe' } };
|
||||
for (const payment of required)
|
||||
checkBureaucratContributors(funded, payment.recipient, [{ payer: actor, amount: payment.amount }]);
|
||||
reconcileBattlePromises(funded, voluntary);
|
||||
const battle = funded.battle;
|
||||
const needsPlan = !!battle && !battle.revealed && [battle.attacker, battle.defender].includes(actor) &&
|
||||
!!battle.truthPromises?.some(promise => promise.player === actor && !promise.released);
|
||||
const battlePlan = needsPlan ? findReachableBattlePlan(funded, payer) : undefined;
|
||||
if (needsPlan && !battlePlan) continue;
|
||||
const traitorContext = traitorTruthContext(funded, actor);
|
||||
const needsTraitor = requestedTraitor || !!battle?.traitorDecisionPromises?.some(promise =>
|
||||
promise.player === actor && !promise.fulfilled && !promise.released);
|
||||
let traitor: Action | undefined;
|
||||
if (needsTraitor) {
|
||||
if (!traitorContext || traitorContext.submitted !== undefined) continue;
|
||||
const choices = traitorDecisionRule(() => traitorDecisionChoices(traitorContext, battle?.traitorDecisionPromises ?? []));
|
||||
if (!choices.length) continue;
|
||||
traitor = { type: 'traitorCall', call: choices[0] };
|
||||
}
|
||||
reconcileBidPromises(funded, voluntary);
|
||||
reconcileRevivalPromises(funded, voluntary);
|
||||
const lot = bidOpportunity(funded);
|
||||
const needsBid = !!lot && bidRequired(funded.bidPromises ?? [], actor, lot);
|
||||
const bid = needsBid ? bidPromiseCompletion(funded, payer) : undefined;
|
||||
if (needsBid && !bid) continue;
|
||||
const needsRevival = [
|
||||
...liveRevivalPromises(funded.revivalPromises ?? [], actor, source.turn),
|
||||
...liveLeaderRevivalPromises(funded.leaderRevivalPromises ?? [], actor, source.turn),
|
||||
...liveRevivalExpressionPromises(funded.revivalExpressionPromises ?? [], actor, source.turn),
|
||||
].some(promise => promise.answer);
|
||||
const revival = needsRevival ? revivalPromisePlan(funded, payer) : undefined;
|
||||
if (needsRevival && !revival) continue;
|
||||
const transport = [
|
||||
...liveShipmentPromises(funded.shipmentPromises ?? [], actor, source.turn),
|
||||
...liveMovementPromises(funded.movementPromises ?? [], actor, source.turn),
|
||||
...liveMovementExpressionPromises(funded.movementExpressionPromises ?? [], actor, source.turn),
|
||||
];
|
||||
const completion = transport.length ? groundMovementCompletionNative(funded, actor) : { actions: [] };
|
||||
if (completion) {
|
||||
if (alliancePlan?.uncertain) { unknownConsent!.value = true; continue; }
|
||||
return { ...completion, ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
|
||||
...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}),
|
||||
...(alliancePlan ? { alliance: alliancePlan.steps.map(step => ({ type: 'alliance', target: step.target })) } : {}),
|
||||
payments: required.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount })) };
|
||||
checkBureaucratContributors(paid, payment.recipient, [{ payer: actor, amount: payment.amount }]);
|
||||
for (const cardSeed of turnActionCardSeeds(paid, actor, cardRequirements)) {
|
||||
const funded = cardSeed.game, payer = getPlayer(funded, actor);
|
||||
try {
|
||||
reconcileBattlePromises(funded, voluntary);
|
||||
const battle = funded.battle;
|
||||
const needsPlan = !!battle && !battle.revealed && [battle.attacker, battle.defender].includes(actor) &&
|
||||
!!battle.truthPromises?.some(promise => promise.player === actor && !promise.released);
|
||||
const battlePlan = needsPlan ? findReachableBattlePlan(funded, payer, { cardPlays: cardRequirements }) : undefined;
|
||||
if (needsPlan && !battlePlan) continue;
|
||||
const traitorContext = traitorTruthContext(funded, actor);
|
||||
const needsTraitor = requestedTraitor || !!battle?.traitorDecisionPromises?.some(promise =>
|
||||
promise.player === actor && !promise.fulfilled && !promise.released);
|
||||
let traitor: Action | undefined;
|
||||
if (needsTraitor) {
|
||||
if (!traitorContext || traitorContext.submitted !== undefined) continue;
|
||||
const choices = traitorDecisionRule(() => traitorDecisionChoices(traitorContext, battle?.traitorDecisionPromises ?? []));
|
||||
if (!choices.length) continue;
|
||||
traitor = { type: 'traitorCall', call: choices[0] };
|
||||
}
|
||||
reconcileBidPromises(funded, voluntary);
|
||||
reconcileRevivalPromises(funded, voluntary);
|
||||
const lot = bidOpportunity(funded);
|
||||
const needsBid = !!lot && bidRequired(funded.bidPromises ?? [], actor, lot);
|
||||
const bid = needsBid ? bidPromiseCompletion(funded, payer) : undefined;
|
||||
if (needsBid && !bid) continue;
|
||||
const needsRevival = [
|
||||
...liveRevivalPromises(funded.revivalPromises ?? [], actor, source.turn),
|
||||
...liveLeaderRevivalPromises(funded.leaderRevivalPromises ?? [], actor, source.turn),
|
||||
...liveRevivalExpressionPromises(funded.revivalExpressionPromises ?? [], actor, source.turn),
|
||||
].some(promise => promise.answer);
|
||||
const revival = needsRevival ? revivalPromisePlan(funded, payer) : undefined;
|
||||
if (needsRevival && !revival) continue;
|
||||
const transport = [
|
||||
...liveShipmentPromises(funded.shipmentPromises ?? [], actor, source.turn),
|
||||
...liveMovementPromises(funded.movementPromises ?? [], actor, source.turn),
|
||||
...liveMovementExpressionPromises(funded.movementExpressionPromises ?? [], actor, source.turn),
|
||||
];
|
||||
const cardsPending = !turnActionCardsMatch(funded, actor, cardRequirements);
|
||||
const forecastCards = cardsPending && source.phase <= 5 && turnActionTransportOpen(source, actor);
|
||||
const completion = transport.length || forecastCards
|
||||
? groundMovementCompletionNative(funded, actor, undefined, undefined, cardRequirements)
|
||||
: !cardsPending || battlePlan?.cardPlaysComplete ? { actions: [] } : null;
|
||||
if (completion) {
|
||||
if (alliancePlan?.uncertain) { unknownConsent!.value = true; continue; }
|
||||
return { ...completion, ...(cardSeed.actions.length ? { cardPreparations: cardSeed.actions } : {}), ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
|
||||
...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}),
|
||||
...(alliancePlan ? { alliance: alliancePlan.steps.map(step => ({ type: 'alliance', target: step.target })) } : {}),
|
||||
payments: required.map(payment => ({ type: 'bribe', target: payment.recipient, amount: payment.amount })) };
|
||||
}
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
@@ -19148,13 +19256,15 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
const compiled = compileMixedActionExpression(claim.mixed,
|
||||
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;
|
||||
let bid = false, revival = false, alliance = false, plan = false, traitor = false, cardHajr = false, cardGhola = false;
|
||||
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 === '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;
|
||||
} else if (node.kind !== 'fact') node.terms.forEach(visit);
|
||||
};
|
||||
@@ -19177,6 +19287,8 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
|
||||
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,
|
||||
...(cardHajr || cardGhola ? { cardPlays: { hajr: cardHajr ? { played: false, closed: !open } : null,
|
||||
ghola: cardGhola ? { played: false, closed: false } : null } } : {}),
|
||||
...(combat ? { combat } : {}),
|
||||
...(preparation ? { preparation } : {}),
|
||||
...(nexus ? { alliance: { source: nexus.source, partner: null, closed: false } } : {}),
|
||||
@@ -19303,6 +19415,7 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
||||
const completion = turnActionCompletion(g, player.id);
|
||||
if (!completion) return null;
|
||||
let action = !bribeTimingBlock(g) ? completion.payments[0] : undefined;
|
||||
if (!action) action = completion.cardPreparations?.[0];
|
||||
if (!action && g.phase <= 5) {
|
||||
const lot = g.phase === 3 && !g.decision ? bidOpportunity(g) : null;
|
||||
if (lot && g.auction?.active === player.id) action = completion.bid ??
|
||||
@@ -19526,12 +19639,13 @@ function groundMovementCompletion(source: Game, actor: string,
|
||||
}
|
||||
function groundMovementCompletionNative(source: Game, actor: string,
|
||||
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn),
|
||||
expressions = liveMovementExpressionPromises(source.movementExpressionPromises ?? [], actor, source.turn)): MovementCompletion | null {
|
||||
expressions = liveMovementExpressionPromises(source.movementExpressionPromises ?? [], actor, source.turn),
|
||||
cardPlays: readonly TurnActionCardRequirement[] = []): MovementCompletion | null {
|
||||
if (!movementPromiseModeSupported(source) || source.phase < 0 || source.phase > 5) return null;
|
||||
const game = groundMovementProbe(source, actor);
|
||||
if (source.phase === 5 && source.phaseOpening?.initialize) {
|
||||
resetGroundMovementForecast(game, actor);
|
||||
return groundMovementCompletionAtMovement(game, actor, promises, expressions);
|
||||
return groundMovementCompletionAtMovement(game, actor, promises, expressions, cardPlays);
|
||||
}
|
||||
if (source.phase < 5) {
|
||||
const paid = source.pendingNullentropy;
|
||||
@@ -19539,20 +19653,22 @@ function groundMovementCompletionNative(source: Game, actor: string,
|
||||
if (nullentropyIntegrity(source)) return null;
|
||||
for (const card of eligibleNullentropyCards(source.discard)) {
|
||||
const projected = projectMovementBoxRecovery(source, actor, paid.box, card.id, true);
|
||||
const completion = projected && groundMovementCompletion(projected, actor, promises, expressions);
|
||||
const completion = projected && (cardPlays.length
|
||||
? groundMovementCompletionNative(projected, actor, promises, expressions, cardPlays)
|
||||
: groundMovementCompletion(projected, actor, promises, expressions));
|
||||
if (completion) return { ...completion,
|
||||
actions: [{ type: 'decision', event: paid.event, card: card.id }, ...completion.actions] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
for (const seed of earlyShipmentCompletionSeeds(game, source, actor)) {
|
||||
for (const seed of earlyShipmentCompletionSeeds(game, source, actor, cardPlays.length > 0)) {
|
||||
resetGroundMovementForecast(seed.game, actor);
|
||||
const completion = groundMovementCompletionAtMovement(seed.game, actor, promises, expressions);
|
||||
const completion = groundMovementCompletionAtMovement(seed.game, actor, promises, expressions, cardPlays);
|
||||
if (completion) return { actions: [...seed.actions, ...completion.actions], early: seed.early };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return groundMovementCompletionAtMovement(game, actor, promises, expressions);
|
||||
return groundMovementCompletionAtMovement(game, actor, promises, expressions, cardPlays);
|
||||
}
|
||||
function resetGroundMovementForecast(game: Game, actor: string) {
|
||||
resetPlayerMovement(getPlayer(game, actor));
|
||||
@@ -19561,14 +19677,35 @@ function resetGroundMovementForecast(game: Game, actor: string) {
|
||||
game.movementRemaining = [actor];
|
||||
}
|
||||
function groundMovementCompletionAtMovement(game: Game, actor: string, promises: readonly MovementPromise[],
|
||||
expressions: readonly MovementExpressionPromise[]): MovementCompletion | null {
|
||||
expressions: readonly MovementExpressionPromise[], cardPlays: readonly TurnActionCardRequirement[] = []): MovementCompletion | null {
|
||||
if (!movementPromiseTargetAvailable(game.pendingNullentropy?.player === actor ? { ...game, decision: null } : game, actor)) return null;
|
||||
const owner = getPlayer(game, actor), paidBox = game.pendingNullentropy?.player === actor;
|
||||
for (const required of cardPlays.filter(required => required.answer &&
|
||||
nativePrintedCardCount(game, actor, required.effect) <= required.baseline)) {
|
||||
const held = owner.hand.some(card => card.effect === required.effect);
|
||||
const recoverable = paidBox ? eligibleNullentropyCards(game.discard)
|
||||
: owner.hand.some(card => card.effect === 'nullentropyBox')
|
||||
? knownMovementDiscards(game.movementDiscardKnowledge, actor, game.discard) : [];
|
||||
if (!held && !recoverable.some(card => card.effect === required.effect)) return null;
|
||||
if (required.effect === 'ghola') {
|
||||
const options = gholaOptions(game, owner);
|
||||
if (!options.maxForces && !options.leaders.length && !options.kwisatz) return null;
|
||||
}
|
||||
}
|
||||
return findMovementCompletion(game, actor, promises, {
|
||||
allowance: movesAllowed,
|
||||
canFinish(state, owner) {
|
||||
return !state.pendingNullentropy && liveShipmentPromises(state.shipmentPromises ?? [], owner, state.turn)
|
||||
return !state.pendingNullentropy && turnActionCardsMatch(state, owner, cardPlays) &&
|
||||
liveShipmentPromises(state.shipmentPromises ?? [], owner, state.turn)
|
||||
.every(promise => matchesShipment(promise, null) === promise.answer);
|
||||
},
|
||||
needsProgress(state, owner) {
|
||||
return state.advanced && !hajrAvailable(state, getPlayer(state, owner)) && cardPlays.some(required =>
|
||||
required.effect === 'hajr' && required.answer && nativePrintedCardCount(state, owner, 'hajr') <= required.baseline);
|
||||
},
|
||||
progressKey(state, owner) {
|
||||
return cardPlays.map(required => [required.effect, nativePrintedCardCount(state, owner, required.effect) > required.baseline]);
|
||||
},
|
||||
*preparations(state, owner) {
|
||||
const player = getPlayer(state, owner), paid = state.pendingNullentropy;
|
||||
const relevant = (card: Card) => card.effect === 'hajr' || card.effect === 'ornithopter' || card.effect === 'ghola' ||
|
||||
@@ -19583,6 +19720,12 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises:
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const required of cardPlays.filter(required => required.answer &&
|
||||
nativePrintedCardCount(state, owner, required.effect) <= required.baseline))
|
||||
for (const action of nativePrintedCardActions(state, owner, required.effect)) {
|
||||
try { yield { game: groundMovementProbe(applyActionInner(state, owner, action, 'movementPreparation'), owner), actions: [action] }; }
|
||||
catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
if (state.advanced && player.faction === 'beneGesserit') {
|
||||
for (const territory of quoteAdvisorPreparationWindow(state, owner).territories) {
|
||||
const action: Action = { type: 'prepareAdvisors', territory };
|
||||
@@ -27603,6 +27746,7 @@ function playMarketGhola(g: Game, p: Player, action: Action) {
|
||||
}
|
||||
if (g.response) (g.response as ResponseWindow).intent = g.pendingChoamMarketGhola.event;
|
||||
const used = discard(g, p, card.id);
|
||||
guardTurnActionPrintedCardCost(g, p, used);
|
||||
log(g, `${p.name} played ${card.name}. The declared CHOAM sale waits for this revival and its income to finish.`,
|
||||
{ faction: p.faction, name: 'Ghola revival' });
|
||||
stageOrdinaryCardDiscard(g, p, used);
|
||||
@@ -27674,6 +27818,7 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
|
||||
}
|
||||
techIncome(g, 'axlotl', p);
|
||||
collectRevivalIncome(g, p, 0, false, true);
|
||||
recordNativePrintedCardPlay(g, p.id, 'ghola');
|
||||
}
|
||||
/** Search complete legal plans without learning or consulting the opponent's sealed choices. */
|
||||
function findLegalBattlePlan(
|
||||
@@ -27796,6 +27941,7 @@ type BattleCompletion = {
|
||||
plan: Plan;
|
||||
actions: Action[];
|
||||
waitingForIncome: boolean;
|
||||
cardPlaysComplete?: true;
|
||||
};
|
||||
function gholaPreparationActions(g: Game, p: Player): Action[] {
|
||||
const card = p.hand.find((c) => c.effect === 'ghola');
|
||||
@@ -27848,11 +27994,12 @@ function pendingBattleRevivalIncome(g: Game, p: Player): number {
|
||||
function findReachableBattlePlan(
|
||||
g: Game,
|
||||
p: Player,
|
||||
options: Parameters<typeof findLegalBattlePlan>[2] = {},
|
||||
options: Parameters<typeof findLegalBattlePlan>[2] & { cardPlays?: readonly TurnActionCardRequirement[] } = {},
|
||||
): BattleCompletion | null {
|
||||
const cardPlays = options.cardPlays ?? [];
|
||||
const immediate = findLegalBattlePlan(g, p, options);
|
||||
if (immediate)
|
||||
return { plan: immediate, actions: [], waitingForIncome: false };
|
||||
if (immediate && turnActionCardsMatch(g, p.id, cardPlays))
|
||||
return { plan: immediate, actions: [], waitingForIncome: false, ...(cardPlays.length ? { cardPlaysComplete: true } : {}) };
|
||||
if (g.battle!.revealed || g.battle!.plans[p.id]) return null;
|
||||
const initial = structuredClone(g);
|
||||
const player = getPlayer(initial, p.id);
|
||||
@@ -27875,8 +28022,8 @@ function findReachableBattlePlan(
|
||||
actor = getPlayer(node.state, p.id);
|
||||
if (node.actions.length || pending) {
|
||||
const plan = findLegalBattlePlan(node.state, actor, options);
|
||||
if (plan)
|
||||
return { plan, actions: node.actions, waitingForIncome: pending > 0 };
|
||||
if (plan && turnActionCardsMatch(node.state, p.id, cardPlays))
|
||||
return { plan, actions: node.actions, waitingForIncome: pending > 0, ...(cardPlays.length ? { cardPlaysComplete: true } : {}) };
|
||||
}
|
||||
for (const action of [
|
||||
...(node.state.status === 'playing' && node.state.phase === 6 && actor.faction === 'ixians' &&
|
||||
@@ -28065,7 +28212,40 @@ function earlyRevivalCompletionSeeds(initial: Game, source: Game, owner: string)
|
||||
return { ...seed, game: g };
|
||||
});
|
||||
}
|
||||
function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string): ShipmentCompletionSeed[] {
|
||||
/** Materialize required ordinary returns before a later printed-card witness
|
||||
* can use the same Tanks or leader. These are the native cumulative quotes,
|
||||
* not an independent optimistic Revival reservation beside the card route. */
|
||||
function completeTurnActionRevivalSeed(seed: ShipmentCompletionSeed, owner: string): ShipmentCompletionSeed | null {
|
||||
const game = structuredClone(seed.game), actions = [...seed.actions], early = [...seed.early];
|
||||
const visited = new Set<string>();
|
||||
const pending = () => [
|
||||
...liveRevivalPromises(game.revivalPromises ?? [], owner, game.turn),
|
||||
...liveLeaderRevivalPromises(game.leaderRevivalPromises ?? [], owner, game.turn),
|
||||
...liveRevivalExpressionPromises(game.revivalExpressionPromises ?? [], owner, game.turn),
|
||||
].some(promise => promise.answer);
|
||||
while (pending()) {
|
||||
const player = getPlayer(game, owner);
|
||||
const signature = JSON.stringify([player.tanks, player.elites, player.spice, player.leaders,
|
||||
player.kwisatz, player.revived, player.leaderRevived, game.revivalPromises,
|
||||
game.leaderRevivalPromises, game.revivalExpressionPromises]);
|
||||
if (visited.has(signature)) return null;
|
||||
visited.add(signature);
|
||||
const action = currentRevivalPromiseCompletion(game, player, game.revivalPromises ?? []);
|
||||
if (!action || action.type === 'ready') return null;
|
||||
try {
|
||||
if (action.type === 'revive') stagePromisedForceReturn(game, player, normalForceRevivalQuote(game, player, action));
|
||||
else if (['reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival'].includes(action.type)) {
|
||||
const quote = action.type === 'acceptLeaderRevival' ? negotiatedLeaderRevivalQuote(game, player)
|
||||
: normalLeaderRevivalQuote(game, player, action);
|
||||
stagePromisedLeaderReturn(game, player, quote);
|
||||
} else return null;
|
||||
actions.push(action); early.push({ phase: 4, action });
|
||||
} catch (error) { if (error instanceof RuleError) return null; throw error; }
|
||||
}
|
||||
return { game, actions, early };
|
||||
}
|
||||
function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string,
|
||||
completeRevival = false): ShipmentCompletionSeed[] {
|
||||
const seeds = earlyRevivalCompletionSeeds(initial, source, owner);
|
||||
// A real Movement quote already owns its active Karama/rate and allowances.
|
||||
// Only earlier phases may pass through the future Revival/Movement reset.
|
||||
@@ -28085,7 +28265,11 @@ function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
}
|
||||
return revivalSeeds.map(seed => {
|
||||
const completed = completeRevival ? revivalSeeds.flatMap(seed => {
|
||||
const next = completeTurnActionRevivalSeed(seed, owner);
|
||||
return next ? [next] : [];
|
||||
}) : revivalSeeds;
|
||||
return completed.map(seed => {
|
||||
seed.game.phase = 5; seed.game.ready = []; seed.game.karamaShipping = null;
|
||||
delete seed.game.revivalOrder;
|
||||
const p = getPlayer(seed.game, owner); p.shipped = false; p.moved = 0;
|
||||
@@ -37079,6 +37263,12 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
const guide = projectedTurnActionGuide(state, getPlayer(state, id));
|
||||
requireRule(guide && action.turnActionPromisePhase === state.phase && movementGuideActionMatches(action, guide.action),
|
||||
'This turn-action guide is stale or belongs to another opportunity.');
|
||||
// Validate the exact guide envelope before the native dispatcher receives
|
||||
// its original action. Strict card controls such as Box accept no extra
|
||||
// selection fields; promise metadata is not a native card selection.
|
||||
action = { ...action };
|
||||
delete action.turnActionPromiseEvent;
|
||||
delete action.turnActionPromisePhase;
|
||||
}
|
||||
if (action.travelPromiseEvent !== undefined || action.travelPromisePhase !== undefined) {
|
||||
const guide = projectedTravelGuide(state, getPlayer(state, id));
|
||||
@@ -41426,6 +41616,7 @@ function applyActionInner(
|
||||
throw new RuleError(availability.reason);
|
||||
if (c.effect === 'hajr') {
|
||||
p.hajrPlays = hajrPlayCount(g,p) + 1;
|
||||
recordNativePrintedCardPlay(g, p.id, 'hajr');
|
||||
if (!g.hajr.includes(id)) g.hajr.push(id);
|
||||
delete p.hajrWindow;
|
||||
const cunning = currentGuildCunning(g,id);
|
||||
@@ -41465,6 +41656,8 @@ function applyActionInner(
|
||||
'This card’s timing and effect are still being implemented.',
|
||||
);
|
||||
const used = discard(g, p, c.id);
|
||||
if (execution === 'live' && (c.effect === 'hajr' || c.effect === 'ghola'))
|
||||
guardTurnActionPrintedCardCost(g, p, used);
|
||||
log(
|
||||
g,
|
||||
`${p.name} played ${c.name}.`,
|
||||
@@ -41481,10 +41674,15 @@ function applyActionInner(
|
||||
if (allianceQuestionWindow(g)) guardAlliancePromiseReady(g, p);
|
||||
const bribeWindowCloses = g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g);
|
||||
if (bribeWindowCloses && g.turnActionPromises) {
|
||||
const closed = turnActionRule(() => finishTurnActionTurn(g.turnActionPromises!, p.id, g.turn));
|
||||
// Known Double closes payments at the next opening, while Ghola keeps
|
||||
// its actual Mentat opportunity. Only full turn expiry closes both.
|
||||
const closed = turnActionRule(() => g.phase === 8
|
||||
? finishTurnActionTurn(g.turnActionPromises!, p.id, g.turn)
|
||||
: closeTurnActionPayments(g.turnActionPromises!, p.id, g.turn));
|
||||
requireRule(!g.turnActionPromises.some((promise, index) => promise.player === p.id && promise.turn === g.turn &&
|
||||
!promise.fulfilled && !promise.released && closed[index].released),
|
||||
'Complete your whole Truthtrance payment answer before closing this turn’s payments.');
|
||||
!promise.fulfilled && !promise.released && closed[index].released) &&
|
||||
(g.phase === 8 || !!turnActionCompletion({ ...g, turnActionPromises: closed }, p.id)),
|
||||
'Complete your whole Truthtrance answer or retain a legal alternative before closing its opportunity.');
|
||||
}
|
||||
if (bribeWindowCloses) requireRule(!hasBribePromises(g, p.id, true),
|
||||
g.phase === 8 ? 'Complete your Truthtrance bribe payments before finishing Mentat Pause.'
|
||||
|
||||
@@ -8,6 +8,10 @@ import { evaluateMovementExpression, liveMovementExpressionPromises, movementExp
|
||||
export type MovementPromiseAdapter = {
|
||||
allowance: (game: Game, player: Player) => number;
|
||||
canFinish: (game: Game, player: string) => boolean;
|
||||
/** A required preparation may itself need a real first move, such as the
|
||||
* native Advanced Hajr window, even without a positive destination claim. */
|
||||
needsProgress?: (game: Game, player: string) => boolean;
|
||||
progressKey?: (game: Game, player: string) => unknown;
|
||||
preparations: (game: Game, player: string) => Iterable<{ game: Game; actions: Action[] }>;
|
||||
reachable: (game: Game, player: Player, origin: string, destination: string, sector: number) => boolean;
|
||||
project: (game: Game, player: string, action: Action) => { game: Game; event: MovementEvent } | null;
|
||||
@@ -75,14 +79,14 @@ export function findMovementCompletion(source: Game, actor: string,
|
||||
game.discard.map(card => card.id), game.movementDiscardKnowledge?.[actor], game.pendingNullentropy,
|
||||
game.choamWorthlessBlocked, game.hajr, game.ornithopter ? [game.ornithopter.mode, game.ornithopter.startingMove, game.ornithopter.completed, game.ornithopter.cohort] : null,
|
||||
live.map(promise => [promise.territory, promise.minimum, promise.answer]),
|
||||
liveWhole.map(promise => [promise.claim, promise.answer, promise.progress])]);
|
||||
liveWhole.map(promise => [promise.claim, promise.answer, promise.progress]), adapter.progressKey?.(game, actor)]);
|
||||
if (failed.has(key)) return null;
|
||||
const destinations = gameTerritories(game).filter(destination => !destination.id.startsWith('homeworld:'));
|
||||
const wanted = new Set([...live.filter(promise => promise.answer).map(promise => promise.territory),
|
||||
...positiveWhole.flatMap(promise => movementExpressionTerritories(promise.claim))]);
|
||||
destinations.sort((a, b) => Number(wanted.has(b.id)) - Number(wanted.has(a.id)) ||
|
||||
(marker ? Number(['arrakeen', 'carthag'].includes(b.id)) - Number(['arrakeen', 'carthag'].includes(a.id)) : 0));
|
||||
if (needsMove && (marker || !live.some(promise => promise.answer && promise.minimum > physical)) &&
|
||||
if ((needsMove || adapter.needsProgress?.(game, actor)) && (marker || !live.some(promise => promise.answer && promise.minimum > physical)) &&
|
||||
!game.pendingNullentropy && player.moved < adapter.allowance(game, player)) {
|
||||
const origins = [...new Set([...Object.keys(player.forces).filter(key => player.forces[key] > 0)
|
||||
.map(key => splitLocation(key).territory), ...(marker ? [marker.location.territory] : [])])];
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
export type PrintedCardEffect = 'hajr' | 'ghola';
|
||||
export type PrintedCardPlayCounts = {
|
||||
turn: number;
|
||||
plays: { player: string; effect: PrintedCardEffect; count: number }[];
|
||||
};
|
||||
|
||||
export class PrintedCardPlayError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'PrintedCardPlayError';
|
||||
}
|
||||
}
|
||||
|
||||
function check(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new PrintedCardPlayError(message);
|
||||
}
|
||||
function object(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
const keys = (value: Record<string, unknown>, expected: string): boolean =>
|
||||
Object.keys(value).sort().join(',') === expected;
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||
const positive = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||||
const effect = (value: unknown): value is PrintedCardEffect => value === 'hajr' || value === 'ghola';
|
||||
|
||||
function validateCounts(counts: unknown): asserts counts is PrintedCardPlayCounts {
|
||||
check(object(counts) && keys(counts, 'plays,turn') && positive(counts.turn) && Array.isArray(counts.plays),
|
||||
'Retain the original turn and accepted printed-card play counts.');
|
||||
const seen = new Set<string>();
|
||||
for (const play of counts.plays) {
|
||||
check(object(play) && keys(play, 'count,effect,player') && identifier(play.player) &&
|
||||
effect(play.effect) && positive(play.count), 'Retain each player’s safe positive printed-role play count.');
|
||||
const key = JSON.stringify([play.player, play.effect]);
|
||||
check(!seen.has(key), 'Retain one printed-card count per player and effect.');
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRequest(player: unknown, turn: unknown, cardEffect: unknown): void {
|
||||
check(identifier(player) && positive(turn) && effect(cardEffect),
|
||||
'Choose the printed Hajr or Ghola play, current turn and exact player.');
|
||||
}
|
||||
|
||||
/** Private native witness baseline only. Per-answer promises track their own
|
||||
* future interval, rather than treating these earlier accepted plays as new. */
|
||||
export function printedCardPlayCount(counts: PrintedCardPlayCounts | undefined, player: string,
|
||||
turn: number, cardEffect: PrintedCardEffect): number {
|
||||
validateRequest(player, turn, cardEffect);
|
||||
if (counts === undefined) return 0;
|
||||
validateCounts(counts);
|
||||
return counts.turn === turn ? counts.plays.find(play => play.player === player && play.effect === cardEffect)?.count ?? 0 : 0;
|
||||
}
|
||||
|
||||
/** Call once for each actually accepted printed-role play, never for an effect,
|
||||
* sale, discard, recovery or role conversion. Genuine reacquisition may replay. */
|
||||
export function recordPrintedCardPlay(counts: PrintedCardPlayCounts | undefined, player: string,
|
||||
turn: number, cardEffect: PrintedCardEffect): PrintedCardPlayCounts {
|
||||
validateRequest(player, turn, cardEffect);
|
||||
if (counts !== undefined) {
|
||||
validateCounts(counts);
|
||||
check(turn >= counts.turn, 'An accepted printed-card play cannot be recorded in a past turn.');
|
||||
}
|
||||
const plays = counts?.turn === turn ? counts.plays.map(play => ({ ...play })) : [];
|
||||
const prior = plays.find(play => play.player === player && play.effect === cardEffect);
|
||||
if (prior) {
|
||||
check(Number.isSafeInteger(prior.count + 1), 'The printed-card play count exceeds the safe integer range.');
|
||||
prior.count++;
|
||||
} else plays.push({ player, effect: cardEffect, count: 1 });
|
||||
return { turn, plays };
|
||||
}
|
||||
+9
-7
@@ -3210,11 +3210,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-turn-actions',
|
||||
title: 'Truthtrance: combined alliances, bids, Revival, payments and transport',
|
||||
title: 'Truthtrance: combined card plays, battle and turn actions',
|
||||
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
|
||||
summary: 'Combine 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 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 the current battle with this turn’s alliances, bids, Revival, payments, shipment and ground movements. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of 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 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.',
|
||||
'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,18 +3224,20 @@ 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 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.',
|
||||
'Yes requires the whole expression and fulfills on qualifying actual events. OR preserves legal alternatives; No negates the whole expression. Current facts freeze at answer. A private native quotation shares funding with earlier commitments without storing or publishing a selected branch.',
|
||||
'Transport closes at your actual Movement end. A payment alternative may remain after that close, through the full current turn including Mentat. Complete a required payment before finishing the turn. No payment caps remain until their native opportunity closes.',
|
||||
'Transport and Hajr close at your actual Movement end. Ghola or a payment alternative may remain after that close, through the full current turn including Mentat. A known Inflation Double opening closes payments earlier without closing Ghola. Complete a required payment before its actual opportunity closes; No payment caps remain binding until then.',
|
||||
'Review the persistent own history and current source-bound native guide, or choose another legal manual route. Every definite answer uniformly reopens applicable concurrent preparation or final payment readiness. Four minimal AI profiles participate; complete strategy and final rules/deployed acceptance remain unfinished.',
|
||||
],
|
||||
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 native battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals and deadlines.', evidence: ['game/turn-action-promises.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
|
||||
{ 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: '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; 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'] },
|
||||
{ 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: '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; broader composition and final acceptance remain.', evidence: ['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'] },
|
||||
{ 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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -729,6 +729,7 @@ 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 === '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)}`;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { parseRevivalActionClaim, type RevivalActionClaim } from './mixed-reviva
|
||||
import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './revival-expression-promises';
|
||||
import { parseBattleActionClaim, type MixedBattleActionClaim } from './mixed-battle-question';
|
||||
import { PlanClaimError } from './battle-promises';
|
||||
import type { PrintedCardEffect } from './printed-card-play';
|
||||
import {
|
||||
MixedActionQuestionError,
|
||||
parseMixedActionExpression,
|
||||
@@ -27,7 +28,8 @@ export const TURN_ACTION_MAX_DEPTH = 4;
|
||||
export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: string; minimum: number } |
|
||||
{ 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: 'traitorCall'; battle: string; territory: string; call: boolean } |
|
||||
{ kind: 'cardPlay'; effect: PrintedCardEffect };
|
||||
export type TurnActionClaim = { turn: number; mixed: MixedActionExpression<TurnActionLeaf> };
|
||||
export type TurnActionPayment = { recipient: string; amount: number };
|
||||
export type TurnActionBranchLeaf = TravelBranchLeaf |
|
||||
@@ -38,7 +40,8 @@ export type TurnActionBranchLeaf = TravelBranchLeaf |
|
||||
{ kind: 'alliance'; partner: string; source: string; answer: boolean } |
|
||||
{ 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 };
|
||||
beneficiary: string; target: string; leader: string | null } |
|
||||
{ kind: 'cardPlay'; effect: PrintedCardEffect; 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;
|
||||
@@ -52,6 +55,7 @@ export type TurnActionCombat = {
|
||||
plan: { closed: boolean; values: TurnActionPlanValue[] | null } | null;
|
||||
traitor: { beneficiary: string; target: string; leader: string | null; closed: boolean; call: boolean | null } | null;
|
||||
};
|
||||
export type TurnActionCardPlays = Record<PrintedCardEffect, { played: boolean; closed: boolean } | null>;
|
||||
export type TurnActionPromise = TurnActionClaim & {
|
||||
event: string;
|
||||
player: string;
|
||||
@@ -73,6 +77,8 @@ export type TurnActionPromise = TurnActionClaim & {
|
||||
/** Final membership is recorded only by the original Nexus closing event. */
|
||||
alliance?: TurnActionAlliance;
|
||||
combat?: TurnActionCombat;
|
||||
/** Accepted printed-role plays after this answer only, never earlier counts. */
|
||||
cardPlays?: TurnActionCardPlays;
|
||||
};
|
||||
|
||||
export class TurnActionError extends Error {
|
||||
@@ -110,8 +116,13 @@ const planKey = (claim: MixedBattleActionClaim): string => JSON.stringify(parseC
|
||||
|
||||
/** Public leaves carry only their native destination/recipient and minimum. */
|
||||
export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
|
||||
check(object(value), 'Choose one future ordinary bid, Revival, final Nexus alliance, reserve shipment, ground movement or bribe payment.');
|
||||
check(object(value), 'Choose one future native action, printed-card play or ordinary bribe payment.');
|
||||
try {
|
||||
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.');
|
||||
return { kind: 'cardPlay', effect: value.effect };
|
||||
}
|
||||
if (value.kind === 'battlePlan' || value.kind === 'traitorCall') {
|
||||
check(keys(value, value.kind === 'battlePlan' ? 'battle,claim,kind,territory' : 'battle,call,kind,territory') &&
|
||||
identifier(value.battle) && identifier(value.territory), 'Retain the exact original current battle and native location.');
|
||||
@@ -158,6 +169,7 @@ function storedFact(value: unknown, depth = 0): TruthFact {
|
||||
}
|
||||
|
||||
function actionKey(action: TurnActionLeaf): string {
|
||||
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]);
|
||||
if (action.kind === 'alliance') return JSON.stringify([action.kind, action.partner]);
|
||||
@@ -205,6 +217,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
if ('preparation' in promise) fields.push('preparation');
|
||||
if ('alliance' in promise) fields.push('alliance');
|
||||
if ('combat' in promise) fields.push('combat');
|
||||
if ('cardPlays' in promise) fields.push('cardPlays');
|
||||
check(keys(promise, fields.sort().join(',')) &&
|
||||
positive(promise.turn) && identifier(promise.event) && identifier(promise.player) && identifier(promise.asker),
|
||||
'Retain the complete original turn-action commitment and its metadata.');
|
||||
@@ -226,6 +239,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
const combatSources = new Set<string>();
|
||||
const planClaims = new Set<string>();
|
||||
let hasTraitor = false;
|
||||
const cardEffects = new Set<PrintedCardEffect>();
|
||||
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
|
||||
if (node.kind === 'action') {
|
||||
originals.add(actionKey(node.action));
|
||||
@@ -238,6 +252,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
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);
|
||||
} else if (node.kind !== 'fact') node.terms.forEach(visit);
|
||||
};
|
||||
visit(mixed);
|
||||
@@ -246,6 +261,15 @@ 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 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.');
|
||||
for (const effect of ['hajr', 'ghola'] as const) {
|
||||
const stage = promise.cardPlays[effect];
|
||||
check(stage === null || object(stage) && keys(stage, 'closed,played') && typeof stage.played === 'boolean' &&
|
||||
typeof stage.closed === 'boolean', 'Retain the actual printed-role play and native stage closure.');
|
||||
}
|
||||
}
|
||||
if ('combat' in promise) {
|
||||
const combat = promise.combat;
|
||||
check(object(combat) && keys(combat, 'event,plan,territory,traitor') && identifier(combat.event) &&
|
||||
@@ -330,6 +354,7 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
}
|
||||
|
||||
function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
||||
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;
|
||||
if (action.kind === 'traitorCall') return !!promise.combat?.traitor?.closed &&
|
||||
@@ -353,6 +378,7 @@ function evaluate(node: CompiledMixedAction<TurnActionLeaf>, promise: TurnAction
|
||||
}
|
||||
|
||||
function actionClosed(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
|
||||
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;
|
||||
if (action.kind === 'alliance') return promise.alliance!.closed;
|
||||
@@ -392,6 +418,7 @@ 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 === '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;
|
||||
if (left.kind === 'battlePlan') return right.kind === 'battlePlan' && left.battle === right.battle &&
|
||||
@@ -416,7 +443,7 @@ function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf
|
||||
if (!prior) merged.push({ ...leaf });
|
||||
continue;
|
||||
}
|
||||
if (leaf.kind === 'leader' || leaf.kind === 'alliance' || leaf.kind === 'battlePlan') {
|
||||
if (leaf.kind === 'leader' || leaf.kind === 'alliance' || leaf.kind === 'battlePlan' || leaf.kind === 'cardPlay') {
|
||||
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' &&
|
||||
@@ -485,6 +512,22 @@ function settle<T extends TurnActionPromise>(promise: T, update: T): T {
|
||||
? { ...update, fulfilled: true } : update;
|
||||
}
|
||||
|
||||
/** 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,
|
||||
turn: number, effect: PrintedCardEffect): T[] {
|
||||
check(effect === 'hajr' || effect === 'ghola', 'Record an accepted printed Hajr or Tleilaxu Ghola play.');
|
||||
const updates = new Map<T, T>();
|
||||
for (const promise of liveTurnActionPromises(promises, player, turn)) {
|
||||
readPromise(promise);
|
||||
const stage = promise.cardPlays?.[effect];
|
||||
if (!stage || stage.closed || stage.played) continue;
|
||||
updates.set(promise, settle(promise, { ...promise, cardPlays: { ...promise.cardPlays!,
|
||||
[effect]: { ...stage, played: true } } }));
|
||||
}
|
||||
return promises.map(promise => updates.get(promise) ?? promise);
|
||||
}
|
||||
|
||||
/** A union receipt may serve several answers; each stored row retains exactly
|
||||
* its original distinct atomic predicates and no unrelated native plan values. */
|
||||
export function recordTurnActionPlan<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
@@ -704,7 +747,20 @@ export function recordTurnActionPayment<T extends TurnActionPromise>(promises: r
|
||||
return promises.map(promise => updates.get(promise) ?? promise);
|
||||
}
|
||||
|
||||
/** Close transport only; a later ordinary payment can still complete the tree.
|
||||
/** A known payment ban can end ordinary payments before the full turn ends.
|
||||
* Other native opportunities, including later Ghola, retain their own stages. */
|
||||
export function closeTurnActionPayments<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
turn: number): T[] {
|
||||
const live = new Set(liveTurnActionPromises(promises, player, turn));
|
||||
return promises.map(promise => {
|
||||
if (!live.has(promise)) return promise;
|
||||
readPromise(promise);
|
||||
if (promise.paymentClosed) return promise;
|
||||
return settle(promise, { ...promise, paymentClosed: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** Close transport and its native Hajr stage; later Ghola or payment can still complete the tree.
|
||||
* The native joint solver, rather than this ledger, rejects own illegal closure. */
|
||||
export function closeTurnActionTransport<T extends TurnActionPromise>(promises: readonly T[], player: string,
|
||||
turn: number): T[] {
|
||||
@@ -713,7 +769,9 @@ export function closeTurnActionTransport<T extends TurnActionPromise>(promises:
|
||||
if (!live.has(promise)) return promise;
|
||||
readPromise(promise);
|
||||
return settle(promise, { ...promise, transportClosed: true,
|
||||
shipment: { ...promise.shipment, pending: false } });
|
||||
shipment: { ...promise.shipment, pending: false }, ...(promise.cardPlays ? { cardPlays: {
|
||||
...promise.cardPlays, hajr: promise.cardPlays.hajr ? { ...promise.cardPlays.hajr, closed: true } : null,
|
||||
} } : {}) });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -729,6 +787,9 @@ export function finishTurnActionTurn<T extends TurnActionPromise>(promises: read
|
||||
shipment: { ...promise.shipment, pending: false }, ...(promise.preparation ? { preparation: {
|
||||
bid: promise.preparation.bid ? { ...promise.preparation.bid, closed: true } : null,
|
||||
revival: promise.preparation.revival ? { ...promise.preparation.revival, closed: true } : null,
|
||||
} } : {}), ...(promise.cardPlays ? { cardPlays: {
|
||||
hajr: promise.cardPlays.hajr ? { ...promise.cardPlays.hajr, closed: true } : null,
|
||||
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);
|
||||
@@ -749,7 +810,8 @@ export function validateTurnActionPromises(promises: readonly TurnActionPromise[
|
||||
(!promise.preparation?.bid || promise.preparation.bid.closed) &&
|
||||
(!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?.traitor || promise.combat.traitor.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 :
|
||||
node.action.kind !== 'payment' || players.includes(node.action.recipient) && node.action.recipient !== promise.player
|
||||
|
||||
Reference in new issue
Block a user