Allow bounded Kull cancellation continuations
Verify and publish checkpoint container / container (push) Successful in 10m22s

This commit is contained in:
Quintine committed 2026-10-29 01:46:28 +11:00
1 parent 07f1f1f2ae
commit 6e1b95d2fc
9 files changed
+569 -7

No files matched your search

+78 -4
View File
@@ -19253,18 +19253,50 @@ function kullCostPromisesAllow(g: Game, p: Player, card: Card, fuelSource: 'prin
projected.decision = null;
projected.pendingKarama = null;
let wholeSource = projected;
if (wholePromises.length && g.pendingKull?.intent.kind === 'ordinary' &&
g.pendingKull.intent.use.kind === 'cancel') {
// Kull success leaves the original response open; prevention resumes the
// original cancellation from that same response. Keep it for either quote.
wholeSource = structuredClone(projected);
wholeSource.response = structuredClone(source.response);
wholeSource.decision = structuredClone(source.decision);
wholeSource.phaseOpening = structuredClone(source.phaseOpening);
}
if (!success && wholePromises.length && g.pendingKull) {
if (g.pendingKull.intent.kind !== 'ordinary') return false;
const original = getPlayer(projected, g.pendingKull.owner);
const original = getPlayer(wholeSource, g.pendingKull.owner);
const attempted = original.hand.find(row => row.id === g.pendingKull!.card);
if (!attempted) return false;
const resumed = projectKullOrdinaryOutcome(projected, original, attempted, g.pendingKull.intent.use);
const resumed = projectKullOrdinaryOutcome(wholeSource, original, attempted, g.pendingKull.intent.use);
if (!resumed) return false;
wholeSource = resumed;
}
let wholeCompletion = !wholePromises.length || !!turnActionCompletion(wholeSource, p.id);
if (!wholeCompletion && success && wholeSource.response &&
(wholeSource.response.kind === 'voice' || wholeSource.response.kind === 'prescience') &&
!wholeSource.decision && !wholeSource.phaseOpening && !battlePromises.length && !shipmentPromises.length &&
!(['travelPromises','movementPromises','movementExpressionPromises','bidPromises','phaseBidPromises',
'revivalPromises','leaderRevivalPromises','revivalExpressionPromises','bribePromises',
'bribeExpressionPromises'] as const).some(key => wholeSource[key]?.some(row =>
row.player === p.id && !row.fulfilled && !row.released)) &&
wholePromises.every(promise => {
let found = false;
for (const branch of turnActionRule(() => turnActionBranches(promise))) {
found = true;
if (branch.some(leaf => leaf.kind !== 'payment')) return false;
}
return found;
})) {
// The reopened Voice/Prescience response cannot change the payer's
// wallet or recipient. Quote a payment-only answer after that response,
// without choosing either player's response or a later battle plan.
const afterResponse = structuredClone(wholeSource);
afterResponse.response = null;
wholeCompletion = !!turnActionCompletion(afterResponse, p.id);
}
if ((battlePromises.length && !findReachableBattlePlan(projected, owner, { promises: battlePromises })) ||
(shipmentPromises.length && !findShipmentCompletion(projected, owner, shipmentPromises)) ||
(wholePromises.length && !turnActionCompletion(wholeSource, p.id)))
!wholeCompletion)
return false;
}
return true;
@@ -19497,10 +19529,52 @@ function resumeKullAttempt(g: Game, prevented: boolean): void {
spendKarama(g, p, p.hand.find(card => card.id === frame.card)!, frame.intent.use, true);
}
}
/** Quote only known shipment-price and ordinary-auction effects without
/** Quote known shipment-price, ordinary-auction and bounded cancellation effects without
* executing a foreign response, drawing a card or exposing a hidden lot.
* Other parent effects need their own deterministic whole-answer quotation. */
function projectKullOrdinaryOutcome(g: Game, player: Player, card: Card, use: KaramaUse): Game | null {
if (use.kind === 'cancel') {
const response = use.response;
// A separately owned arrival/phase decision remains at the table after
// this response; do not erase it to manufacture a completion witness.
if (!g.response || g.decision || g.phaseOpening ||
JSON.stringify(g.response) !== JSON.stringify(response)) return null;
const battleWindow = karamaBattleCancelOpportunity(g, player.id);
const trial = structuredClone(g), owner = getPlayer(trial, player.id);
if (!owner.hand.some(held => held.id === card.id)) return null;
owner.hand = owner.hand.filter(held => held.id !== card.id);
if (battleWindow && card.kind === 'special' && card.effect === 'karama')
recordNativeTurnActionWindowCard(trial, player.id, 'karamaBattleCancel', battleWindow.source);
if (response.kind === 'voice' || response.kind === 'prescience' || response.kind === 'eliteStrength') {
const quote = currentCombatResponseQuote(trial, {kind:'response',
response:{kind:response.kind, owner:response.owner}, canceled:true});
// A later power/choice belongs to its original owner and cannot be
// assumed as part of this deterministic cancellation quote.
if (quote.response || quote.decision) return null;
commitCombatResponseQuote(trial, quote);
} else {
// A plain queued shipment entry has no remaining effect without Ecaz
// or Moritani. Any other queued entry may own a separate decision.
const arrivals = g.pendingArrivalReaction ?? [];
const inertArrivals = !arrivals.length || (!byFaction(g, 'ecaz') && !byFaction(g, 'moritani') &&
arrivals.every(entry => entry.cause === 'shipment' && entry.resume === 'none' &&
entry.turn === g.turn && entry.phase === g.phase &&
Object.keys(entry).every(key => ['entrant','territory','sector','amount','elite','cause','resume','turn','phase'].includes(key))));
if (!semutaEmperorShareKaramaResponse(g, response) &&
!(response.kind === 'guildIncome' && response.source === undefined &&
semutaIncomeKaramaResponse(g, response) && !g.pendingArrivalOverlap &&
inertArrivals && !g.pendingSpiritualAdvisor && !g.pendingAutomaticAdvisorFlips?.length)) return null;
}
if (battleWindow) {
const original = liveTurnActionPromises(trial.turnActionPromises ?? [], player.id, trial.turn);
trial.turnActionPromises = turnActionRule(() => closeTurnActionWindowCardPlay(
trial.turnActionPromises ?? [], player.id, trial.turn, 'karamaBattleCancel', battleWindow.source));
if (original.some(row => trial.turnActionPromises?.find(next => next.event === row.event)?.released)) return null;
}
trial.truthtrance = null; trial.response = null; trial.decision = null;
trial.pendingKarama = null; trial.phaseOpening = null; trial.pendingKull = null;
return trial;
}
if (use.kind !== 'shipment' && use.kind !== 'purchase' && use.kind !== 'auctionPayment') return null;
const window = windowCardOpportunity(g, use.kind === 'shipment' ? 'karamaShipment' : 'karamaAuction');
const lot = use.kind === 'shipment' ? null : bidOpportunity(g);