Support Truthtrance battle cancellation questions

This commit is contained in:
Quintine committed 2026-10-27 20:57:00 +11:00
1 parent 813a2bacbe
commit 56b3aeab68
15 files changed
+674 -28

No files matched your search

+26 -10
View File
@@ -7,7 +7,7 @@ import type { MixedActionExpression } from '@/game/mixed-action-question';
import { parseBattleActionClaim } from '@/game/mixed-battle-question';
import { battlePowerOf, nativeBattlePowerOpportunity, type BattlePowerClaim } from '@/game/battle-power-question';
import { battleCardLabel, VOICE_KINDS } from '@/game/battle-cards';
import { WINDOW_CARD_EFFECTS, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
import { karamaBattleCancelOpportunity, WINDOW_CARD_EFFECTS, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES, type TurnActionLeaf } from '@/game/turn-action-promises';
import { BattleClaimFields } from './battle-promises';
import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields';
@@ -51,7 +51,9 @@ const newPowerClaim = (game: GameView, target: string): BattlePowerClaim =>
const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester',
karamaAuction: 'Spend a printed Karama for this current ordinary lot',
karamaShipment: 'Spend a printed Karama for the current shipment-price opportunity',
karamaBattleCancel: 'Spend a printed Karama to cancel this Voice or Prescience',
thumper: 'Play Thumper before this turn’s first spice reveal', amal: 'Play Amal in this original phase opening' };
const battleCancelOpportunity = karamaBattleCancelOpportunity;
const config: MixedActionConfig<TurnActionLeaf> = {
prefix: 'turn-action',
legend: 'Current battle and this-turn action conditions, with optional current facts',
@@ -102,6 +104,10 @@ const config: MixedActionConfig<TurnActionLeaf> = {
unused native shipment price. The giver may be another player. The price grant counts separately from an actual force shipment;
it does not require the recipient to ship forces. Other roles, Worthless conversion and recovery alone do not count.
This condition closes when that original shipment-price opportunity ends.
A battle-cancellation Karama condition concerns the actual current Voice or Prescience response in its named battle.
Only spending a physical printed Karama to cancel that declaration after this answer counts.
Passing, another Karama role, Worthless conversion and a different battle or response do not count.
The condition closes when the original response ends; another player&apos;s cancellation can end the opportunity.
Thumper conditions count an actual accepted future printed play before this turn&apos;s original first spice reveal.
Earlier plays, disposal, other roles and recovery alone do not count. A later reveal is a different opportunity.
This condition closes when the actual first spice draw begins.
@@ -121,6 +127,8 @@ const config: MixedActionConfig<TurnActionLeaf> = {
newAction: () => ({ kind: 'ship', territory: 'carthag', minimum: 1 }),
parseAction: (input, game, target) => {
const action = parseTurnActionLeaf(input);
if (windowCardOpportunity(game, 'karamaBattleCancel') && (action.kind !== 'windowCardPlay' || action.effect !== 'karamaBattleCancel'))
throw new Error('During this response, choose only a printed Karama cancellation of the current Voice or Prescience; current facts may still be combined.');
if (action.kind === 'payment' && !recipients(game, target).some(recipient => recipient.id === action.recipient))
throw new Error('Choose a public non-allied recipient for the future ordinary payment.');
if (action.kind === 'bid' && currentLot(game)?.lot !== action.lot)
@@ -141,16 +149,19 @@ const config: MixedActionConfig<TurnActionLeaf> = {
throw new Error('Choose this player’s original native Voice or Prescience opportunity in the current battle.');
}
if (action.kind === 'windowCardPlay') {
const opportunity = windowCardOpportunity(game, action.effect);
const opportunity = action.effect === 'karamaBattleCancel' ? battleCancelOpportunity(game, target)
: windowCardOpportunity(game, action.effect);
if (!opportunity || opportunity.turn !== game.turn || action.source !== opportunity.source)
throw new Error('Choose the original current native window for this printed role, including the first spice reveal or active shipment price.');
throw new Error('Choose an original current native window for this printed role, including an eligible battle response.');
}
return action;
},
renderAction: ({ value, id, game, target, disabled, onChange }) => {
const choices = recipients(game, target), leaderChoices = leaders(game, target), lot = currentLot(game), partnerChoices = partners(game, target);
const currentPower = value.kind === 'battlePower' ? powerOpportunity(game, target, value.claim) : null;
const currentWindow = value.kind === 'windowCardPlay' ? windowCardOpportunity(game, value.effect) : null;
const currentWindow = value.kind === 'windowCardPlay'
? value.effect === 'karamaBattleCancel' ? battleCancelOpportunity(game, target) : windowCardOpportunity(game, value.effect)
: null;
return <>
<label htmlFor={`${id}-action`}>Future action</label>
<select id={`${id}-action`} value={value.kind} disabled={disabled} onChange={event => {
@@ -159,8 +170,9 @@ const config: MixedActionConfig<TurnActionLeaf> = {
const minimum = 'minimum' in value ? value.minimum : 1;
if (kind === 'cardPlay') onChange({ kind, effect: 'hajr' });
if (kind === 'windowCardPlay') {
const effect = windowCardOpportunity(game, 'amal') ? 'amal'
: WINDOW_CARD_EFFECTS.find(effect => windowCardOpportunity(game, effect)) ?? 'weather';
const effect = battleCancelOpportunity(game, target) ? 'karamaBattleCancel'
: windowCardOpportunity(game, 'amal') ? 'amal'
: WINDOW_CARD_EFFECTS.find(effect => effect !== 'karamaBattleCancel' && windowCardOpportunity(game, effect)) ?? 'weather';
onChange({ kind, effect, source: windowCardOpportunity(game, effect)?.source ?? '' });
}
if (kind === 'battlePower') {
@@ -212,16 +224,19 @@ const config: MixedActionConfig<TurnActionLeaf> = {
<select id={`${id}-window-effect`} value={WINDOW_CARD_EFFECTS.includes(value.effect) ? value.effect : ''}
disabled={disabled} aria-invalid={!WINDOW_CARD_EFFECTS.includes(value.effect)} onChange={event => {
const effect = event.currentTarget.value;
if (effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'thumper' || effect === 'amal')
onChange({ kind: 'windowCardPlay', effect, source: windowCardOpportunity(game, effect)?.source ?? '' });
if (effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' || effect === 'thumper' || effect === 'amal')
onChange({ kind: 'windowCardPlay', effect, source: (effect === 'karamaBattleCancel'
? battleCancelOpportunity(game, target) : windowCardOpportunity(game, effect))?.source ?? '' });
}}>
<option value="">Choose a printed role</option>
{WINDOW_CARD_EFFECTS.map(effect => <option key={effect} value={effect}>{windowRoleNames[effect]}</option>)}
{WINDOW_CARD_EFFECTS.filter(effect => effect !== 'karamaBattleCancel' || !!battleCancelOpportunity(game, target) || value.effect === effect)
.map(effect => <option key={effect} value={effect}>{windowRoleNames[effect]}</option>)}
</select>
<label htmlFor={`${id}-window-source`}>Original native window</label>
<select id={`${id}-window-source`} value={currentWindow?.source === value.source ? value.source : ''}
disabled={disabled} aria-invalid={!currentWindow || currentWindow.source !== value.source} onChange={event => {
const opportunity = windowCardOpportunity(game, value.effect);
const opportunity = value.effect === 'karamaBattleCancel' ? battleCancelOpportunity(game, target)
: windowCardOpportunity(game, value.effect);
if (event.currentTarget.value === '') onChange({ ...value, source: '' });
else if (opportunity?.source === event.currentTarget.value) onChange({ ...value, source: opportunity.source });
}}>
@@ -231,6 +246,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
: value.effect === 'karamaAuction' ? `Turn ${currentWindow.turn} · Ordinary lot ${game.bidPromiseLot?.lot}`
: value.effect === 'thumper' ? `Turn ${currentWindow.turn} · Before the original first spice reveal`
: value.effect === 'amal' ? `Turn ${currentWindow.turn} · Original ${PHASES[game.phase]} opening`
: value.effect === 'karamaBattleCancel' ? `Turn ${currentWindow.turn} · ${game.response?.kind === 'voice' ? 'Voice' : 'Prescience'} declared by ${game.players.find(player => player.id === game.response?.owner)?.name ?? game.response?.owner} · ${game.combatLocations.find(location => location.id === game.battle?.territory)?.name ?? game.battle?.territory}`
: currentWindow.effect === 'karamaShipment' ? `Turn ${currentWindow.turn} · ${game.players.find(player => player.id === currentWindow.recipient)?.name ?? currentWindow.recipient}’s current native shipment price`
: `Turn ${currentWindow.turn} · Before this Storm movement`}</option>}
</select>
+24 -7
View File
@@ -10,7 +10,7 @@ import { gholaForcePreparations } from '@/game/ghola-force-preparation';
import { richeseCardDefinition } from '@/game/richese-cards';
import { nativeBattlePowerOpportunity, type NativeBattlePower } from '@/game/battle-power-question';
import { battleCardLabel, VOICE_KINDS } from '@/game/battle-cards';
import { WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
import { karamaBattleCancelOpportunity, WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
import type { TurnActionLeaf } from '@/game/turn-action-promises';
import type { MixedActionExpression } from '@/game/mixed-action-question';
import { truthQuestionText, type TruthAnswer } from '@/game/truthtrance';
@@ -27,10 +27,11 @@ const publicBattleContext = (game: GameView) => game.battle && ({
preparation: game.battle.preparation,
traitorVoters: game.battle.traitorVoters, traitorSubmitted: game.battle.traitorSubmitted,
});
const isWindowEffect = (effect: unknown): effect is Exclude<WindowCardEffect, 'karamaAuction' | 'karamaShipment'> =>
const isWindowEffect = (effect: unknown): effect is Exclude<WindowCardEffect, 'karamaAuction' | 'karamaShipment' | 'karamaBattleCancel'> =>
effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'thumper' || effect === 'amal';
const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester',
karamaAuction: 'printed Karama for this ordinary lot', karamaShipment: 'printed Karama for this shipment price', thumper: 'Thumper', amal: 'Amal' };
karamaAuction: 'printed Karama for this ordinary lot', karamaShipment: 'printed Karama for this shipment price',
karamaBattleCancel: 'printed Karama for this battle response', thumper: 'Thumper', amal: 'Amal' };
const namesWindow = (mixed: MixedActionExpression<TurnActionLeaf>, effect: WindowCardEffect, source?: string): boolean =>
mixed.kind === 'action' ? mixed.action.kind === 'windowCardPlay' && mixed.action.effect === effect && (source === undefined || mixed.action.source === source)
: mixed.kind !== 'fact' && mixed.terms.some(term => namesWindow(term, effect, source));
@@ -102,13 +103,18 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const shipmentRoleNamed = !!windowPromise && !!currentShipmentWindow && namesWindow(windowPromise.mixed, 'karamaShipment', currentShipmentWindow.source);
const shipmentPriceAction = action.type === 'card' && action.mode === 'shipment';
const printedShipmentAction = shipmentPriceAction && shipmentRoleNamed;
const currentBattleCancelWindow = karamaBattleCancelOpportunity(game, actor);
const battleCancelRoleNamed = !!windowPromise && !!currentBattleCancelWindow &&
namesWindow(windowPromise.mixed, 'karamaBattleCancel', currentBattleCancelWindow.source);
const printedBattleCancelAction = action.type === 'card' && action.mode === 'cancel' && battleCancelRoleNamed;
const printedInvoiceAction = action.type === 'decision' && action.karama === true && auctionRoleNamed;
const cardOwner = action.type === 'card' || printedInvoiceAction ? game.players.find(player => player.id === actor) : undefined;
const heldCard = cardOwner?.hand?.find(card => card.id === action.card);
const cardSignature = heldCard && JSON.stringify({ id: heldCard.id, kind: heldCard.kind, effect: heldCard.effect });
const windowEffect = (auctionPurchaseAction || printedInvoiceAction) && heldCard?.effect === windowCardNativeEffect('karamaAuction')
? 'karamaAuction' : printedShipmentAction && heldCard?.effect === windowCardNativeEffect('karamaShipment')
? 'karamaShipment' : isWindowEffect(heldCard?.effect) ? heldCard.effect : null;
? 'karamaShipment' : printedBattleCancelAction && heldCard?.effect === windowCardNativeEffect('karamaBattleCancel')
? 'karamaBattleCancel' : isWindowEffect(heldCard?.effect) ? heldCard.effect : null;
const cardWindow = windowEffect ? windowCardOpportunity(game, windowEffect) : null;
const earlyWindowReadyAction = action.type === 'ready' && (phase === 0 || phase === 1);
const publicWindowState = (current: GameView) => JSON.stringify({
@@ -173,6 +179,8 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const flightOwnerSignature = flightNeeded ? flightOwnerSource(game) : null;
const auctionCards = game.karamaAuctionCards, auctionCardsSignature = JSON.stringify(auctionCards);
const shipmentCards = game.karamaShipmentCards, shipmentCardsSignature = JSON.stringify(shipmentCards);
const battleCancelControls = game.responseControls, battleCancelControlsSignature = JSON.stringify(battleCancelControls);
const battleCancelResponse = game.response, battleCancelResponseSignature = JSON.stringify(battleCancelResponse);
const thumperCards = game.thumperCards, thumperCardsSignature = JSON.stringify(thumperCards);
const amalCards = game.amalCards, amalCardsSignature = JSON.stringify(amalCards);
const auctionHandSource = (current: GameView) => {
@@ -237,7 +245,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
? typeof action.leader === 'string' && (action.leader === 'kwisatz' ? ghola.kwisatz : ghola.leaders.some(leader => leader.id === action.leader))
: action.elite === undefined ? forceChoices.length === 1 : forceChoices.some(choice => choice.elite === action.elite));
const windowPacket = Object.keys(action).every(key => ['type', 'card', 'turnActionPromiseEvent', 'turnActionPromisePhase',
...(windowEffect === 'weather' ? ['amount'] : windowEffect === 'karamaAuction' ? ['mode'] : windowEffect === 'karamaShipment' ? ['mode', 'target'] : [])].includes(key)) &&
...(windowEffect === 'weather' ? ['amount'] : windowEffect === 'karamaAuction' || windowEffect === 'karamaBattleCancel' ? ['mode'] : windowEffect === 'karamaShipment' ? ['mode', 'target'] : [])].includes(key)) &&
(windowEffect !== 'weather' || typeof action.amount === 'number' && Number.isSafeInteger(action.amount) && action.amount >= 0 && action.amount <= 10);
const printedWindowSource = ownPhysicalCard && !!heldCard && heldCard.kind === 'special' && !!windowEffect && !!cardWindow &&
heldCard.effect === windowCardNativeEffect(windowEffect) && !!windowPromise &&
@@ -252,6 +260,11 @@ export function TurnActionGuideButton(props: TurnActionProps) {
phase === 5 && current.active === cardWindow.recipient && action.target === cardWindow.recipient &&
Array.isArray(current.karamaShipmentCards) && current.karamaShipmentCards === shipmentCards &&
JSON.stringify(current.karamaShipmentCards) === shipmentCardsSignature && current.karamaShipmentCards.includes(heldCard.id);
const nativeBattleCancelCard = windowEffect === 'karamaBattleCancel' && !!heldCard && phase === 6 &&
current.response === battleCancelResponse && JSON.stringify(current.response) === battleCancelResponseSignature &&
current.responseControls === battleCancelControls && JSON.stringify(current.responseControls) === battleCancelControlsSignature &&
current.responseControls?.cancelCards.includes(heldCard.id) &&
karamaBattleCancelOpportunity(current, actor, cardWindow?.source)?.source === cardWindow?.source;
const nativeThumperCard = windowEffect === 'thumper' && !!heldCard && phase === 1 && !current.spiceWindow && !current.nexus &&
Array.isArray(current.thumperCards) && current.thumperCards === thumperCards &&
JSON.stringify(current.thumperCards) === thumperCardsSignature && current.thumperCards.includes(heldCard.id);
@@ -264,6 +277,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
(windowEffect === 'karamaAuction' ? action.mode === 'purchase' && nativeAuctionCard && !!me && !current.decision &&
Number.isSafeInteger(me.handLimit) && Array.isArray(me.hand) && me.hand.length < me.handLimit
: windowEffect === 'karamaShipment' ? action.mode === 'shipment' && nativeShipmentCard
: windowEffect === 'karamaBattleCancel' ? action.mode === 'cancel' && nativeBattleCancelCard
: windowEffect === 'thumper' ? action.mode === undefined && nativeThumperCard
: windowEffect === 'amal' ? action.mode === undefined && nativeAmalCard
: action.mode === undefined && ordinaryCardAvailability(current, heldCard.id, windowEffect === 'weather' ? { amount: action.amount } : undefined)?.available === true);
@@ -310,7 +324,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
!battle.revealed && [battle.attacker, battle.defender].includes(actor) && action.event === battle.event && action.event === residual.event &&
action.target === residual.target && action.target === (battle.attacker === actor ? battle.defender : battle.attacker) &&
Object.keys(action).every(key => ['type', 'card', 'event', 'target', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key));
const genericCard = ownCard && action.type === 'card' && !auctionPurchaseAction && !printedShipmentAction && quotedBoxCard &&
const genericCard = ownCard && action.type === 'card' && !auctionPurchaseAction && !printedShipmentAction && !printedBattleCancelAction && quotedBoxCard &&
(!boxRoleNamed || heldCard?.effect !== 'nullentropyBox' || printedBox) &&
(!shipmentPriceAction || (action.target ?? actor) === current.active) &&
!!heldCard && heldCard.effect !== 'ghola' && heldCard.effect !== 'hajr' && heldCard.effect !== 'distrans' && heldCard.effect !== 'juiceOfSapho' && heldCard.effect !== 'semutaDrug' && heldCard.effect !== 'ornithopter' && heldCard.effect !== 'residualPoison' && !isWindowEffect(heldCard.effect);
@@ -411,10 +425,12 @@ export function TurnActionGuideButton(props: TurnActionProps) {
Number.isSafeInteger(phase) && phase >= 0 && phase <= 8 && current.phase === phase && current.turn === turn && current.me === actor &&
current.turnActionGuide === guide && guide.event === event && guide.action === action &&
action.turnActionPromiseEvent === event && action.turnActionPromisePhase === phase && JSON.stringify(action) === signature && opportunity && movementPacket &&
(!printedBattleCancelAction || printedWindow) &&
current.turnActionPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
!promise.fulfilled && !promise.released) &&
(!amalReadyAction || openingReady) &&
!!me && !me.autopilot && (!current.semutaReaction || nativeSemuta) && (!current.truthtrance || nativeSemuta) && (!current.response || nativeSemuta) &&
!!me && !me.autopilot && (!current.semutaReaction || nativeSemuta) && (!current.truthtrance || nativeSemuta) &&
(!current.response || nativeSemuta || windowEffect === 'karamaBattleCancel' && printedWindow) &&
(!current.decision || nativeSemuta || paidBox || invoice || saphoDecision || earlyWindowBox && ownInvoiceSource) &&
(!current.phaseOpening || nativeSemuta || allowedOpeningAction) && (!current.automaticContinuationPending || nativeSemuta) && !current.roomControl?.paused && !current.roomControl?.closed;
};
@@ -440,6 +456,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
: `Play Tleilaxu Ghola to return ${amount} physical forces while honoring your whole answer`
: action.type === 'card' && windowEffect === 'karamaAuction' ? 'Spend a printed Karama to purchase this ordinary lot while honoring your whole answer'
: action.type === 'card' && windowEffect === 'karamaShipment' ? `Spend a printed Karama to grant ${recipient} this native shipment price while honoring your whole answer`
: action.type === 'card' && windowEffect === 'karamaBattleCancel' ? `Spend a printed Karama to cancel this ${game.response?.kind === 'voice' ? 'Voice' : 'Prescience'} while honoring your whole answer`
: action.type === 'card' && heldCard?.effect === 'nullentropyBox' ? 'Pay 2 spice to activate Nullentropy Box while honoring your whole answer'
: action.type === 'card' && heldCard?.effect === 'distrans' ? `Give ${distrans?.choices.find(choice => choice.recipient === action.target)?.cards.find(card => card.id === action.give)?.name ?? 'the quoted card'} to ${recipient} with Distrans while honoring your whole answer`
: action.type === 'card' && heldCard?.effect === 'juiceOfSapho' ? `Play Juice of Sapho: ${action.scope === 'battleAggressor'
+9
View File
@@ -20,6 +20,15 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Truthtrance battle cancellation — 27 October. Prototyped / Partial.**
Supported Basic native tables can ask a seated non-owner whether they will
spend a printed Karama to cancel the current Voice or Prescience. The original
response remains live through the answer and discard; the real cost, pass,
other-player cancellation and owner-only guide retain the accepted answer.
Current facts can be combined with this exact source. Advanced, optional module
combinations and full rules/deployed acceptance remain open.
[Scope](TRUTHTRANCE_TURN_ACTIONS.md#current-voice-and-prescience-cancellation--27-october-2026).
**Preview-start guidance correction — 27 October. Integrated / Partial.**
Setup, selected expansion rule topics and Richese card inspectors now describe
supported faction starts as unfinished previews instead of claiming that all
+10
View File
@@ -1,5 +1,15 @@
# Dune implementation status
## 27 October 2026 — Truthtrance battle cancellation
Supported Basic native Voice and Prescience responses admit a source-bound
question about the respondent's physical printed Karama cancellation. The
question, answer, native card cost, independent pass/cancel and human/AI guide
are connected; current facts may accompany the exact response condition.
The online timing is provisional. Other response families, Advanced, optional
modules and final acceptance remain open.
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#current-voice-and-prescience-cancellation--27-october-2026).
## 27 October 2026 — Preview-start guidance correction
Setup, card inspection and selected faction/card rule guides now identify
+9
View File
@@ -1,5 +1,14 @@
# Rule decision index
## Truthtrance and current battle cancellation — 27 October 2026
For a current native Basic Voice or Prescience response, the provisional
online question binds the respondent’s own printed Karama cost to that exact
battle and declaration. Current facts may be combined; arbitrary future-action
clauses remain outside this response. Another player's cancellation can close
the source without forcing the respondent. This is an implementation procedure,
not a separate publisher timing ruling. [Contract](TRUTHTRANCE_TURN_ACTIONS.md#current-voice-and-prescience-cancellation--27-october-2026).
## Ambassador child arrivals during overlap — 27 October 2026
Provisionally finish a Fremen or Guild Ambassador-created child arrival before
+26
View File
@@ -1007,3 +1007,29 @@ This is local engine/SSR/control evidence. No HTTP/storage boundary, database,
reset/deletion, server restart or deployed operation changes. Broader card,
recovery and module combinations and all final assurance/release gates remain
open.
## Current Voice and Prescience cancellation — 27 October 2026
**Prototyped / Partial.** The publisher FAQ (November 2020, p. 8) permits
current-turn Yes/No questions; the event-bound cancellation question is this
implementation's provisional online procedure, not a separate publisher ruling.
In a supported Basic table with the original Voice or Prescience response open,
a question can name whether a distinct seated player will spend a physical
printed Karama to cancel that declaration. Its source binds turn, battle event,
territory, power and declarer without exposing the respondent's hand. Current
facts may be combined with this exact response; other future action families
remain unavailable during this particular response window.
A definite answer preserves the original response and Truthtrance discard.
Only the actual accepted printed Karama cost counts, before its native
cancellation continuation. A whole No rejects that use and removes the card
from the native cancellation quote. A required Yes retains a legal completion:
passing remains possible while another participant keeps the response open, but
an own final pass cannot voluntarily close its last opportunity. A different
player's cancellation may end that opportunity and release an impossible Yes.
The owner-only guide uses the real quoted card and response; minimal bots use
the same native guide. Controlled physical-card swaps in the runtime fixture are
explicitly labelled; no game database, server or saved room is involved.
Advanced, optional overlays, broader response/action combinations and final
rules, browser and deployed acceptance remain open.
+77 -7
View File
@@ -10,7 +10,7 @@ import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay,
import { battlePowerOf, battlePowerMatches, nativeBattlePowerOpportunity,
type NativeBattlePower, type BattlePowerDeclaration } from './battle-power-question';
import { WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, karamaShipmentOpportunities,
parseKaramaShipmentSource, type WindowCardEffect } from './window-card-play';
parseKaramaShipmentSource, karamaBattleCancelOpportunity, type WindowCardEffect } from './window-card-play';
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, closeTurnActionPayments, finishTurnActionTurn,
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival, recordTurnActionCardPlay,
@@ -17166,7 +17166,9 @@ function karamaSpendingBlock(g: Game, p: Player, card: Card, purpose?: KullPurpo
if (card.kind === 'special' && card.effect === 'karama' && policy?.player === p.id &&
policy.forbidden.some(required =>
(required.effect === 'karamaAuction' && purpose === 'auction-free-purchase' ||
required.effect === 'karamaShipment' && purpose === 'shipment-price') &&
required.effect === 'karamaShipment' && purpose === 'shipment-price' ||
required.effect === 'karamaBattleCancel' && !!g.response &&
purpose === karamaUsePurpose(g, { kind: 'cancel', response: g.response })) &&
windowCardOpportunity(g, required.effect)?.source === required.source))
return 'Retain this opportunity without a forbidden printed Karama cost.';
const kullBlock = kullKaramaBlock(g, p, purpose);
@@ -17225,7 +17227,17 @@ function responseCancelCards(
// A physically spendable card whose Truthtrance feasibility is not proven
// remains a possible choice; the authoritative action still checks promises.
return p.hand
.filter((card) => !karamaSpendingBlock(g, p, card, karamaUsePurpose(g, {kind:'cancel', response})))
.filter((card) => {
if (karamaSpendingBlock(g, p, card, karamaUsePurpose(g, {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;
try {
// Pure receipt quote: do not simulate the cancellation or its suffix.
recordTurnActionWindowCardPlay(g.turnActionPromises, p.id, g.turn, 'karamaBattleCancel', window.source);
return true;
} catch (error) { if (error instanceof TurnActionError) return false; throw error; }
})
.map((card) => card.id);
}
/** The same conversion can be suspended by an independent private interaction. */
@@ -18404,6 +18416,19 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
}
if (shipmentWindow && card.kind === 'special' && card.effect === 'karama')
recordNativeTurnActionWindowCard(g, p.id, 'karamaShipment', shipmentWindow.source);
const battleCancel = use.kind === 'cancel' ? karamaBattleCancelOpportunity(g, p.id) : null;
if (battleCancel && card.kind === 'special' && card.effect === 'karama')
recordNativeTurnActionWindowCard(g, p.id, 'karamaBattleCancel', battleCancel.source);
else if (battleCancel && liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).some(row =>
row.windowCards?.karamaBattleCancel?.source === battleCancel.source && !row.windowCards.karamaBattleCancel.closed)) {
// A different Karama role ends this exact response without paying the
// promised printed cost. Check its closure before any discard reaction.
const trial = structuredClone(g);
trial.response = null;
trial.turnActionPromises = ownTurnActionWindowClosure(trial, p.id, ['karamaBattleCancel']);
requireRule(!!turnActionCompletion(trial, p.id),
'Retain your promised printed Karama cancellation or another whole-answer completion.');
}
const used = discard(g, p, card.id);
if (auctionWindow && auctionRows.some(row => row.windowCards?.karamaAuction?.source === auctionWindow))
guardTurnActionAuctionClosure(g, p.id, auctionRows);
@@ -20156,6 +20181,18 @@ function quotedShipmentKaramaCards(g: Game, player: Player): string[] {
} catch (error) { if (error instanceof RuleError) return false; throw error; }
}).map(card => card.id);
}
function quotedBattleCancelKaramaCards(g: Game, player: Player): string[] {
const response = g.response;
if (!response || !karamaBattleCancelOpportunity(g, player.id) || g.truthtrance || g.decision ||
g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingKull) return [];
const use = { kind: 'cancel' as const, response };
return player.hand.filter(card => {
if (card.kind !== 'special' || card.effect !== 'karama' ||
karamaSpendingBlock(g, player, card, karamaUsePurpose(g, use))) return false;
try { validateKaramaUse(g, player, use, card); return true; }
catch (error) { if (error instanceof RuleError) return false; throw error; }
}).map(card => card.id);
}
function thumperReadyStartsDraw(g: Game): boolean {
return !!windowCardOpportunity(g, 'thumper') && !g.truthtrance && !g.phaseOpening && !g.response &&
!g.decision && !g.pendingTreacheryDiscard && !g.spiceWindow && (!g.nexus || !!g.summonedBeforeBlow);
@@ -20179,6 +20216,10 @@ function quotedAmalReady(g: Game, actor: string): boolean {
function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffect): Generator<Action> {
if (!windowCardOpportunity(g, effect)) return;
const player = getPlayer(g, actor);
if (effect === 'karamaBattleCancel') {
for (const card of quotedBattleCancelKaramaCards(g, player)) yield { type: 'card', card, mode: 'cancel' };
return;
}
if (effect === 'amal') {
for (const card of quotedAmalCards(g, player)) yield { type: 'card', card };
return;
@@ -20321,13 +20362,18 @@ function recordNativeTurnActionWindowCard(g: Game, actor: string, effect: Window
}
function reconcileTurnActionWindowCards(g: Game) {
if (!g.turnActionPromises) return;
const continuation = g.pendingTreacheryDiscard?.continuation;
// A completed Truthtrance parks its original response until the physical
// discard settles; it has not closed the native cancellation opportunity.
const windowGame = continuation?.kind === 'truthtranceDiscard'
? { ...g, response: continuation.resume.response } : g;
for (const promise of g.turnActionPromises) {
if (promise.fulfilled || promise.released) continue;
for (const effect of WINDOW_CARD_EFFECTS) {
const stage = promise.windowCards?.[effect];
const foreignInvoice = effect === 'karamaAuction' && g.decision?.kind === 'auctionPayment' &&
g.decision.player !== promise.player;
if (!stage || stage.closed || !foreignInvoice && promise.turn === g.turn && windowCardOpportunity(g, effect, stage.source)?.source === stage.source) continue;
if (!stage || stage.closed || !foreignInvoice && promise.turn === g.turn && windowCardOpportunity(windowGame, effect, stage.source)?.source === stage.source) continue;
g.turnActionPromises = turnActionRule(() => closeTurnActionWindowCardPlay(g.turnActionPromises!,
promise.player, promise.turn, effect, stage.source));
}
@@ -20483,9 +20529,11 @@ function guardTurnActionShipmentPriceDelivery(g: Game, shipment: PendingShipment
trial.turnActionPromises = structuredClone(g.turnActionPromises);
guardTurnActionShipmentPriceBoundary(trial, shipment.player, original, undefined, receipt);
}
function turnActionTargetAvailable(g: Game, _actor: string): boolean {
function turnActionTargetAvailable(g: Game, actor: string): boolean {
const context = g.phaseOpening ? { ...g, phaseOpening: null } : g;
return turnActionPromiseTurn(g) && !bribeTimingBlock({ ...context, truthtrance: null,
const battleCancel = karamaBattleCancelOpportunity(context, actor);
return turnActionPromiseTurn(g) && (!g.response || !!battleCancel) &&
!bribeTimingBlock({ ...context, truthtrance: null, response: battleCancel ? null : context.response,
inflation: context.inflation?.side === 'double' ? null : context.inflation });
}
/** Whole native commitments retain the existing Semuta discard continuation.
@@ -21264,10 +21312,20 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
const ownedInvoice = g.decision?.kind === 'auctionPayment' && g.decision.player === player.id;
const saphoExtortion = g.decision?.kind === 'moritaniExtortion' &&
saphoOptions(g, player).some(option => option.scope === 'extortion');
if (!promise || g.truthtrance || g.response || g.pendingTreacheryDiscard ||
const battleCancel = karamaBattleCancelOpportunity(g, player.id);
if (!promise || g.truthtrance || g.response && !battleCancel || g.pendingTreacheryDiscard ||
g.decision && !ownedBox && !ownedInvoice && !saphoExtortion) return null;
const completion = turnActionCompletion(g, player.id);
if (!completion) return null;
if (battleCancel) {
const row = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(item =>
item.windowCards?.karamaBattleCancel && !item.windowCards.karamaBattleCancel.closed &&
item.windowCards.karamaBattleCancel.source === battleCancel.source);
const action = completion.cardPreparations?.find(item => item.type === 'card' && item.mode === 'cancel' &&
quotedBattleCancelKaramaCards(g, player).includes(item.card as string));
return row && action ? { event: row.event, action: { ...action,
turnActionPromiseEvent: row.event, turnActionPromisePhase: g.phase } } : null;
}
if (g.phaseOpening) {
const opportunity = windowCardOpportunity(g, 'amal');
const row = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row =>
@@ -40824,6 +40882,18 @@ function applyActionInner(
!response.passed.includes(id),
'You have already passed this response window.',
);
const battleCancel = karamaBattleCancelOpportunity(g, id);
if (battleCancel && liveTurnActionPromises(g.turnActionPromises ?? [], id, g.turn).some(row =>
row.windowCards?.karamaBattleCancel?.source === battleCancel.source && !row.windowCards.karamaBattleCancel.closed)) {
const trial = structuredClone(g);
trial.response!.passed.push(id);
if (!trial.players.some(other => !trial.response!.passed.includes(other.id) &&
responseCancelCards(trial, other, trial.response!).length > 0)) {
trial.turnActionPromises = ownTurnActionWindowClosure(trial, id, ['karamaBattleCancel']);
requireRule(!!turnActionCompletion(trial, id),
'Play your promised printed Karama or retain another completion before closing this response.');
}
}
response.passed.push(id);
} else {
requireRule(
+1
View File
@@ -3281,6 +3281,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'In a supported Basic battle response, ask whether a named non-owner will spend a physical printed Karama to cancel the current Voice or Prescience. The question may combine this exact response with current facts; other future action families remain unavailable while the response is open. Yes requires that actual card use while possible; No prevents it. A different player may cancel first, releasing an impossible Yes. Passing does not itself surrender a still-open response, but the target cannot close their last opportunity while a required Yes remains.',
'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.',
'A printed Nullentropy Box clause counts its accepted two-spice search activation after answering this turn, before private selection. It does not name the recovered card; earlier use or recovery alone does not count. No lasts through Mentat. The actual fee shares the purse with payments and physical actions; an unpaid unknown recovered card cannot supply another effect. Whole native Basic Richese answers retain their real Semuta discard continuation. Current guides use native availability and paid selection keeps its own panel.',
+9 -2
View File
@@ -4,7 +4,7 @@ import { parseTravelActionClaim, type TravelClaim } from './travel-expression-pr
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES,
turnActionPromiseModeSupported, type TurnActionClaim } from './turn-action-promises';
import { battlePowerOf, nativeBattlePowerOpportunity } from './battle-power-question';
import { parseKaramaShipmentSource, windowCardOpportunity } from './window-card-play';
import { parseKaramaShipmentSource, parseKaramaBattleCancelSource, karamaBattleCancelOpportunity, windowCardOpportunity } from './window-card-play';
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';
@@ -421,13 +421,16 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
return { kind: 'bid', target: v.target, ...claim };
}
if (v.kind === 'turnActions') {
const battleCancel = g.response ? karamaBattleCancelOpportunity(g, v.target as string) : null;
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && v.turn === g.turn &&
turnActionPromiseModeSupported(g) && g.status === 'playing' && Number.isInteger(g.phase) && g.phase >= 0 && g.phase <= 8 &&
!g.response && !g.decision && (!g.phaseOpening || !!windowCardOpportunity(g, 'amal')) && !g.pendingTreacheryDiscard && !g.pendingKarama,
(!g.response || !!battleCancel) && !g.decision && (!g.phaseOpening || !!windowCardOpportunity(g, 'amal')) && !g.pendingTreacheryDiscard && !g.pendingKarama,
'Supply the current turn, complete turn-action question and a supported quiet native window.');
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 (battleCancel) check(action.kind === 'windowCardPlay' && action.effect === 'karamaBattleCancel' &&
action.source === battleCancel.source, 'Ask only about the current printed Karama cancellation in this response.');
if (action.kind === 'windowCardPlay') {
const opportunity = windowCardOpportunity(g, action.effect);
check(opportunity && opportunity.turn === g.turn && opportunity.source === action.source,
@@ -786,6 +789,10 @@ export function truthQuestionText(
if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => {
if (action.kind === 'windowCardPlay') {
if (action.effect === 'karamaBattleCancel') {
const response = parseKaramaBattleCancelSource(action.source);
return `you spend a printed Karama after this answer to cancel ${response?.kind === 'voice' ? 'Voice' : 'Prescience'} in this original battle response`;
}
if (action.effect === 'amal') return 'you play Amal after this answer during this original phase opening, halving available spice before ordinary payments';
if (action.effect === 'karamaAuction') return 'you spend a printed Karama after this answer on this original ordinary auction lot, by immediate purchase or its winning invoice';
if (action.effect === 'karamaShipment') {
+49 -2
View File
@@ -1,10 +1,17 @@
import { harvesterAvailable } from './cards';
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'thumper', 'amal'] as const;
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'thumper', 'amal'] as const;
export type WindowCardEffect = typeof WINDOW_CARD_EFFECTS[number];
export function windowCardNativeEffect(effect: WindowCardEffect): 'weather' | 'atomics' | 'harvester' | 'karama' | 'thumper' | 'amal' {
return effect === 'karamaAuction' || effect === 'karamaShipment' ? 'karama' : effect;
return effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' ? 'karama' : effect;
}
export type KaramaBattleCancelSource = {
turn: number;
battle: string;
territory: string;
kind: 'voice' | 'prescience';
owner: string;
};
export type KaramaShipmentOpportunity = { effect: 'karamaShipment'; source: string; turn: number; recipient: string };
export type KaramaShipmentSource = { turn: number; recipient: string; kind: 'original' } |
{ turn: number; recipient: string; kind: 'cunning'; event: string };
@@ -12,6 +19,7 @@ export type WindowCardOpportunity = { effect: Exclude<WindowCardEffect, 'karamaS
KaramaShipmentOpportunity;
export type WindowCardGame = {
status: string;
advanced?: boolean;
phase: number;
turn: number;
shieldWallDestroyed: boolean;
@@ -31,6 +39,8 @@ export type WindowCardGame = {
harvesterClosed?: boolean;
} | null;
response?: unknown;
/** Public battle identity; plans and card custody are deliberately omitted. */
battle?: { event?: string | null; territory: string; revealed: boolean } | null;
decision?: unknown;
phaseOpening?: unknown;
truthtrance?: unknown;
@@ -68,6 +78,30 @@ export function parseKaramaShipmentSource(source: unknown): KaramaShipmentSource
? { turn: tuple[1], recipient: tuple[2], kind: 'cunning', event: tuple[4] } : null;
}
/** Decode only the canonical event-bound public response identity. This stays
* available after the original response closes for accepted promise receipts. */
export function parseKaramaBattleCancelSource(source: unknown): KaramaBattleCancelSource | null {
if (!identifier(source)) return null;
let tuple: unknown;
try { tuple = JSON.parse(source); } catch { return null; }
if (!Array.isArray(tuple) || tuple.length !== 6 || tuple[0] !== 'karamaBattleCancel' ||
!whole(tuple[1]) || tuple[1] < 1 || !identifier(tuple[2]) || !identifier(tuple[3]) ||
(tuple[4] !== 'voice' && tuple[4] !== 'prescience') || !identifier(tuple[5]) ||
JSON.stringify(tuple) !== source) return null;
return { turn: tuple[1], battle: tuple[2], territory: tuple[3], kind: tuple[4], owner: tuple[5] };
}
/** A battle response is public. The target check deliberately reads no hand,
* card reservation, response pass or other private cancellation eligibility. */
export function karamaBattleCancelOpportunity(game: WindowCardGame, target: string,
source?: string): WindowCardOpportunity | null {
const window = windowCardOpportunity(game, 'karamaBattleCancel', source);
const parsed = window && parseKaramaBattleCancelSource(window.source);
if (!parsed || !identifier(target) || !game.players?.some(player => player.id === target) ||
target === parsed.owner) return null;
return window;
}
/** Every still-unused original entitlement remains bound through other actors'
* turns. A declared Cunning continuation permanently retires its original one. */
export function karamaShipmentOpportunities(game: WindowCardGame): KaramaShipmentOpportunity[] {
@@ -124,6 +158,19 @@ export function karamaShipmentOpportunities(game: WindowCardGame): KaramaShipmen
* force conditions and chosen effect parameters remain native engine rules. */
function globalWindowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null {
if (game.status !== 'playing' || !whole(game.turn) || game.turn < 1) return null;
if (effect === 'karamaBattleCancel') {
const battle = game.battle, response = game.response, players = game.players;
if (game.advanced !== false || game.phase !== 6 || !battle || !identifier(battle.event) ||
!identifier(battle.territory) || battle.revealed !== false || !object(response) ||
(response.kind !== 'voice' && response.kind !== 'prescience') || !identifier(response.owner) ||
!Array.isArray(players) || !players.length ||
!players.every(player => object(player) && identifier(player.id)) ||
new Set(players.map(player => player.id)).size !== players.length ||
!players.some(player => player.id === response.owner)) return null;
return { effect, turn: game.turn, source: JSON.stringify([
effect, game.turn, battle.event, battle.territory, response.kind, response.owner,
]) };
}
if (effect === 'amal') {
if (!whole(game.phase) || game.phase > 8 || game.amalWindowOpen !== undefined && typeof game.amalWindowOpen !== 'boolean' ||
game.amalWindowOpen === false) return null;
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import { type Action, type Game } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact } from '../game/truthtrance';
import {
actTruthTurnPower, createTruthTurnPowerFixture, truthTurnPowerInventory,
truthTurnPowerPlayer, type TruthTurnPowerStep,
} from './fixture-truth-turn-power';
export type ResponseCancelLeaf = { kind: 'windowCardPlay'; effect: 'karamaBattleCancel'; source: string };
export type ResponseCancelExpression = MixedActionExpression<ResponseCancelLeaf>;
export const actResponseCancel = actTruthTurnPower;
export const responseCancelFact = (fact: TruthFact): ResponseCancelExpression => ({ kind: 'fact', fact });
export const responseCancelPlay = (source: string): ResponseCancelExpression => ({
kind: 'action', action: { kind: 'windowCardPlay', effect: 'karamaBattleCancel', source },
});
/** Genuine Basic setup, shipment, battle and native power declarations are
* inherited from the existing fixture. Two explicit controlled physical-card
* swaps place the purchased Karama with the respondent and the other original
* Karama with the asker. All cards remain present once; this does not claim
* either controlled swap was a native sale or legal in-game transfer. */
export function createResponseCancelFixture(power: 'voice' | 'prescience') {
const base = createTruthTurnPowerFixture({ power });
let game = structuredClone(base.game);
const owner = base.target, asker = base.asker;
const target = power === 'voice' ? base.atreides : base.bg;
assert.notEqual(owner, target); assert.notEqual(asker, target);
const holder = truthTurnPowerPlayer(game, asker), respondent = truthTurnPowerPlayer(game, target);
const bought = holder.hand.findIndex(card => card.id === base.karama);
assert.ok(bought >= 0); assert.ok(respondent.hand.length);
[holder.hand[bought], respondent.hand[0]] = [respondent.hand[0], holder.hand[bought]];
const second = game.deck.findIndex(card => card.kind === 'special' && card.effect === 'karama');
assert.ok(second >= 0, 'The second original Karama must be in the controlled unopened deck');
const secondKarama = game.deck[second].id;
[holder.hand[bought], game.deck[second]] = [game.deck[second], holder.hand[bought]];
assert.deepEqual(truthTurnPowerInventory(game), base.originalCards);
const beforeDeclaration = structuredClone(game);
game = actTruthTurnPower(game, owner, base.nativeAction);
assert.equal(game.response?.kind, power); assert.equal(game.response.owner, owner);
assert.equal(game.battle?.event, base.event); assert.equal(game.battle.territory, base.territory);
const source = JSON.stringify(['karamaBattleCancel', game.turn, base.event, base.territory, power, owner]);
const mixed = responseCancelPlay(source);
return { base, game, beforeDeclaration, asker, target, owner, power, source, mixed,
truth: base.truth, karama: base.karama, secondKarama,
cancel: { type: 'card', card: base.karama, mode: 'cancel' } satisfies Action,
foreignCancel: { type: 'card', card: secondKarama, mode: 'cancel' } satisfies Action,
sourceDisclosure: `${base.sourceDisclosure} The adapter uses controlled physical hand/deck swaps for ` +
'two Karama cards, then makes the actual native power declaration and retains its real response.',
};
}
export type ResponseCancelFixture = ReturnType<typeof createResponseCancelFixture>;
export function declareResponseCancel(f: ResponseCancelFixture, source: Game = f.game, actions: TruthTurnPowerStep[] = []) {
let game = actTruthTurnPower(source, f.asker, { type: 'card', card: f.truth }, actions);
while (game.truthtrance?.stage === 'priority') {
const player = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id));
assert.ok(player); game = actTruthTurnPower(game, player.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return game;
}
export function askResponseCancel(game: Game, f: ResponseCancelFixture, mixed = f.mixed) {
return actTruthTurnPower(game, f.asker, { type: 'truthAsk', question: {
kind: 'turnActions', target: f.target, turn: game.turn, mixed: structuredClone(mixed),
} });
}
export function bindResponseCancel(f: ResponseCancelFixture, answer: 'yes' | 'no', mixed = f.mixed) {
const declared = declareResponseCancel(f), asked = askResponseCancel(declared, f, mixed);
const game = actTruthTurnPower(asked, f.target, { type: 'truthAnswer', answer });
return { declared, asked, game };
}
+130
View File
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { windowCardOpportunity } from '../game/window-card-play';
import { truthTurnPowerInventory, truthTurnPowerPlayer } from './fixture-truth-turn-power';
import {
actResponseCancel as act, askResponseCancel as ask, bindResponseCancel as bind,
createResponseCancelFixture as fixture, declareResponseCancel as declare,
responseCancelFact as fact, responseCancelPlay as play, type ResponseCancelFixture,
} from './fixture-truth-turn-response-cancel';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before);
}
function receipt(game: Game, f: ResponseCancelFixture) {
const row = game.turnActionPromises?.find(promise => promise.player === f.target);
assert.ok(row); const stage = row.windowCards?.karamaBattleCancel; assert.ok(stage);
return { row, stage };
}
function physical(game: Game, f: ResponseCancelFixture) {
assert.deepEqual(truthTurnPowerInventory(game), f.base.originalCards);
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
}
void test('current native Voice and Prescience responses accept a printed cancellation Yes and record the original physical cost once', () => {
for (const power of ['voice', 'prescience'] as const) {
const f = fixture(power);
const window = windowCardOpportunity(f.game, 'karamaBattleCancel');
assert.equal(window?.source, f.source);
const declared = declare(f), asked = ask(declared, f);
assert.ok(viewGame(asked, f.target).truthTurnActionAnswers?.includes('yes'));
let game = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.equal(game.response?.kind, power);
assert.deepEqual(receipt(game, f).stage, { source: f.source, played: false, closed: false });
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.mode, 'cancel');
assert.equal(guide.action.card, f.karama);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.target);
view.players.find(player => player.id === f.target)!.bot = difficulty;
assert.deepEqual(botActions(view)[0], guide.action);
}
game = act(game, f.target, guide.action);
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(game.battle?.[power], undefined);
assert.equal(receipt(game, f).stage.played, true);
assert.equal(receipt(game, f).row.fulfilled, true);
assert.ok(truthTurnPowerPlayer(game, f.asker).hand.some(card => card.id === f.secondKarama));
physical(game, f);
}
});
void test('No blocks the exact printed cancellation while retaining the original response and other player choice', () => {
for (const power of ['voice', 'prescience'] as const) {
const f = fixture(power); let game = bind(f, 'no').game;
assert.equal(game.response?.kind, power);
const view = viewGame(game, f.target);
assert.ok(!view.responseControls?.cancelCards.includes(f.karama));
for (const difficulty of DIFFICULTIES) {
const bot = viewGame(game, f.target);
bot.players.find(player => player.id === f.target)!.bot = difficulty;
assert.ok(!botActions(bot).some(action => action.type === 'card' && action.card === f.karama && action.mode === 'cancel'));
}
rejected(game, f.target, f.cancel);
assert.ok(truthTurnPowerPlayer(game, f.target).hand.some(card => card.id === f.karama));
game = act(game, f.asker, { type: 'passResponse' });
if (game.response && !game.response.passed.includes(f.target))
game = act(game, f.target, { type: 'passResponse' });
assert.equal(game.response, null);
assert.equal(receipt(game, f).stage.played, false);
assert.equal(receipt(game, f).stage.closed, true);
assert.equal(receipt(game, f).row.fulfilled, true);
assert.ok(game.battle?.[power]);
physical(game, f);
}
});
void test('a Yes may pass while another participant remains, but cannot voluntarily close its last actual opportunity', () => {
for (const power of ['voice', 'prescience'] as const) {
const f = fixture(power), bound = bind(f, 'yes').game;
let passed = act(bound, f.target, { type: 'passResponse' });
assert.ok(passed.response?.passed.includes(f.target));
passed = act(passed, f.target, f.cancel);
assert.equal(receipt(passed, f).row.fulfilled, true);
let last = act(bound, f.asker, { type: 'passResponse' });
assert.equal(last.response?.kind, power);
rejected(last, f.target, { type: 'passResponse' });
last = act(last, f.target, f.cancel);
assert.equal(receipt(last, f).row.fulfilled, true);
physical(last, f);
}
});
void test('another player cancelling the same original power releases an unmet Yes without spending the respondent card', () => {
for (const power of ['voice', 'prescience'] as const) {
const f = fixture(power); let game = bind(f, 'yes').game;
game = act(game, f.asker, f.foreignCancel);
assert.equal(game.battle?.[power], undefined);
assert.equal(game.discard.filter(card => card.id === f.secondKarama).length, 1);
assert.ok(truthTurnPowerPlayer(game, f.target).hand.some(card => card.id === f.karama));
assert.equal(receipt(game, f).stage.played, false);
assert.equal(receipt(game, f).stage.closed, true);
assert.equal(receipt(game, f).row.released, true);
physical(game, f);
}
});
void test('current facts may combine with this exact response, while stale sources, the power owner and unrelated actions are rejected', () => {
const f = fixture('voice'), declared = declare(f);
const stale = play(JSON.stringify(['karamaBattleCancel', declared.turn, 'other-battle', f.base.territory, f.power, f.owner]));
rejected(declared, f.asker, { type: 'truthAsk', question: {
kind: 'turnActions', target: f.target, turn: declared.turn, mixed: stale,
} });
rejected(declared, f.asker, { type: 'truthAsk', question: {
kind: 'turnActions', target: f.owner, turn: declared.turn, mixed: f.mixed,
} });
rejected(declared, f.asker, { type: 'truthAsk', question: {
kind: 'turnActions', target: f.target, turn: declared.turn,
mixed: { kind: 'action', action: { kind: 'payment', recipient: f.asker, minimum: 1 } },
} });
const mixed = { kind: 'and' as const, terms: [
fact({ kind: 'spice', compare: 'gte', value: 0 }), f.mixed,
] };
const asked = ask(declared, f, mixed);
assert.ok(viewGame(asked, f.target).truthTurnActionAnswers?.includes('yes'));
});
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { TurnActionFields, turnActionInputError } from '../components/turn-action-fields';
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TurnActionLeaf } from '../game/turn-action-promises';
type Expression = MixedActionExpression<TurnActionLeaf>;
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
const source = (kind: 'voice' | 'prescience') => JSON.stringify([
'karamaBattleCancel', 2, 'controlled-battle', 'arrakeen', kind, 'a',
]);
/** A controlled public projection tests the editor, not native battle legality. */
function projection(kind: 'voice' | 'prescience'): GameView {
const game = createGame('TURNACTIONCANCEL', newPlayer('p', 'Respondent', 'emperor'));
joinGame(game, newPlayer('a', 'Declarer', kind === 'voice' ? 'beneGesserit' : 'atreides'));
joinGame(game, newPlayer('r', 'Other', 'harkonnen'));
const view = viewGame(game, 'p');
view.status = 'playing'; view.turn = 2; view.phase = 6;
view.response = { kind, owner: 'a', passed: [] };
view.battle = { event: 'controlled-battle', territory: 'arrakeen', attacker: 'a', defender: 'p', revealed: false,
submitted: [], traitorVoters: ['p', 'a'], traitorSubmitted: [], plans: {}, truthPromises: [],
} as unknown as NonNullable<GameView['battle']>;
return view;
}
const claim = (kind: 'voice' | 'prescience'): Expression => ({ kind: 'action', action: {
kind: 'windowCardPlay', effect: 'karamaBattleCancel', source: source(kind),
} });
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
return [node, ...elements(node.props.children)];
}
for (const kind of ['voice', 'prescience'] as const) {
void test(`editor binds printed Karama to the current ${kind} response`, () => {
const game = projection(kind), value = claim(kind);
assert.equal(turnActionInputError(value, game, 'p'), null);
const html = renderToStaticMarkup(createElement(TurnActionFields, { game, value, target: 'p', disabled: false, onChange() {} }));
assert.match(html, /Spend a printed Karama to cancel this Voice or Prescience/);
assert.match(html, kind === 'voice' ? /Voice declared by Declarer/ : /Prescience declared by Declarer/);
assert.match(html, /Arrakeen/);
assert.match(html, /Only spending a physical printed Karama/);
assert.doesNotMatch(html, /Respondent.s hand|private-card/);
assert.notEqual(turnActionInputError(value, game, 'a'), null, 'the declaration owner is not a cancellation target');
game.response = null;
assert.notEqual(turnActionInputError(value, game, 'p'), null, 'closed original response is no longer selectable');
});
}
void test('editor defaults to the current battle cancellation and keeps its exact source', () => {
const game = projection('voice');
let value: Expression = { kind: 'action', action: { kind: 'ship', territory: 'carthag', minimum: 1 } };
const tree = TurnActionFields({ game, value, target: 'p', disabled: false, onChange(next) { value = next; } });
const select = elements(tree).find(element => element.props.id === 'truth-turn-action-1-action');
assert.ok(select);
(select.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value: 'windowCardPlay' } });
assert.deepEqual(value, claim('voice'));
});
void test('response questions reject unrelated future actions even within a mixed expression', () => {
const game = projection('voice');
const unrelated: Expression = { kind: 'action', action: { kind: 'ship', territory: 'carthag', minimum: 1 } };
const mixed: Expression = { kind: 'or', terms: [claim('voice'), unrelated] };
assert.match(turnActionInputError(unrelated, game, 'p') ?? '', /choose only a printed Karama cancellation/i);
assert.match(turnActionInputError(mixed, game, 'p') ?? '', /choose only a printed Karama cancellation/i);
game.response = null;
assert.equal(turnActionInputError(unrelated, game, 'p'), null, 'ordinary questions retain their existing editor rules');
});
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { TurnActionGuideButton } from '../components/turn-action-promises';
import { baseDeck } from '../game/cards';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
/** Controlled projected quote tests callback fencing, not battle legality. */
function projection(kind: 'voice' | 'prescience') {
const game = createGame('TURNCANCELGUIDE', newPlayer('p', 'Respondent', 'emperor'));
joinGame(game, newPlayer('a', 'Declarer', kind === 'voice' ? 'beneGesserit' : 'atreides'));
joinGame(game, newPlayer('r', 'Other', 'harkonnen'));
const view = viewGame(game, 'p');
view.status = 'playing'; view.turn = 2; view.phase = 6;
view.response = { kind, owner: 'a', passed: [] };
view.battle = { event: 'controlled-battle', territory: 'arrakeen', attacker: 'a', defender: 'p', revealed: false,
submitted: [], traitorVoters: ['p', 'a'], traitorSubmitted: [], plans: {}, truthPromises: [],
} as unknown as NonNullable<GameView['battle']>;
const card = baseDeck().find(item => item.kind === 'special' && item.effect === 'karama'); assert.ok(card);
const physical = { ...card, id: 'private-physical-karama' };
view.players.find(player => player.id === 'p')!.hand = [physical];
view.responseControls = { cancelCards: [physical.id], hasPassed: false };
const event = 'controlled-truth-promise';
const source = JSON.stringify(['karamaBattleCancel', 2, 'controlled-battle', 'arrakeen', kind, 'a']);
view.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 2,
mixed: { kind: 'action', action: { kind: 'windowCardPlay', effect: 'karamaBattleCancel', source } },
answer: true, fulfilled: false, released: false }];
const action: Action = { type: 'card', card: physical.id, mode: 'cancel',
turnActionPromiseEvent: event, turnActionPromisePhase: 6 };
view.turnActionGuide = { event, action };
return { view, action };
}
for (const kind of ['voice', 'prescience'] as const) {
void test(`human guide submits only the quoted printed Karama for current ${kind}`, () => {
const { view, action } = projection(kind), sent: Action[] = [];
const props = { game: view, busy: false, act: (next: Action) => sent.push(next) };
const button = TurnActionGuideButton(props); assert.ok(button); assert.equal(button.props.disabled, false);
const html = renderToStaticMarkup(createElement(TurnActionGuideButton, props));
assert.match(html, kind === 'voice' ? /cancel this Voice/ : /cancel this Prescience/);
assert.doesNotMatch(html, /private-physical-karama/);
button.props.onClick(); assert.deepEqual(sent, [action]);
});
}
void test('stale battle response or changed native cancellation quote prevents guide dispatch', () => {
for (const mutate of [
(view: GameView) => { view.response = null; },
(view: GameView) => { view.response = { kind: 'prescience', owner: 'a', passed: [] }; },
(view: GameView) => { view.responseControls!.cancelCards = []; },
(view: GameView) => { view.battle!.event = 'another-battle'; },
]) {
const { view } = projection('voice'), sent: Action[] = [];
const button = TurnActionGuideButton({ game: view, busy: false, act: action => sent.push(action) });
assert.ok(button); assert.equal(button.props.disabled, false);
mutate(view); button.props.onClick(); assert.deepEqual(sent, []);
}
});
void test('guide excludes non-printed cards and unavailable native quotes', () => {
for (const mutate of [
(view: GameView) => { view.responseControls!.cancelCards = []; },
(view: GameView) => { view.players.find(player => player.id === 'p')!.hand![0].kind = 'worthless'; },
(view: GameView) => { view.turnActionGuide!.action.mode = 'shipment'; },
]) {
const { view } = projection('voice'); mutate(view);
const button = TurnActionGuideButton({ game: view, busy: false, act() {} });
assert.ok(button); assert.equal(button.props.disabled, true);
}
});
+89
View File
@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { karamaBattleCancelOpportunity, parseKaramaBattleCancelSource,
windowCardNativeEffect, windowCardOpportunity, type WindowCardGame } from '../game/window-card-play';
const fixture = (kind: 'voice' | 'prescience' = 'voice'): WindowCardGame => ({
status: 'playing', advanced: false, phase: 6, turn: 3, shieldWallDestroyed: false,
battle: { event: 'battle:3:one', territory: 'Arrakeen', revealed: false },
response: { kind, owner: 'owner', passed: [] },
players: [{ id: 'owner' }, { id: 'target' }, { id: 'third' }],
});
void test('current native battle response has a canonical public source for either power', () => {
for (const kind of ['voice', 'prescience'] as const) {
const game = fixture(kind);
const source = JSON.stringify(['karamaBattleCancel', 3, 'battle:3:one', 'Arrakeen', kind, 'owner']);
const opportunity = { effect: 'karamaBattleCancel', source, turn: 3 };
assert.deepEqual(windowCardOpportunity(game, 'karamaBattleCancel'), opportunity);
assert.deepEqual(karamaBattleCancelOpportunity(game, 'target'), opportunity);
assert.deepEqual(parseKaramaBattleCancelSource(source), {
turn: 3, battle: 'battle:3:one', territory: 'Arrakeen', kind, owner: 'owner',
});
assert.equal(windowCardNativeEffect('karamaBattleCancel'), 'karama');
assert.equal(windowCardOpportunity(game, 'karamaBattleCancel', source)?.source, source);
assert.equal(windowCardOpportunity(game, 'karamaBattleCancel', `${source} `), null);
assert.equal(karamaBattleCancelOpportunity(game, 'owner'), null);
assert.equal(karamaBattleCancelOpportunity(game, 'not-seated'), null);
}
});
void test('a response or battle identity change never reuses the prior source', () => {
const game = fixture();
const source = windowCardOpportunity(game, 'karamaBattleCancel')!.source;
const variants: WindowCardGame[] = [
{ ...game, turn: 4 },
{ ...game, battle: { event: 'battle:3:two', territory: 'Arrakeen', revealed: false } },
{ ...game, battle: { event: 'battle:3:one', territory: 'Carthag', revealed: false } },
{ ...game, response: { kind: 'prescience', owner: 'owner', passed: [] } },
{ ...game, response: { kind: 'voice', owner: 'third', passed: [] } },
];
for (const variant of variants) {
assert.ok(windowCardOpportunity(variant, 'karamaBattleCancel'));
assert.equal(windowCardOpportunity(variant, 'karamaBattleCancel', source), null);
assert.equal(karamaBattleCancelOpportunity(variant, 'target', source), null);
}
});
void test('missing, malformed, ineligible and non-Basic response states have no opportunity', () => {
const game = fixture();
const variants: WindowCardGame[] = [
{ ...game, status: 'lobby' }, { ...game, advanced: true }, { ...game, advanced: undefined },
{ ...game, phase: 5 }, { ...game, turn: 0 }, { ...game, response: null },
{ ...game, response: { kind: 'nexusPrescience', owner: 'owner' } },
{ ...game, response: { kind: 'voice', owner: 'missing' } },
{ ...game, battle: null },
{ ...game, battle: { event: null, territory: 'Arrakeen', revealed: false } },
{ ...game, battle: { event: 'battle:3:one', territory: 'Arrakeen', revealed: true } },
{ ...game, players: [{ id: 'owner' }, { id: 'owner' }] },
{ ...game, players: undefined },
];
for (const variant of variants) assert.equal(windowCardOpportunity(variant, 'karamaBattleCancel'), null);
});
void test('source parser rejects ambiguous, noncanonical and malformed tuples', () => {
const invalid = [null, '', '{}', '[]',
'[ "karamaBattleCancel",3,"event","territory","voice","owner"]',
JSON.stringify(['karamaBattleCancel', 0, 'event', 'territory', 'voice', 'owner']),
JSON.stringify(['karamaBattleCancel', 1, 'event', 'territory', 'voice', 'owner', 'extra']),
JSON.stringify(['karamaBattleCancel', 1, 'event', 'territory', 'nexusPrescience', 'owner']),
JSON.stringify(['karamaBattleCancel', 1, 'event ', 'territory', 'voice', 'owner']),
];
for (const source of invalid) assert.equal(parseKaramaBattleCancelSource(source), null);
const source = JSON.stringify(['karamaBattleCancel', 1, 'battle:one', 'territory:two', 'voice', 'owner']);
const distinct = JSON.stringify(['karamaBattleCancel', 1, 'battle:one:territory', 'two', 'voice', 'owner']);
assert.notEqual(source, distinct);
assert.notDeepEqual(parseKaramaBattleCancelSource(source), parseKaramaBattleCancelSource(distinct));
});
void test('public identity does not depend on private hands or passed responders', () => {
const game = fixture();
const source = windowCardOpportunity(game, 'karamaBattleCancel')!.source;
const altered = {
...game,
response: { kind: 'voice', owner: 'owner', passed: ['target'] },
players: [{ id: 'owner', hand: ['secret-a'] }, { id: 'target', hand: ['secret-b'] }, { id: 'third' }],
};
assert.equal(karamaBattleCancelOpportunity(altered, 'target')?.source, source);
assert.doesNotMatch(source, /secret/);
});