Support Truthtrance battle cancellation questions
This commit is contained in:
1 parent
813a2bacbe
commit
56b3aeab68
15 files changed
+675
-29
No files matched your search
@@ -7,7 +7,7 @@ import type { MixedActionExpression } from '@/game/mixed-action-question';
|
|||||||
import { parseBattleActionClaim } from '@/game/mixed-battle-question';
|
import { parseBattleActionClaim } from '@/game/mixed-battle-question';
|
||||||
import { battlePowerOf, nativeBattlePowerOpportunity, type BattlePowerClaim } from '@/game/battle-power-question';
|
import { battlePowerOf, nativeBattlePowerOpportunity, type BattlePowerClaim } from '@/game/battle-power-question';
|
||||||
import { battleCardLabel, VOICE_KINDS } from '@/game/battle-cards';
|
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 { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES, type TurnActionLeaf } from '@/game/turn-action-promises';
|
||||||
import { BattleClaimFields } from './battle-promises';
|
import { BattleClaimFields } from './battle-promises';
|
||||||
import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields';
|
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',
|
const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester',
|
||||||
karamaAuction: 'Spend a printed Karama for this current ordinary lot',
|
karamaAuction: 'Spend a printed Karama for this current ordinary lot',
|
||||||
karamaShipment: 'Spend a printed Karama for the current shipment-price opportunity',
|
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' };
|
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> = {
|
const config: MixedActionConfig<TurnActionLeaf> = {
|
||||||
prefix: 'turn-action',
|
prefix: 'turn-action',
|
||||||
legend: 'Current battle and this-turn action conditions, with optional current facts',
|
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;
|
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.
|
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.
|
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's cancellation can end the opportunity.
|
||||||
Thumper conditions count an actual accepted future printed play before this turn's original first spice reveal.
|
Thumper conditions count an actual accepted future printed play before this turn's original first spice reveal.
|
||||||
Earlier plays, disposal, other roles and recovery alone do not count. A later reveal is a different opportunity.
|
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.
|
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 }),
|
newAction: () => ({ kind: 'ship', territory: 'carthag', minimum: 1 }),
|
||||||
parseAction: (input, game, target) => {
|
parseAction: (input, game, target) => {
|
||||||
const action = parseTurnActionLeaf(input);
|
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))
|
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.');
|
throw new Error('Choose a public non-allied recipient for the future ordinary payment.');
|
||||||
if (action.kind === 'bid' && currentLot(game)?.lot !== action.lot)
|
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.');
|
throw new Error('Choose this player’s original native Voice or Prescience opportunity in the current battle.');
|
||||||
}
|
}
|
||||||
if (action.kind === 'windowCardPlay') {
|
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)
|
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;
|
return action;
|
||||||
},
|
},
|
||||||
renderAction: ({ value, id, game, target, disabled, onChange }) => {
|
renderAction: ({ value, id, game, target, disabled, onChange }) => {
|
||||||
const choices = recipients(game, target), leaderChoices = leaders(game, target), lot = currentLot(game), partnerChoices = partners(game, target);
|
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 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 <>
|
return <>
|
||||||
<label htmlFor={`${id}-action`}>Future action</label>
|
<label htmlFor={`${id}-action`}>Future action</label>
|
||||||
<select id={`${id}-action`} value={value.kind} disabled={disabled} onChange={event => {
|
<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;
|
const minimum = 'minimum' in value ? value.minimum : 1;
|
||||||
if (kind === 'cardPlay') onChange({ kind, effect: 'hajr' });
|
if (kind === 'cardPlay') onChange({ kind, effect: 'hajr' });
|
||||||
if (kind === 'windowCardPlay') {
|
if (kind === 'windowCardPlay') {
|
||||||
const effect = windowCardOpportunity(game, 'amal') ? 'amal'
|
const effect = battleCancelOpportunity(game, target) ? 'karamaBattleCancel'
|
||||||
: WINDOW_CARD_EFFECTS.find(effect => windowCardOpportunity(game, effect)) ?? 'weather';
|
: windowCardOpportunity(game, 'amal') ? 'amal'
|
||||||
|
: WINDOW_CARD_EFFECTS.find(effect => effect !== 'karamaBattleCancel' && windowCardOpportunity(game, effect)) ?? 'weather';
|
||||||
onChange({ kind, effect, source: windowCardOpportunity(game, effect)?.source ?? '' });
|
onChange({ kind, effect, source: windowCardOpportunity(game, effect)?.source ?? '' });
|
||||||
}
|
}
|
||||||
if (kind === 'battlePower') {
|
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 : ''}
|
<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 => {
|
disabled={disabled} aria-invalid={!WINDOW_CARD_EFFECTS.includes(value.effect)} onChange={event => {
|
||||||
const effect = event.currentTarget.value;
|
const effect = event.currentTarget.value;
|
||||||
if (effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'thumper' || effect === 'amal')
|
if (effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' || effect === 'thumper' || effect === 'amal')
|
||||||
onChange({ kind: 'windowCardPlay', effect, source: windowCardOpportunity(game, effect)?.source ?? '' });
|
onChange({ kind: 'windowCardPlay', effect, source: (effect === 'karamaBattleCancel'
|
||||||
|
? battleCancelOpportunity(game, target) : windowCardOpportunity(game, effect))?.source ?? '' });
|
||||||
}}>
|
}}>
|
||||||
<option value="">Choose a printed role</option>
|
<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>
|
</select>
|
||||||
<label htmlFor={`${id}-window-source`}>Original native window</label>
|
<label htmlFor={`${id}-window-source`}>Original native window</label>
|
||||||
<select id={`${id}-window-source`} value={currentWindow?.source === value.source ? value.source : ''}
|
<select id={`${id}-window-source`} value={currentWindow?.source === value.source ? value.source : ''}
|
||||||
disabled={disabled} aria-invalid={!currentWindow || currentWindow.source !== value.source} onChange={event => {
|
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: '' });
|
if (event.currentTarget.value === '') onChange({ ...value, source: '' });
|
||||||
else if (opportunity?.source === event.currentTarget.value) onChange({ ...value, source: opportunity.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 === 'karamaAuction' ? `Turn ${currentWindow.turn} · Ordinary lot ${game.bidPromiseLot?.lot}`
|
||||||
: value.effect === 'thumper' ? `Turn ${currentWindow.turn} · Before the original first spice reveal`
|
: 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 === '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`
|
: 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>}
|
: `Turn ${currentWindow.turn} · Before this Storm movement`}</option>}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { gholaForcePreparations } from '@/game/ghola-force-preparation';
|
|||||||
import { richeseCardDefinition } from '@/game/richese-cards';
|
import { richeseCardDefinition } from '@/game/richese-cards';
|
||||||
import { nativeBattlePowerOpportunity, type NativeBattlePower } from '@/game/battle-power-question';
|
import { nativeBattlePowerOpportunity, type NativeBattlePower } from '@/game/battle-power-question';
|
||||||
import { battleCardLabel, VOICE_KINDS } from '@/game/battle-cards';
|
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 { TurnActionLeaf } from '@/game/turn-action-promises';
|
||||||
import type { MixedActionExpression } from '@/game/mixed-action-question';
|
import type { MixedActionExpression } from '@/game/mixed-action-question';
|
||||||
import { truthQuestionText, type TruthAnswer } from '@/game/truthtrance';
|
import { truthQuestionText, type TruthAnswer } from '@/game/truthtrance';
|
||||||
@@ -27,10 +27,11 @@ const publicBattleContext = (game: GameView) => game.battle && ({
|
|||||||
preparation: game.battle.preparation,
|
preparation: game.battle.preparation,
|
||||||
traitorVoters: game.battle.traitorVoters, traitorSubmitted: game.battle.traitorSubmitted,
|
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';
|
effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'thumper' || effect === 'amal';
|
||||||
const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester',
|
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 =>
|
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 === '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));
|
: 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 shipmentRoleNamed = !!windowPromise && !!currentShipmentWindow && namesWindow(windowPromise.mixed, 'karamaShipment', currentShipmentWindow.source);
|
||||||
const shipmentPriceAction = action.type === 'card' && action.mode === 'shipment';
|
const shipmentPriceAction = action.type === 'card' && action.mode === 'shipment';
|
||||||
const printedShipmentAction = shipmentPriceAction && shipmentRoleNamed;
|
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 printedInvoiceAction = action.type === 'decision' && action.karama === true && auctionRoleNamed;
|
||||||
const cardOwner = action.type === 'card' || printedInvoiceAction ? game.players.find(player => player.id === actor) : undefined;
|
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 heldCard = cardOwner?.hand?.find(card => card.id === action.card);
|
||||||
const cardSignature = heldCard && JSON.stringify({ id: heldCard.id, kind: heldCard.kind, effect: heldCard.effect });
|
const cardSignature = heldCard && JSON.stringify({ id: heldCard.id, kind: heldCard.kind, effect: heldCard.effect });
|
||||||
const windowEffect = (auctionPurchaseAction || printedInvoiceAction) && heldCard?.effect === windowCardNativeEffect('karamaAuction')
|
const windowEffect = (auctionPurchaseAction || printedInvoiceAction) && heldCard?.effect === windowCardNativeEffect('karamaAuction')
|
||||||
? 'karamaAuction' : printedShipmentAction && heldCard?.effect === windowCardNativeEffect('karamaShipment')
|
? '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 cardWindow = windowEffect ? windowCardOpportunity(game, windowEffect) : null;
|
||||||
const earlyWindowReadyAction = action.type === 'ready' && (phase === 0 || phase === 1);
|
const earlyWindowReadyAction = action.type === 'ready' && (phase === 0 || phase === 1);
|
||||||
const publicWindowState = (current: GameView) => JSON.stringify({
|
const publicWindowState = (current: GameView) => JSON.stringify({
|
||||||
@@ -173,6 +179,8 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
|||||||
const flightOwnerSignature = flightNeeded ? flightOwnerSource(game) : null;
|
const flightOwnerSignature = flightNeeded ? flightOwnerSource(game) : null;
|
||||||
const auctionCards = game.karamaAuctionCards, auctionCardsSignature = JSON.stringify(auctionCards);
|
const auctionCards = game.karamaAuctionCards, auctionCardsSignature = JSON.stringify(auctionCards);
|
||||||
const shipmentCards = game.karamaShipmentCards, shipmentCardsSignature = JSON.stringify(shipmentCards);
|
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 thumperCards = game.thumperCards, thumperCardsSignature = JSON.stringify(thumperCards);
|
||||||
const amalCards = game.amalCards, amalCardsSignature = JSON.stringify(amalCards);
|
const amalCards = game.amalCards, amalCardsSignature = JSON.stringify(amalCards);
|
||||||
const auctionHandSource = (current: GameView) => {
|
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))
|
? 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));
|
: action.elite === undefined ? forceChoices.length === 1 : forceChoices.some(choice => choice.elite === action.elite));
|
||||||
const windowPacket = Object.keys(action).every(key => ['type', 'card', 'turnActionPromiseEvent', 'turnActionPromisePhase',
|
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);
|
(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 &&
|
const printedWindowSource = ownPhysicalCard && !!heldCard && heldCard.kind === 'special' && !!windowEffect && !!cardWindow &&
|
||||||
heldCard.effect === windowCardNativeEffect(windowEffect) && !!windowPromise &&
|
heldCard.effect === windowCardNativeEffect(windowEffect) && !!windowPromise &&
|
||||||
@@ -252,6 +260,11 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
|||||||
phase === 5 && current.active === cardWindow.recipient && action.target === cardWindow.recipient &&
|
phase === 5 && current.active === cardWindow.recipient && action.target === cardWindow.recipient &&
|
||||||
Array.isArray(current.karamaShipmentCards) && current.karamaShipmentCards === shipmentCards &&
|
Array.isArray(current.karamaShipmentCards) && current.karamaShipmentCards === shipmentCards &&
|
||||||
JSON.stringify(current.karamaShipmentCards) === shipmentCardsSignature && current.karamaShipmentCards.includes(heldCard.id);
|
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 &&
|
const nativeThumperCard = windowEffect === 'thumper' && !!heldCard && phase === 1 && !current.spiceWindow && !current.nexus &&
|
||||||
Array.isArray(current.thumperCards) && current.thumperCards === thumperCards &&
|
Array.isArray(current.thumperCards) && current.thumperCards === thumperCards &&
|
||||||
JSON.stringify(current.thumperCards) === thumperCardsSignature && current.thumperCards.includes(heldCard.id);
|
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 &&
|
(windowEffect === 'karamaAuction' ? action.mode === 'purchase' && nativeAuctionCard && !!me && !current.decision &&
|
||||||
Number.isSafeInteger(me.handLimit) && Array.isArray(me.hand) && me.hand.length < me.handLimit
|
Number.isSafeInteger(me.handLimit) && Array.isArray(me.hand) && me.hand.length < me.handLimit
|
||||||
: windowEffect === 'karamaShipment' ? action.mode === 'shipment' && nativeShipmentCard
|
: windowEffect === 'karamaShipment' ? action.mode === 'shipment' && nativeShipmentCard
|
||||||
|
: windowEffect === 'karamaBattleCancel' ? action.mode === 'cancel' && nativeBattleCancelCard
|
||||||
: windowEffect === 'thumper' ? action.mode === undefined && nativeThumperCard
|
: windowEffect === 'thumper' ? action.mode === undefined && nativeThumperCard
|
||||||
: windowEffect === 'amal' ? action.mode === undefined && nativeAmalCard
|
: windowEffect === 'amal' ? action.mode === undefined && nativeAmalCard
|
||||||
: action.mode === undefined && ordinaryCardAvailability(current, heldCard.id, windowEffect === 'weather' ? { amount: action.amount } : undefined)?.available === true);
|
: 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 &&
|
!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) &&
|
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));
|
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) &&
|
(!boxRoleNamed || heldCard?.effect !== 'nullentropyBox' || printedBox) &&
|
||||||
(!shipmentPriceAction || (action.target ?? actor) === current.active) &&
|
(!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);
|
!!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 &&
|
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 &&
|
current.turnActionGuide === guide && guide.event === event && guide.action === action &&
|
||||||
action.turnActionPromiseEvent === event && action.turnActionPromisePhase === phase && JSON.stringify(action) === signature && opportunity && movementPacket &&
|
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 &&
|
current.turnActionPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
|
||||||
!promise.fulfilled && !promise.released) &&
|
!promise.fulfilled && !promise.released) &&
|
||||||
(!amalReadyAction || openingReady) &&
|
(!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.decision || nativeSemuta || paidBox || invoice || saphoDecision || earlyWindowBox && ownInvoiceSource) &&
|
||||||
(!current.phaseOpening || nativeSemuta || allowedOpeningAction) && (!current.automaticContinuationPending || nativeSemuta) && !current.roomControl?.paused && !current.roomControl?.closed;
|
(!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`
|
: `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 === '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 === '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 === '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 === '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'
|
: action.type === 'card' && heldCard?.effect === 'juiceOfSapho' ? `Play Juice of Sapho: ${action.scope === 'battleAggressor'
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ open mode gates.
|
|||||||
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
||||||
Prototype labels and final acceptance gates remain.
|
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.**
|
**Preview-start guidance correction — 27 October. Integrated / Partial.**
|
||||||
Setup, selected expansion rule topics and Richese card inspectors now describe
|
Setup, selected expansion rule topics and Richese card inspectors now describe
|
||||||
supported faction starts as unfinished previews instead of claiming that all
|
supported faction starts as unfinished previews instead of claiming that all
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Dune implementation status
|
# 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
|
## 27 October 2026 — Preview-start guidance correction
|
||||||
|
|
||||||
Setup, card inspection and selected faction/card rule guides now identify
|
Setup, card inspection and selected faction/card rule guides now identify
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Rule decision index
|
# 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
|
## Ambassador child arrivals during overlap — 27 October 2026
|
||||||
|
|
||||||
Provisionally finish a Fremen or Guild Ambassador-created child arrival before
|
Provisionally finish a Fremen or Guild Ambassador-created child arrival before
|
||||||
|
|||||||
@@ -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,
|
reset/deletion, server restart or deployed operation changes. Broader card,
|
||||||
recovery and module combinations and all final assurance/release gates remain
|
recovery and module combinations and all final assurance/release gates remain
|
||||||
open.
|
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.
|
||||||
+78
-8
@@ -10,7 +10,7 @@ import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay,
|
|||||||
import { battlePowerOf, battlePowerMatches, nativeBattlePowerOpportunity,
|
import { battlePowerOf, battlePowerMatches, nativeBattlePowerOpportunity,
|
||||||
type NativeBattlePower, type BattlePowerDeclaration } from './battle-power-question';
|
type NativeBattlePower, type BattlePowerDeclaration } from './battle-power-question';
|
||||||
import { WINDOW_CARD_EFFECTS, windowCardNativeEffect, windowCardOpportunity, karamaShipmentOpportunities,
|
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,
|
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
|
||||||
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, closeTurnActionPayments, finishTurnActionTurn,
|
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, closeTurnActionPayments, finishTurnActionTurn,
|
||||||
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival, recordTurnActionCardPlay,
|
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 &&
|
if (card.kind === 'special' && card.effect === 'karama' && policy?.player === p.id &&
|
||||||
policy.forbidden.some(required =>
|
policy.forbidden.some(required =>
|
||||||
(required.effect === 'karamaAuction' && purpose === 'auction-free-purchase' ||
|
(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))
|
windowCardOpportunity(g, required.effect)?.source === required.source))
|
||||||
return 'Retain this opportunity without a forbidden printed Karama cost.';
|
return 'Retain this opportunity without a forbidden printed Karama cost.';
|
||||||
const kullBlock = kullKaramaBlock(g, p, purpose);
|
const kullBlock = kullKaramaBlock(g, p, purpose);
|
||||||
@@ -17225,7 +17227,17 @@ function responseCancelCards(
|
|||||||
// A physically spendable card whose Truthtrance feasibility is not proven
|
// A physically spendable card whose Truthtrance feasibility is not proven
|
||||||
// remains a possible choice; the authoritative action still checks promises.
|
// remains a possible choice; the authoritative action still checks promises.
|
||||||
return p.hand
|
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);
|
.map((card) => card.id);
|
||||||
}
|
}
|
||||||
/** The same conversion can be suspended by an independent private interaction. */
|
/** 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')
|
if (shipmentWindow && card.kind === 'special' && card.effect === 'karama')
|
||||||
recordNativeTurnActionWindowCard(g, p.id, 'karamaShipment', shipmentWindow.source);
|
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);
|
const used = discard(g, p, card.id);
|
||||||
if (auctionWindow && auctionRows.some(row => row.windowCards?.karamaAuction?.source === auctionWindow))
|
if (auctionWindow && auctionRows.some(row => row.windowCards?.karamaAuction?.source === auctionWindow))
|
||||||
guardTurnActionAuctionClosure(g, p.id, auctionRows);
|
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; }
|
} catch (error) { if (error instanceof RuleError) return false; throw error; }
|
||||||
}).map(card => card.id);
|
}).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 {
|
function thumperReadyStartsDraw(g: Game): boolean {
|
||||||
return !!windowCardOpportunity(g, 'thumper') && !g.truthtrance && !g.phaseOpening && !g.response &&
|
return !!windowCardOpportunity(g, 'thumper') && !g.truthtrance && !g.phaseOpening && !g.response &&
|
||||||
!g.decision && !g.pendingTreacheryDiscard && !g.spiceWindow && (!g.nexus || !!g.summonedBeforeBlow);
|
!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> {
|
function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffect): Generator<Action> {
|
||||||
if (!windowCardOpportunity(g, effect)) return;
|
if (!windowCardOpportunity(g, effect)) return;
|
||||||
const player = getPlayer(g, actor);
|
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') {
|
if (effect === 'amal') {
|
||||||
for (const card of quotedAmalCards(g, player)) yield { type: 'card', card };
|
for (const card of quotedAmalCards(g, player)) yield { type: 'card', card };
|
||||||
return;
|
return;
|
||||||
@@ -20321,13 +20362,18 @@ function recordNativeTurnActionWindowCard(g: Game, actor: string, effect: Window
|
|||||||
}
|
}
|
||||||
function reconcileTurnActionWindowCards(g: Game) {
|
function reconcileTurnActionWindowCards(g: Game) {
|
||||||
if (!g.turnActionPromises) return;
|
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) {
|
for (const promise of g.turnActionPromises) {
|
||||||
if (promise.fulfilled || promise.released) continue;
|
if (promise.fulfilled || promise.released) continue;
|
||||||
for (const effect of WINDOW_CARD_EFFECTS) {
|
for (const effect of WINDOW_CARD_EFFECTS) {
|
||||||
const stage = promise.windowCards?.[effect];
|
const stage = promise.windowCards?.[effect];
|
||||||
const foreignInvoice = effect === 'karamaAuction' && g.decision?.kind === 'auctionPayment' &&
|
const foreignInvoice = effect === 'karamaAuction' && g.decision?.kind === 'auctionPayment' &&
|
||||||
g.decision.player !== promise.player;
|
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!,
|
g.turnActionPromises = turnActionRule(() => closeTurnActionWindowCardPlay(g.turnActionPromises!,
|
||||||
promise.player, promise.turn, effect, stage.source));
|
promise.player, promise.turn, effect, stage.source));
|
||||||
}
|
}
|
||||||
@@ -20483,10 +20529,12 @@ function guardTurnActionShipmentPriceDelivery(g: Game, shipment: PendingShipment
|
|||||||
trial.turnActionPromises = structuredClone(g.turnActionPromises);
|
trial.turnActionPromises = structuredClone(g.turnActionPromises);
|
||||||
guardTurnActionShipmentPriceBoundary(trial, shipment.player, original, undefined, receipt);
|
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;
|
const context = g.phaseOpening ? { ...g, phaseOpening: null } : g;
|
||||||
return turnActionPromiseTurn(g) && !bribeTimingBlock({ ...context, truthtrance: null,
|
const battleCancel = karamaBattleCancelOpportunity(context, actor);
|
||||||
inflation: context.inflation?.side === 'double' ? null : context.inflation });
|
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.
|
/** Whole native commitments retain the existing Semuta discard continuation.
|
||||||
* This does not expand the separate legacy bribe or Kull/module envelopes. */
|
* This does not expand the separate legacy bribe or Kull/module envelopes. */
|
||||||
@@ -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 ownedInvoice = g.decision?.kind === 'auctionPayment' && g.decision.player === player.id;
|
||||||
const saphoExtortion = g.decision?.kind === 'moritaniExtortion' &&
|
const saphoExtortion = g.decision?.kind === 'moritaniExtortion' &&
|
||||||
saphoOptions(g, player).some(option => option.scope === 'extortion');
|
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;
|
g.decision && !ownedBox && !ownedInvoice && !saphoExtortion) return null;
|
||||||
const completion = turnActionCompletion(g, player.id);
|
const completion = turnActionCompletion(g, player.id);
|
||||||
if (!completion) return null;
|
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) {
|
if (g.phaseOpening) {
|
||||||
const opportunity = windowCardOpportunity(g, 'amal');
|
const opportunity = windowCardOpportunity(g, 'amal');
|
||||||
const row = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row =>
|
const row = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row =>
|
||||||
@@ -40824,6 +40882,18 @@ function applyActionInner(
|
|||||||
!response.passed.includes(id),
|
!response.passed.includes(id),
|
||||||
'You have already passed this response window.',
|
'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);
|
response.passed.push(id);
|
||||||
} else {
|
} else {
|
||||||
requireRule(
|
requireRule(
|
||||||
|
|||||||
@@ -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 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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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
@@ -4,7 +4,7 @@ import { parseTravelActionClaim, type TravelClaim } from './travel-expression-pr
|
|||||||
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES,
|
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES,
|
||||||
turnActionPromiseModeSupported, type TurnActionClaim } from './turn-action-promises';
|
turnActionPromiseModeSupported, type TurnActionClaim } from './turn-action-promises';
|
||||||
import { battlePowerOf, nativeBattlePowerOpportunity } from './battle-power-question';
|
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 { parseTraitorActionClaim, type MixedTraitorClaim } from './mixed-traitor-question';
|
||||||
import { parseBattleActionClaim, MIXED_BATTLE_MAX_NODES, MIXED_BATTLE_MAX_TERMS, MIXED_BATTLE_MAX_DEPTH,
|
import { parseBattleActionClaim, MIXED_BATTLE_MAX_NODES, MIXED_BATTLE_MAX_TERMS, MIXED_BATTLE_MAX_DEPTH,
|
||||||
MIXED_BATTLE_MAX_LEAVES, type MixedBattleClaim } from './mixed-battle-question';
|
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 };
|
return { kind: 'bid', target: v.target, ...claim };
|
||||||
}
|
}
|
||||||
if (v.kind === 'turnActions') {
|
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 &&
|
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 &&
|
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.');
|
'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 respondent = g.players.find(player => player.id === v.target)!;
|
||||||
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), input => {
|
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), input => {
|
||||||
const action = parseTurnActionLeaf(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') {
|
if (action.kind === 'windowCardPlay') {
|
||||||
const opportunity = windowCardOpportunity(g, action.effect);
|
const opportunity = windowCardOpportunity(g, action.effect);
|
||||||
check(opportunity && opportunity.turn === g.turn && opportunity.source === action.source,
|
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,
|
if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
|
||||||
action => {
|
action => {
|
||||||
if (action.kind === 'windowCardPlay') {
|
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 === '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 === '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') {
|
if (action.effect === 'karamaShipment') {
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { harvesterAvailable } from './cards';
|
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 type WindowCardEffect = typeof WINDOW_CARD_EFFECTS[number];
|
||||||
export function windowCardNativeEffect(effect: WindowCardEffect): 'weather' | 'atomics' | 'harvester' | 'karama' | 'thumper' | 'amal' {
|
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 KaramaShipmentOpportunity = { effect: 'karamaShipment'; source: string; turn: number; recipient: string };
|
||||||
export type KaramaShipmentSource = { turn: number; recipient: string; kind: 'original' } |
|
export type KaramaShipmentSource = { turn: number; recipient: string; kind: 'original' } |
|
||||||
{ turn: number; recipient: string; kind: 'cunning'; event: string };
|
{ turn: number; recipient: string; kind: 'cunning'; event: string };
|
||||||
@@ -12,6 +19,7 @@ export type WindowCardOpportunity = { effect: Exclude<WindowCardEffect, 'karamaS
|
|||||||
KaramaShipmentOpportunity;
|
KaramaShipmentOpportunity;
|
||||||
export type WindowCardGame = {
|
export type WindowCardGame = {
|
||||||
status: string;
|
status: string;
|
||||||
|
advanced?: boolean;
|
||||||
phase: number;
|
phase: number;
|
||||||
turn: number;
|
turn: number;
|
||||||
shieldWallDestroyed: boolean;
|
shieldWallDestroyed: boolean;
|
||||||
@@ -31,6 +39,8 @@ export type WindowCardGame = {
|
|||||||
harvesterClosed?: boolean;
|
harvesterClosed?: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
response?: unknown;
|
response?: unknown;
|
||||||
|
/** Public battle identity; plans and card custody are deliberately omitted. */
|
||||||
|
battle?: { event?: string | null; territory: string; revealed: boolean } | null;
|
||||||
decision?: unknown;
|
decision?: unknown;
|
||||||
phaseOpening?: unknown;
|
phaseOpening?: unknown;
|
||||||
truthtrance?: 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;
|
? { 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'
|
/** Every still-unused original entitlement remains bound through other actors'
|
||||||
* turns. A declared Cunning continuation permanently retires its original one. */
|
* turns. A declared Cunning continuation permanently retires its original one. */
|
||||||
export function karamaShipmentOpportunities(game: WindowCardGame): KaramaShipmentOpportunity[] {
|
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. */
|
* force conditions and chosen effect parameters remain native engine rules. */
|
||||||
function globalWindowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null {
|
function globalWindowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null {
|
||||||
if (game.status !== 'playing' || !whole(game.turn) || game.turn < 1) return 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 (effect === 'amal') {
|
||||||
if (!whole(game.phase) || game.phase > 8 || game.amalWindowOpen !== undefined && typeof game.amalWindowOpen !== 'boolean' ||
|
if (!whole(game.phase) || game.phase > 8 || game.amalWindowOpen !== undefined && typeof game.amalWindowOpen !== 'boolean' ||
|
||||||
game.amalWindowOpen === false) return null;
|
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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user