Support whole-turn answers with Basic Kull
Verify and publish checkpoint container / container (push) Successful in 10m21s

This commit is contained in:
Quintine committed 2026-10-29 01:37:33 +11:00
1 parent d6ddb7cb5c
commit 07f1f1f2ae
16 files changed
+870 -14

No files matched your search

+114 -3
View File
@@ -1618,6 +1618,8 @@ export type Game = {
revivalReceipt?: true;
/** New Kull/Semuta attempts retain the original alliance answer ledger. */
allianceReceipt?: true;
/** New Basic Kull attempts retain whole-answer order and progress. */
turnActionReceipt?: true;
stage: 'offer' | 'counter';
selection?: { source: 'printed' | 'nexus'; card: string; nexusEvent?: string };
intent: { kind: 'ordinary'; use: KaramaUse } | { kind: 'special'; use: SpecialKaramaIntent };
@@ -10020,6 +10022,10 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
requireRule(next.declaration && next.use, 'The Kull counter lost its saved continuation.');
g.pendingKull = next.frame;
g.pendingChoamWorthless = next.declaration;
// The verified Semuta exchange may legitimately advance a whole answer.
// Rebind only that new-format receipt before the original counter resumes.
if (result && g.pendingKull.turnActionReceipt)
g.pendingKull.signature = kullSignature(g, g.pendingKull);
completeKarama(g, getPlayer(g, next.player), next.use, pending.batch.entries[0].card);
}
return;
@@ -18288,6 +18294,8 @@ function responseCancelCards(
return p.hand
.filter((card) => {
if (karamaSpendingBlock(g, p, card, karamaUsePurpose(g, {kind:'cancel', response}))) return false;
if (g.kullPreview && byFaction(g, 'choam')?.id !== p.id &&
!kullOriginalWholeCostAllowed(g, p, card, {kind:'cancel', response})) return false;
const window = card.kind === 'special' && card.effect === 'karama' && g.response === response
? karamaBattleCancelOpportunity(g, p.id) : null;
if (!window || !g.turnActionPromises) return true;
@@ -19088,7 +19096,8 @@ function assertKaramaPromiseFeasibility(
use.response.kind === 'choamWorthless' && use.response.intent === 'Kull Wahad') {
const moves = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn);
const expressions = liveMovementExpressionPromises(g.movementExpressionPromises ?? [], p.id, g.turn);
if (moves.length || expressions.length) {
const whole = liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn);
if ((moves.length || expressions.length) && !whole.length) {
if (!costCard) return false;
const projected = structuredClone(g);
const owner = getPlayer(projected, p.id);
@@ -19103,6 +19112,18 @@ function assertKaramaPromiseFeasibility(
throw error;
}
}
if (whole.length) {
if (!costCard || g.pendingKull.intent.kind !== 'ordinary') return false;
const projected = structuredClone(kullSource(g, g.pendingKull));
getPlayer(projected, p.id).hand = getPlayer(projected, p.id).hand.filter(card => card.id !== costCard.id);
const original = getPlayer(projected, g.pendingKull.owner);
const card = original.hand.find(row => row.id === g.pendingKull!.card);
if (!card) return false;
const tail = projectKullOrdinaryOutcome(projected, original, card, g.pendingKull.intent.use);
if (!tail || ((moves.length || expressions.length) &&
!groundMovementCompletion(tail, p.id, moves, expressions)) ||
!turnActionCompletion(tail, p.id)) return false;
}
}
const battlePromises =
g.battle && !g.battle.revealed && !g.battle.plans[p.id]
@@ -19211,7 +19232,8 @@ function kullCostPromisesAllow(g: Game, p: Player, card: Card, fuelSource: 'prin
? (source.battle.truthPromises ?? []).filter(promise => promise.player === p.id && !promise.released)
: [];
const shipmentPromises = liveShipmentPromises(source.shipmentPromises ?? [], p.id, source.turn);
if (!battlePromises.length && !shipmentPromises.length) return true;
const wholePromises = liveTurnActionPromises(source.turnActionPromises ?? [], p.id, source.turn);
if (!battlePromises.length && !shipmentPromises.length && !wholePromises.length) return true;
for (const success of [true, false]) {
const projected = structuredClone(source);
const owner = getPlayer(projected, p.id);
@@ -19230,8 +19252,19 @@ function kullCostPromisesAllow(g: Game, p: Player, card: Card, fuelSource: 'prin
projected.response = null;
projected.decision = null;
projected.pendingKarama = null;
let wholeSource = projected;
if (!success && wholePromises.length && g.pendingKull) {
if (g.pendingKull.intent.kind !== 'ordinary') return false;
const original = getPlayer(projected, 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);
if (!resumed) return false;
wholeSource = resumed;
}
if ((battlePromises.length && !findReachableBattlePlan(projected, owner, { promises: battlePromises })) ||
(shipmentPromises.length && !findShipmentCompletion(projected, owner, shipmentPromises)))
(shipmentPromises.length && !findShipmentCompletion(projected, owner, shipmentPromises)) ||
(wholePromises.length && !turnActionCompletion(wholeSource, p.id)))
return false;
}
return true;
@@ -19287,6 +19320,8 @@ function kullSignature(g: Game, frame: KullFrame): string {
...(frame.allianceReceipt ? {allianceReceipt:frame.allianceReceipt,alliancePromises:source.alliancePromises,
allianceExpressionPromises:source.allianceExpressionPromises,mixedAllianceQuestions:source.mixedAllianceQuestions,
alliancePromiseSequence:source.alliancePromiseSequence} : {}),
...(frame.turnActionReceipt ? {turnActionReceipt:frame.turnActionReceipt,
turnActionPromises:source.turnActionPromises,travelPromiseOrder:source.travelPromiseOrder} : {}),
});
}
/** Only the verified pre-source printed profile may bind its old held cost. */
@@ -19342,6 +19377,9 @@ function kullIntegrity(g: Game): void {
alliancePromiseModeSupported(g) && !!(g.alliancePromises?.length || g.allianceExpressionPromises?.length ||
g.mixedAllianceQuestions?.length),
'The Kull attempt has lost its original Basic alliance-answer ledger.');
requireRule(frame.turnActionReceipt === undefined || frame.turnActionReceipt === true &&
turnActionPromiseModeSupported(g) && !!g.turnActionPromises?.length,
'The Kull attempt has lost its original whole-answer ledger.');
validateKullAttemptStamp(g, frame, physicalTreacheryCards(g));
requireRule(Number.isSafeInteger(g.kullSequence) && g.kullSequence! > 0 &&
frame.event === JSON.stringify(['kull', g.turn, g.phase, g.kullSequence, frame.player, frame.owner]),
@@ -19432,6 +19470,8 @@ function beginKullAttempt(g: Game, p: Player, card: Card, intent: KullFrame['int
...(g.semutaPreview && alliancePromiseModeSupported(g) && (g.alliancePromises?.length ||
g.allianceExpressionPromises?.length || g.mixedAllianceQuestions?.length)
? { allianceReceipt: true as const } : {}),
...(turnActionPromiseModeSupported(g) && g.turnActionPromises?.length
? { turnActionReceipt: true as const } : {}),
worthless: structuredClone(g.pendingChoamWorthless ?? null), signature: '',
};
// Payment dispatch removed this control before entering its native branch.
@@ -19457,6 +19497,59 @@ 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
* 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 !== '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);
if (use.kind !== 'shipment' && (!g.auction || !lot)) return null;
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 (window && card.kind === 'special' && card.effect === 'karama') recordNativeTurnActionWindowCard(
trial, player.id, use.kind === 'shipment' ? 'karamaShipment' : 'karamaAuction', window.source);
if (use.kind === 'shipment') trial.karamaShipping = {player:use.recipient, owner:player.id, card:card.id};
else {
const auction = g.auction!, actual = auction.cards[auction.index];
if (!actual) return null;
if (livePhaseBidPromises(g.phaseBidPromises ?? [], player.id, g.turn).some(row => row.answer)) return null;
const known = player.faction === 'atreides' && !!auction.peekKnown;
const opaqueId = JSON.stringify(['kull-opaque-lot', g.turn, auction.index, player.id]);
const acquired: Card = known ? structuredClone(actual) : {id:opaqueId, name:'Unknown card', kind:'special'};
owner.hand.push(acquired);
if (!known) trial.auction!.cards[auction.index] = structuredClone(acquired);
if (!known) (trial as TurnActionCardProbe)._turnActionUnknownCards = [
...((trial as TurnActionCardProbe)._turnActionUnknownCards ?? []), opaqueId,
];
const original = liveTurnActionPromises(trial.turnActionPromises ?? [], player.id, trial.turn);
trial.turnActionPromises = turnActionRule(() => closeTurnActionBidding(
trial.turnActionPromises ?? [], trial.turn, lot!.source, player.id));
if (window) trial.turnActionPromises = turnActionRule(() => closeTurnActionWindowCardPlay(
trial.turnActionPromises!, player.id, trial.turn, 'karamaAuction', window.source));
if (original.some(row => trial.turnActionPromises?.find(next => next.event === row.event)?.released)) return null;
// Keep unawarded lots in their original custody while preventing this
// closed lot from becoming another bid or exposing its hidden face.
trial.currentAuctionSale = {winner:player.id, amount:auction.bid, free:true, origin:'normal', seller:null};
reconcileBidPromises(trial, {actor:player.id, action:{type:'card'}});
}
trial.truthtrance = null; trial.response = null; trial.decision = null;
trial.pendingKarama = null; trial.phaseOpening = null; trial.pendingKull = null;
return trial;
}
/** Before interrupting an owned Karama, prove its known original use can
* preserve the actor's whole answer if CHOAM declines or is countered. */
function kullOriginalWholeCostAllowed(g: Game, p: Player, card: Card, use: KaramaUse): boolean {
if (!liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length) return true;
try {
const trial = projectKullOrdinaryOutcome(g, p, card, use);
return !!trial && !!turnActionCompletion(trial, p.id);
} catch (error) {
if (error instanceof RuleError) return false;
throw error;
}
}
function decideKull(g: Game, p: Player, action: Action): void {
const frame = g.pendingKull!;
requireRule(action.type === 'kullDecision' && action.event === frame.event &&
@@ -19493,6 +19586,9 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
const blocked = karamaSpendingBlock(g, p, card, karamaUsePurpose(g, use));
requireRule(!blocked, blocked ?? 'This card cannot be spent as Karama.');
validateKaramaUse(g, p, use, card);
if (!kullOffered && !g.pendingKull && g.kullPreview && byFaction(g, 'choam')?.id !== p.id)
requireRule(kullOriginalWholeCostAllowed(g, p, card, use),
'Retain a legal completion of your whole Truthtrance answer if this Karama resolves.');
const promiseProven = assertKaramaPromiseFeasibility(g, p, use, card);
requireRule(!g.pendingKull || promiseProven,
'Kull preview cannot accept this unproven Truthtrance counter continuation.');
@@ -21982,6 +22078,17 @@ function turnActionFreshDiscardTail(source: Game,
game.pendingKarama = structuredClone(continuation.resume.pendingKarama);
game.phaseOpening = structuredClone(continuation.resume.phaseOpening);
}
if (continuation.kind === 'kullCounterDiscard' && continuation.frame.intent.kind === 'ordinary') {
// Passing the counter's discard resumes the original Karama, not the
// already-countered Kull response. Quote its known effects without
// executing the parent or sampling an unopened auction card.
const source = structuredClone(game);
Object.assign(source, structuredClone(continuation.frame.resume));
const original = getPlayer(source, continuation.frame.owner);
const card = original.hand.find(held => held.id === continuation.frame.card);
const projected = card && projectKullOrdinaryOutcome(source, original, card, continuation.frame.intent.use);
if (projected) return projected;
}
return game;
}
/** Fresh use precedes every later witness. An uncommitted hidden face cannot
@@ -22391,6 +22498,9 @@ function bindTurnActionTruth(g: Game, p: Player, claim: TurnActionClaim, answer:
appendTravelOrder(g, 'turnActions', g.turnActionPromises.length - 1);
}
function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) {
const suspendedKull = g.pendingTreacheryDiscard?.continuation.kind === 'kullSuccessDiscard' ||
g.pendingTreacheryDiscard?.continuation.kind === 'kullCounterDiscard';
if (g.pendingKull || suspendedKull) return;
reconcileTurnActionCombat(g);
reconcileTurnActionWindowCards(g);
for (const row of g.turnActionPromises ?? []) {
@@ -22493,6 +22603,7 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
return action ? { event: row.event, action: { ...action,
turnActionPromiseEvent: row.event, turnActionPromisePhase: g.phase } } : null;
}
if (g.pendingKull) return null;
const ownedBox = g.decision?.kind === 'nullentropy' && g.pendingNullentropy?.player === player.id;
const ownedInvoice = g.decision?.kind === 'auctionPayment' && g.decision.player === player.id;
const saphoExtortion = g.decision?.kind === 'moritaniExtortion' &&