Prototype Tleilaxu special Karama Truthtrance
Verify and publish checkpoint container / container (push) Successful in 11m6s
Verify and publish checkpoint container / container (push) Successful in 11m6s
This commit is contained in:
1 parent
5b8d510470
commit
cfccfe5acb
15 files changed
+391
-20
No files matched your search
@@ -9,7 +9,7 @@ import { parseBattleActionClaim } from '@/game/mixed-battle-question';
|
||||
import { battlePowerOf, nativeBattlePowerOpportunity, type BattlePowerClaim } from '@/game/battle-power-question';
|
||||
import { battleCardLabel, voiceChoices } from '@/game/battle-cards';
|
||||
import { nativeStormDialOpportunity } from '@/game/storm-dial-question';
|
||||
import { karamaBattleCancelOpportunity, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, parseKaramaAtreidesSpecialSource, parseKaramaFremenSpecialSource, WINDOW_CARD_EFFECTS, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
|
||||
import { karamaBattleCancelOpportunity, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, parseKaramaAtreidesSpecialSource, parseKaramaFremenSpecialSource, parseKaramaTleilaxuSpecialSource, WINDOW_CARD_EFFECTS, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
|
||||
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES, type TurnActionLeaf } from '@/game/turn-action-promises';
|
||||
import { BattleClaimFields } from './battle-promises';
|
||||
import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields';
|
||||
@@ -59,6 +59,7 @@ const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Co
|
||||
karamaEmperorSpecial: 'Use Emperor’s special Karama during Revival',
|
||||
karamaAtreidesSpecial: 'Use Atreides’ special Karama in this battle',
|
||||
karamaFremenSpecial: 'Use Fremen’s special Karama during Spice Blow',
|
||||
karamaTleilaxuSpecial: 'Use Tleilaxu’s special Karama during Revival',
|
||||
thumper: 'Play Thumper before this turn’s first spice reveal', amal: 'Play Amal in this original phase opening' };
|
||||
const battleCancelOpportunity = karamaBattleCancelOpportunity;
|
||||
/** Harkonnen is public; do not inspect that player's hand or target's cards. */
|
||||
@@ -73,6 +74,8 @@ const windowOpportunity = (game: GameView, target: string, effect: WindowCardEff
|
||||
(!opportunity || parseKaramaAtreidesSpecialSource(opportunity.source)?.owner !== target)) return null;
|
||||
if (effect === 'karamaFremenSpecial' &&
|
||||
(!opportunity || parseKaramaFremenSpecialSource(opportunity.source)?.owner !== target)) return null;
|
||||
if (effect === 'karamaTleilaxuSpecial' &&
|
||||
(!opportunity || parseKaramaTleilaxuSpecialSource(opportunity.source)?.owner !== target)) return null;
|
||||
return opportunity;
|
||||
};
|
||||
const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
@@ -278,11 +281,11 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
<select id={`${id}-window-effect`} value={WINDOW_CARD_EFFECTS.includes(value.effect) ? value.effect : ''}
|
||||
disabled={disabled} aria-invalid={!WINDOW_CARD_EFFECTS.includes(value.effect)} onChange={event => {
|
||||
const effect = event.currentTarget.value;
|
||||
if (effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' || effect === 'karamaHarkonnenSpecial' || effect === 'karamaEmperorSpecial' || effect === 'karamaAtreidesSpecial' || effect === 'karamaFremenSpecial' || effect === 'thumper' || effect === 'amal')
|
||||
if (effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' || effect === 'karamaHarkonnenSpecial' || effect === 'karamaEmperorSpecial' || effect === 'karamaAtreidesSpecial' || effect === 'karamaFremenSpecial' || effect === 'karamaTleilaxuSpecial' || effect === 'thumper' || effect === 'amal')
|
||||
onChange({ kind: 'windowCardPlay', effect, source: windowOpportunity(game, target, effect)?.source ?? '' });
|
||||
}}>
|
||||
<option value="">Choose a printed role</option>
|
||||
{WINDOW_CARD_EFFECTS.filter(effect => (effect !== 'karamaBattleCancel' && effect !== 'karamaHarkonnenSpecial' && effect !== 'karamaEmperorSpecial' && effect !== 'karamaAtreidesSpecial' && effect !== 'karamaFremenSpecial') || !!windowOpportunity(game, target, effect) || value.effect === effect)
|
||||
{WINDOW_CARD_EFFECTS.filter(effect => (effect !== 'karamaBattleCancel' && effect !== 'karamaHarkonnenSpecial' && effect !== 'karamaEmperorSpecial' && effect !== 'karamaAtreidesSpecial' && effect !== 'karamaFremenSpecial' && effect !== 'karamaTleilaxuSpecial') || !!windowOpportunity(game, target, effect) || value.effect === effect)
|
||||
.map(effect => <option key={effect} value={effect}>{windowRoleNames[effect]}</option>)}
|
||||
</select>
|
||||
<label htmlFor={`${id}-window-source`}>Original native window</label>
|
||||
@@ -302,6 +305,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
: value.effect === 'karamaEmperorSpecial' ? `Turn ${currentWindow.turn} · Emperor special Karama during Revival`
|
||||
: value.effect === 'karamaAtreidesSpecial' ? `Turn ${currentWindow.turn} · Atreides special Karama in this battle`
|
||||
: value.effect === 'karamaFremenSpecial' ? `Turn ${currentWindow.turn} · Fremen special Karama during Spice Blow`
|
||||
: value.effect === 'karamaTleilaxuSpecial' ? `Turn ${currentWindow.turn} · Tleilaxu special Karama during Revival`
|
||||
: value.effect === 'karamaBattleCancel' ? `Turn ${currentWindow.turn} · ${game.response?.kind === 'voice' ? 'Voice' : 'Prescience'} declared by ${game.players.find(player => player.id === game.response?.owner)?.name ?? game.response?.owner} · ${game.combatLocations.find(location => location.id === game.battle?.territory)?.name ?? game.battle?.territory}`
|
||||
: currentWindow.effect === 'karamaShipment' ? `Turn ${currentWindow.turn} · ${game.players.find(player => player.id === currentWindow.recipient)?.name ?? currentWindow.recipient}’s current native shipment price`
|
||||
: `Turn ${currentWindow.turn} · Before this Storm movement`}</option>}
|
||||
|
||||
@@ -29,12 +29,12 @@ const publicBattleContext = (game: GameView) => game.battle && ({
|
||||
preparation: game.battle.preparation,
|
||||
traitorVoters: game.battle.traitorVoters, traitorSubmitted: game.battle.traitorSubmitted,
|
||||
});
|
||||
const isWindowEffect = (effect: unknown): effect is Exclude<WindowCardEffect, 'karamaAuction' | 'karamaShipment' | 'karamaBattleCancel' | 'karamaHarkonnenSpecial' | 'karamaEmperorSpecial' | 'karamaAtreidesSpecial' | 'karamaFremenSpecial'> =>
|
||||
const isWindowEffect = (effect: unknown): effect is Exclude<WindowCardEffect, 'karamaAuction' | 'karamaShipment' | 'karamaBattleCancel' | 'karamaHarkonnenSpecial' | 'karamaEmperorSpecial' | 'karamaAtreidesSpecial' | 'karamaFremenSpecial' | 'karamaTleilaxuSpecial'> =>
|
||||
effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'thumper' || effect === 'amal';
|
||||
const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester',
|
||||
karamaAuction: 'printed Karama for this ordinary lot', karamaShipment: 'printed Karama for this shipment price',
|
||||
karamaBattleCancel: 'printed Karama for this battle response',
|
||||
karamaHarkonnenSpecial: 'Harkonnen special Karama', karamaEmperorSpecial: 'Emperor special Karama', karamaAtreidesSpecial: 'Atreides special Karama', karamaFremenSpecial: 'Fremen special Karama', thumper: 'Thumper', amal: 'Amal' };
|
||||
karamaHarkonnenSpecial: 'Harkonnen special Karama', karamaEmperorSpecial: 'Emperor special Karama', karamaAtreidesSpecial: 'Atreides special Karama', karamaFremenSpecial: 'Fremen special Karama', karamaTleilaxuSpecial: 'Tleilaxu special Karama', thumper: 'Thumper', amal: 'Amal' };
|
||||
const namesWindow = (mixed: MixedActionExpression<TurnActionLeaf>, effect: WindowCardEffect, source?: string): boolean =>
|
||||
mixed.kind === 'action' ? mixed.action.kind === 'windowCardPlay' && mixed.action.effect === effect && (source === undefined || mixed.action.source === source)
|
||||
: mixed.kind !== 'fact' && mixed.terms.some(term => namesWindow(term, effect, source));
|
||||
@@ -141,6 +141,10 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
const fremenRoleNamed = !!windowPromise && !!currentFremenWindow &&
|
||||
namesWindow(windowPromise.mixed, 'karamaFremenSpecial', currentFremenWindow.source);
|
||||
const printedFremenAction = action.type === 'card' && action.mode === 'special' && fremenRoleNamed;
|
||||
const currentTleilaxuWindow = windowCardOpportunity(game, 'karamaTleilaxuSpecial');
|
||||
const tleilaxuRoleNamed = !!windowPromise && !!currentTleilaxuWindow &&
|
||||
namesWindow(windowPromise.mixed, 'karamaTleilaxuSpecial', currentTleilaxuWindow.source);
|
||||
const printedTleilaxuAction = action.type === 'card' && action.mode === 'special' && tleilaxuRoleNamed;
|
||||
const printedInvoiceAction = action.type === 'decision' && action.karama === true && auctionRoleNamed;
|
||||
const cardOwner = action.type === 'card' || printedInvoiceAction ? game.players.find(player => player.id === actor) : undefined;
|
||||
const heldCard = cardOwner?.hand?.find(card => card.id === action.card);
|
||||
@@ -152,7 +156,8 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
? 'karamaHarkonnenSpecial' : printedEmperorAction && heldCard?.effect === windowCardNativeEffect('karamaEmperorSpecial')
|
||||
? 'karamaEmperorSpecial' : printedAtreidesAction && heldCard?.effect === windowCardNativeEffect('karamaAtreidesSpecial')
|
||||
? 'karamaAtreidesSpecial' : printedFremenAction && heldCard?.effect === windowCardNativeEffect('karamaFremenSpecial')
|
||||
? 'karamaFremenSpecial' : isWindowEffect(heldCard?.effect) ? heldCard.effect : null;
|
||||
? 'karamaFremenSpecial' : printedTleilaxuAction && heldCard?.effect === windowCardNativeEffect('karamaTleilaxuSpecial')
|
||||
? 'karamaTleilaxuSpecial' : isWindowEffect(heldCard?.effect) ? heldCard.effect : null;
|
||||
const cardWindow = windowEffect ? windowCardOpportunity(game, windowEffect) : null;
|
||||
const earlyWindowReadyAction = action.type === 'ready' && (phase === 0 || phase === 1);
|
||||
const publicWindowState = (current: GameView) => JSON.stringify({
|
||||
@@ -230,6 +235,8 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
revival: current.revival, used: owner?.specialKaramaUsed });
|
||||
};
|
||||
const emperorRevivalSignature = printedEmperorAction ? emperorRevivalSource(game) : null;
|
||||
const tleilaxuQuote = printedTleilaxuAction ? game.tleilaxuSpecialKarama : null;
|
||||
const tleilaxuSignature = JSON.stringify(tleilaxuQuote);
|
||||
const battleCancelControls = game.responseControls, battleCancelControlsSignature = JSON.stringify(battleCancelControls);
|
||||
const battleCancelResponse = game.response, battleCancelResponseSignature = JSON.stringify(battleCancelResponse);
|
||||
const thumperCards = game.thumperCards, thumperCardsSignature = JSON.stringify(thumperCards);
|
||||
@@ -296,7 +303,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
? typeof action.leader === 'string' && (action.leader === 'kwisatz' ? ghola.kwisatz : ghola.leaders.some(leader => leader.id === action.leader))
|
||||
: action.elite === undefined ? forceChoices.length === 1 : forceChoices.some(choice => choice.elite === action.elite));
|
||||
const windowPacket = Object.keys(action).every(key => ['type', 'card', 'turnActionPromiseEvent', 'turnActionPromisePhase',
|
||||
...(windowEffect === 'weather' ? ['amount'] : windowEffect === 'karamaAuction' || windowEffect === 'karamaBattleCancel' ? ['mode'] : windowEffect === 'karamaShipment' ? ['mode', 'target'] : windowEffect === 'karamaHarkonnenSpecial' ? ['mode', 'target', 'amount'] : windowEffect === 'karamaEmperorSpecial' ? ['mode', 'leader', 'amount', 'elite'] : windowEffect === 'karamaAtreidesSpecial' ? ['mode', 'target'] : windowEffect === 'karamaFremenSpecial' ? ['mode', 'territory'] : [])].includes(key)) &&
|
||||
...(windowEffect === 'weather' ? ['amount'] : windowEffect === 'karamaAuction' || windowEffect === 'karamaBattleCancel' ? ['mode'] : windowEffect === 'karamaShipment' ? ['mode', 'target'] : windowEffect === 'karamaHarkonnenSpecial' ? ['mode', 'target', 'amount'] : windowEffect === 'karamaEmperorSpecial' ? ['mode', 'leader', 'amount', 'elite'] : windowEffect === 'karamaAtreidesSpecial' || windowEffect === 'karamaTleilaxuSpecial' ? ['mode', 'target'] : windowEffect === 'karamaFremenSpecial' ? ['mode', 'territory'] : [])].includes(key)) &&
|
||||
(windowEffect !== 'weather' || typeof action.amount === 'number' && Number.isSafeInteger(action.amount) && action.amount >= 0 && action.amount <= 10);
|
||||
const printedWindowSource = ownPhysicalCard && !!heldCard && heldCard.kind === 'special' && !!windowEffect && !!cardWindow &&
|
||||
heldCard.effect === windowCardNativeEffect(windowEffect) && !!windowPromise &&
|
||||
@@ -346,6 +353,12 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
Object.entries(current.spice).every(([key, amount]) => !key.startsWith(`${fremenTerritory}:`) || amount <= 0) &&
|
||||
current.players.every(player => Object.entries(player.forces).every(([key, amount]) =>
|
||||
!key.startsWith(`${fremenTerritory}:`) || amount <= 0));
|
||||
const nativeTleilaxuCard = windowEffect === 'karamaTleilaxuSpecial' && !!me && me.faction === 'tleilaxu' &&
|
||||
me.specialKaramaUsed === false && phase === 4 && action.mode === 'special' && typeof action.target === 'string' &&
|
||||
!!heldCard && !!tleilaxuQuote && current.tleilaxuSpecialKarama === tleilaxuQuote &&
|
||||
JSON.stringify(current.tleilaxuSpecialKarama) === tleilaxuSignature && tleilaxuQuote.blocked === null &&
|
||||
tleilaxuQuote.cards.includes(heldCard.id) && tleilaxuQuote.targets.some(target => target.player === action.target && target.blocked === null) &&
|
||||
(!tleilaxuQuote.pending || current.decision?.kind === 'revivalStop' && current.decision.player === actor);
|
||||
const nativeThumperCard = windowEffect === 'thumper' && !!heldCard && phase === 1 && !current.spiceWindow && !current.nexus &&
|
||||
Array.isArray(current.thumperCards) && current.thumperCards === thumperCards &&
|
||||
JSON.stringify(current.thumperCards) === thumperCardsSignature && current.thumperCards.includes(heldCard.id);
|
||||
@@ -363,6 +376,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
: windowEffect === 'karamaEmperorSpecial' ? nativeEmperorCard
|
||||
: windowEffect === 'karamaAtreidesSpecial' ? nativeAtreidesCard
|
||||
: windowEffect === 'karamaFremenSpecial' ? nativeFremenCard
|
||||
: windowEffect === 'karamaTleilaxuSpecial' ? nativeTleilaxuCard
|
||||
: windowEffect === 'thumper' ? action.mode === undefined && nativeThumperCard
|
||||
: windowEffect === 'amal' ? action.mode === undefined && nativeAmalCard
|
||||
: action.mode === undefined && ordinaryCardAvailability(current, heldCard.id, windowEffect === 'weather' ? { amount: action.amount } : undefined)?.available === true);
|
||||
@@ -409,7 +423,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
!battle.revealed && [battle.attacker, battle.defender].includes(actor) && action.event === battle.event && action.event === residual.event &&
|
||||
action.target === residual.target && action.target === (battle.attacker === actor ? battle.defender : battle.attacker) &&
|
||||
Object.keys(action).every(key => ['type', 'card', 'event', 'target', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key));
|
||||
const genericCard = ownCard && action.type === 'card' && !auctionPurchaseAction && !printedShipmentAction && !printedBattleCancelAction && !printedHarkonnenAction && !printedEmperorAction && !printedAtreidesAction && !printedFremenAction && quotedBoxCard &&
|
||||
const genericCard = ownCard && action.type === 'card' && !auctionPurchaseAction && !printedShipmentAction && !printedBattleCancelAction && !printedHarkonnenAction && !printedEmperorAction && !printedAtreidesAction && !printedFremenAction && !printedTleilaxuAction && quotedBoxCard &&
|
||||
(!boxRoleNamed || heldCard?.effect !== 'nullentropyBox' || printedBox) &&
|
||||
(!shipmentPriceAction || (action.target ?? actor) === current.active) &&
|
||||
!!heldCard && heldCard.effect !== 'ghola' && heldCard.effect !== 'hajr' && heldCard.effect !== 'distrans' && heldCard.effect !== 'juiceOfSapho' && heldCard.effect !== 'semutaDrug' && heldCard.effect !== 'ornithopter' && heldCard.effect !== 'residualPoison' && !isWindowEffect(heldCard.effect);
|
||||
@@ -524,13 +538,14 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
Number.isSafeInteger(phase) && phase >= 0 && phase <= 8 && current.phase === phase && current.turn === turn && current.me === actor &&
|
||||
current.turnActionGuide === guide && guide.event === event && guide.action === action &&
|
||||
action.turnActionPromiseEvent === event && action.turnActionPromisePhase === phase && JSON.stringify(action) === signature && opportunity && movementPacket &&
|
||||
(!printedBattleCancelAction || printedWindow) && (!printedHarkonnenAction || printedWindow) && (!printedEmperorAction || printedWindow) && (!printedAtreidesAction || printedWindow) && (!sectorShipmentGuide || currentSectorShipment) &&
|
||||
(!printedBattleCancelAction || printedWindow) && (!printedHarkonnenAction || printedWindow) && (!printedEmperorAction || printedWindow) && (!printedAtreidesAction || printedWindow) && (!printedTleilaxuAction || printedWindow) && (!sectorShipmentGuide || currentSectorShipment) &&
|
||||
current.turnActionPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
|
||||
!promise.fulfilled && !promise.released) &&
|
||||
(!amalReadyAction || openingReady) &&
|
||||
!!me && !me.autopilot && (!current.semutaReaction || nativeSemuta) && (!current.truthtrance || nativeSemuta) &&
|
||||
(!current.response || nativeSemuta || windowEffect === 'karamaBattleCancel' && printedWindow) &&
|
||||
(!current.decision || nativeSemuta || paidBox || invoice || saphoDecision || earlyWindowBox && ownInvoiceSource) &&
|
||||
(!current.decision || nativeSemuta || paidBox || invoice || saphoDecision || earlyWindowBox && ownInvoiceSource ||
|
||||
windowEffect === 'karamaTleilaxuSpecial' && printedWindow && tleilaxuQuote?.pending && current.decision.kind === 'revivalStop' && current.decision.player === actor) &&
|
||||
(!current.phaseOpening || nativeSemuta || allowedOpeningAction) && (!current.automaticContinuationPending || nativeSemuta) && !current.roomControl?.paused && !current.roomControl?.closed;
|
||||
};
|
||||
const amount = action.forces && typeof action.forces === 'object'
|
||||
@@ -561,6 +576,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
: action.type === 'card' && windowEffect === 'karamaEmperorSpecial' ? `Use Emperor’s special Karama to revive ${action.leader ? 'the selected leader' : `${amount} forces`} while honoring your whole answer`
|
||||
: action.type === 'card' && windowEffect === 'karamaAtreidesSpecial' ? `Use Atreides’ special Karama to inspect ${recipient}’s battle plan while honoring your whole answer`
|
||||
: action.type === 'card' && windowEffect === 'karamaFremenSpecial' ? `Use Fremen’s special Karama to summon Shai-Hulud in empty ${destination} while honoring your whole answer`
|
||||
: action.type === 'card' && windowEffect === 'karamaTleilaxuSpecial' ? `Use Tleilaxu’s special Karama to prevent ${recipient}’s Revival while honoring your whole answer`
|
||||
: action.type === 'card' && heldCard?.effect === 'nullentropyBox' ? 'Pay 2 spice to activate Nullentropy Box while honoring your whole answer'
|
||||
: action.type === 'card' && heldCard?.effect === 'distrans' ? `Give ${distrans?.choices.find(choice => choice.recipient === action.target)?.cards.find(card => card.id === action.give)?.name ?? 'the quoted card'} to ${recipient} with Distrans while honoring your whole answer`
|
||||
: action.type === 'card' && heldCard?.effect === 'juiceOfSapho' ? `Play Juice of Sapho: ${action.scope === 'battleAggressor'
|
||||
|
||||
@@ -17,6 +17,15 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Tleilaxu special Karama Truthtrance — 29 October. Prototyped / Partial.**
|
||||
In clean Advanced Revival, a whole-turn question can bind the actual physical
|
||||
Tleilaxu Karama use and its native target-specific prevention. The guide uses
|
||||
the current card and target quote; a conflicting promise to revive prevents
|
||||
self-targeting. Six related files pass 223 cases, with a conserved Truthtrance
|
||||
card staged for the runtime fixture. Kull, Semuta, interrupted revival windows
|
||||
and deployed verification remain open.
|
||||
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#tleilaxu-special-karama-during-revival--29-october-2026).
|
||||
|
||||
**Fremen special Karama Truthtrance — 29 October. Prototyped / Partial.**
|
||||
In a clean Advanced classic pre-blow window, a whole-turn question can bind
|
||||
Fremen's actual special Karama worm. A detached empty-sand witness executes the
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# Truthtrance combined current-turn actions
|
||||
|
||||
## Tleilaxu special Karama during Revival — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** A whole-turn question may name Tleilaxu's
|
||||
once-per-game special Karama in a quiet Advanced Revival window. Its public
|
||||
source identifies the turn and Tleilaxu owner, without selecting a prevention
|
||||
target. A qualifying Yes requires a successful future native special use of
|
||||
an original physical printed Karama after the answer; No forbids that role
|
||||
while the original source remains open. Earlier plays, ordinary Karama roles,
|
||||
and failed or prevented activations do not count.
|
||||
|
||||
The native action chooses the actual player whose remaining force and leader
|
||||
revivals are prevented, including Tleilaxu itself. Its existing physical cost,
|
||||
prevention and phase expiry remain authoritative; previously completed returns
|
||||
and their payments are not undone. The bounded initial completion witness
|
||||
uses the quiet Revival action. Kull, Semuta, pending revival decisions and
|
||||
other interruptions are outside this Truthtrance profile. This is a provisional
|
||||
online composition of the existing rules, not a separate publisher ruling.
|
||||
|
||||
The runtime fixture reaches Advanced Ix Revival through native actions. It
|
||||
stages an original Truthtrance in the asker's hand by exchanging physical cards
|
||||
with an existing card zone; natural acquisition is not claimed. Six related
|
||||
files pass 223 cases, including a conflicting self-targeted revival promise.
|
||||
Broader interactions, saved-game assurance and deployed verification remain open.
|
||||
|
||||
## Fremen special Karama before Spice Blow — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** In a clean Advanced classic
|
||||
|
||||
+23
-3
@@ -19003,7 +19003,8 @@ function karamaSpendingBlock(g: Game, p: Player, card: Card, purpose?: KullPurpo
|
||||
required.effect === 'karamaHarkonnenSpecial' && purpose === 'special:harkonnen' ||
|
||||
required.effect === 'karamaEmperorSpecial' && purpose === 'special:emperor' ||
|
||||
required.effect === 'karamaAtreidesSpecial' && purpose === 'special:atreides' ||
|
||||
required.effect === 'karamaFremenSpecial' && purpose === 'special:fremen') &&
|
||||
required.effect === 'karamaFremenSpecial' && purpose === 'special:fremen' ||
|
||||
required.effect === 'karamaTleilaxuSpecial' && purpose === 'special:tleilaxu') &&
|
||||
windowCardOpportunity(g, required.effect)?.source === required.source))
|
||||
return 'Retain this opportunity without a forbidden printed Karama cost.';
|
||||
const kullBlock = kullKaramaBlock(g, p, purpose);
|
||||
@@ -20495,7 +20496,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
|
||||
// still-open Harkonnen special promise before a foreign response can park
|
||||
// the shared reconciler.
|
||||
if (card.kind === 'special' && card.effect === 'karama' && auctionRows.some(row => {
|
||||
return (['karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial', 'karamaFremenSpecial'] as const).some(effect => {
|
||||
return (['karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial', 'karamaFremenSpecial', 'karamaTleilaxuSpecial'] as const).some(effect => {
|
||||
const stage = row.windowCards?.[effect];
|
||||
return stage && !stage.closed && windowCardOpportunity(g, effect, stage.source)?.source === stage.source;
|
||||
});
|
||||
@@ -22395,6 +22396,19 @@ function emptyFremenSpecialWormTerritory(g: Game, id: string): boolean {
|
||||
function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffect): Generator<Action> {
|
||||
if (!windowCardOpportunity(g, effect)) return;
|
||||
const player = getPlayer(g, actor);
|
||||
if (effect === 'karamaTleilaxuSpecial') {
|
||||
if (player.faction !== 'tleilaxu' || g.kullPreview || g.pendingKull || g.semutaPreview ||
|
||||
g.semutaRecruitsPreview || g.semutaAdvancedOrdinaryPreview || g.truthtrance || g.response ||
|
||||
g.decision || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard ||
|
||||
g.pendingNullentropy || g.pendingRevival) return;
|
||||
for (const card of player.hand.filter(card => card.kind === 'special' && card.effect === 'karama'))
|
||||
for (const target of g.players) {
|
||||
const action: Action = { type: 'card', mode: 'special', card: card.id, target: target.id };
|
||||
try { if (prepareSpecialKaramaIntent(g, actor, action).kind === 'tleilaxu') yield action; }
|
||||
catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (effect === 'karamaFremenSpecial') {
|
||||
if (player.faction !== 'fremen' || g.expansions.length || g.nexusCards || g.homeworlds || g.discoveryEnabled ||
|
||||
g.kullPreview || g.pendingKull || g.semutaPreview || g.semutaRecruitsPreview || g.semutaAdvancedOrdinaryPreview ||
|
||||
@@ -23808,7 +23822,8 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
|
||||
? player.faction === 'harkonnen' ? 'karamaHarkonnenSpecial'
|
||||
: player.faction === 'emperor' ? 'karamaEmperorSpecial'
|
||||
: player.faction === 'atreides' ? 'karamaAtreidesSpecial'
|
||||
: player.faction === 'fremen' ? 'karamaFremenSpecial' : null
|
||||
: player.faction === 'fremen' ? 'karamaFremenSpecial'
|
||||
: player.faction === 'tleilaxu' ? 'karamaTleilaxuSpecial' : null
|
||||
: isWindowCardEffect(held?.effect) ? held.effect : null;
|
||||
const windows: WindowCardEffect[] = printedWindow ? [printedWindow]
|
||||
: action.type === 'decision' && action.karama === true ? ['karamaAuction']
|
||||
@@ -42774,7 +42789,12 @@ function resolveSpecialKaramaEffect(g: Game, p: Player, card: Card, intent: Spec
|
||||
});
|
||||
} else if (intent.kind === 'tleilaxu') {
|
||||
const target = intent.target;
|
||||
const opportunity = windowCardOpportunity(g, 'karamaTleilaxuSpecial');
|
||||
if (opportunity && card.kind === 'special' && card.effect === 'karama')
|
||||
recordNativeTurnActionWindowCard(g, p.id, 'karamaTleilaxuSpecial', opportunity.source);
|
||||
p.specialKaramaUsed = true;
|
||||
if (liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length)
|
||||
guardTurnActionPrintedCardCost(g, p, card);
|
||||
g.revivalPrevention = { player: target, turn: g.turn };
|
||||
if (g.pendingRevival?.nexusEmperorEvent)
|
||||
finishNexusEmperorRevival(g, g.pendingRevival, 'denied');
|
||||
|
||||
@@ -3366,6 +3366,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A current-lot Karama condition counts a future physical printed Karama cost for this original ordinary auction card, either immediate purchase or its winning invoice. Bidding alone, another Karama role, a converted Worthless card, an earlier play, recovery or a later lot does not count. A bid-and-Karama answer must retain the real bid before the purchase; free acquisition still uses the original native controls and hand limit. A whole No shares its payment and bid funding without assuming a forbidden free invoice. Unseen future auction cards cannot fund another promised card play; actual owned knowledge remains usable.',
|
||||
'In a quiet Advanced Bidding window, a whole answer can name Harkonnen’s successful once-game special Karama. The original owner and Bidding turn bind the interval across lots. Only a future actual physical printed Karama used for that special counts; ordinary roles and failed use do not. No and native hand-exchange timing remain authoritative. The shared witness returns unseen drawn cards without sampling their faces; Kull, Semuta and other faction special powers remain outside this prototype.',
|
||||
'A separate Emperor special-Karama condition covers quiet Advanced Revival. It names an actual physical printed Karama activation and its real free leader or force return, preserving the existing return-before-cost order. Kull, Semuta and broader interactions remain outside the bounded profile.',
|
||||
'A separate Tleilaxu special-Karama condition covers quiet Advanced Revival. Its source names the owner and turn; a successful future special use spends the original physical printed Karama and prevents the chosen player’s remaining force and leader revivals, including Tleilaxu itself. Kull, Semuta, pending revival decisions and other interruptions are outside this bounded profile. Runtime coverage uses a conserved Truthtrance card swap.',
|
||||
'A separate Atreides special-Karama condition covers a clean Advanced battle before plans reveal. It binds the original public battle, and the private witness pays the real card without reading an opposing sealed plan. Actual inspection and target-first sequencing remain native; Kull, Semuta and broader interactions remain outside the bounded profile. Guides select the actual special-use promise even when another answer was recorded first.',
|
||||
'A Fremen special-Karama condition covers clean Advanced classic pre-blow. Its owned witness executes an empty sand appearance without spice; standalone Yes may choose a native destination, while populated destinations remain guarded with other commitments. Kull, Semuta and other modules remain excluded.',
|
||||
'A printed Karama shipment-price clause names the current beneficiary’s unused native shipment. The giver spends the physical printed card; the beneficiary still pays the native bank tariff, such as two spice becoming one. Playing that cost counts even if its price is unused. The source remains while that original recipient is entitled, including another actor’s turn; a different beneficiary or a Cunning second opportunity has its own interval. Own shipping or ending must preserve the whole answer before a foreign counter, while foreign actions retain their rights.',
|
||||
|
||||
+7
-1
@@ -5,7 +5,7 @@ import { parseTravelActionClaim, type TravelClaim } from './travel-expression-pr
|
||||
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES,
|
||||
turnActionPromiseModeSupported, type TurnActionClaim } from './turn-action-promises';
|
||||
import { battlePowerOf, nativeBattlePowerOpportunity } from './battle-power-question';
|
||||
import { parseKaramaShipmentSource, parseKaramaBattleCancelSource, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, parseKaramaAtreidesSpecialSource, parseKaramaFremenSpecialSource, karamaBattleCancelOpportunity, windowCardOpportunity } from './window-card-play';
|
||||
import { parseKaramaShipmentSource, parseKaramaBattleCancelSource, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, parseKaramaAtreidesSpecialSource, parseKaramaFremenSpecialSource, parseKaramaTleilaxuSpecialSource, karamaBattleCancelOpportunity, windowCardOpportunity } from './window-card-play';
|
||||
import { nativeStormDialOpportunity } from './storm-dial-question';
|
||||
import { parseTraitorActionClaim, type MixedTraitorClaim } from './mixed-traitor-question';
|
||||
import { parseBattleActionClaim, MIXED_BATTLE_MAX_NODES, MIXED_BATTLE_MAX_TERMS, MIXED_BATTLE_MAX_DEPTH,
|
||||
@@ -521,6 +521,10 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
g.expansions.length === 0 && !g.nexusCards && !g.homeworlds && !g.discoveryEnabled &&
|
||||
!g.spiceWindow && !g.spiceSequence && !g.spiceResolution && !g.nexus && !g.summonedWorm && !g.wormRides.length,
|
||||
'Choose Fremen’s own special Karama in a supported clean Spice Blow profile.');
|
||||
if (action.effect === 'karamaTleilaxuSpecial')
|
||||
check(parseKaramaTleilaxuSpecialSource(action.source)?.owner === respondent.id &&
|
||||
!g.kullPreview && !g.semutaPreview && !g.semutaRecruitsPreview && !g.semutaAdvancedOrdinaryPreview && !g.pendingRevival,
|
||||
'Choose Tleilaxu’s own special Karama in a supported clean Revival profile.');
|
||||
}
|
||||
if (action.kind === 'battlePlan' || action.kind === 'traitorCall' || action.kind === 'battlePower') {
|
||||
const battle = g.battle;
|
||||
@@ -929,6 +933,8 @@ export function truthQuestionText(
|
||||
return 'you use Atreides’ special Karama after this answer in this battle';
|
||||
if (action.effect === 'karamaFremenSpecial')
|
||||
return 'you use Fremen’s special Karama after this answer during Spice Blow';
|
||||
if (action.effect === 'karamaTleilaxuSpecial')
|
||||
return 'you use Tleilaxu’s special Karama after this answer during Revival';
|
||||
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`;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { RevivalExpressionPromiseError, type RevivalExpressionReturn } from './r
|
||||
import { parseBattleActionClaim, type MixedBattleActionClaim } from './mixed-battle-question';
|
||||
import { PlanClaimError } from './battle-promises';
|
||||
import { PRINTED_CARD_EFFECTS, type PrintedCardEffect } from './printed-card-play';
|
||||
import { WINDOW_CARD_EFFECTS, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, parseKaramaAtreidesSpecialSource, parseKaramaFremenSpecialSource, type WindowCardEffect } from './window-card-play';
|
||||
import { WINDOW_CARD_EFFECTS, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, parseKaramaTleilaxuSpecialSource, parseKaramaAtreidesSpecialSource, parseKaramaFremenSpecialSource, type WindowCardEffect } from './window-card-play';
|
||||
import { StormDialQuestionError, parseStormDialClaim, parseStormDialSource, stormDialMatches, type StormDialClaim } from './storm-dial-question';
|
||||
import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope';
|
||||
import { BattlePowerQuestionError, battlePowerMatches, battlePowerOf, parseBattlePowerClaim,
|
||||
@@ -171,6 +171,8 @@ export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
|
||||
'Retain the original Harkonnen special Karama Bidding window.');
|
||||
if (value.effect === 'karamaEmperorSpecial') check(!!parseKaramaEmperorSpecialSource(value.source),
|
||||
'Retain the original Emperor special Karama Revival window.');
|
||||
if (value.effect === 'karamaTleilaxuSpecial') check(!!parseKaramaTleilaxuSpecialSource(value.source),
|
||||
'Retain the original Tleilaxu special Karama Revival window.');
|
||||
if (value.effect === 'karamaFremenSpecial') check(!!parseKaramaFremenSpecialSource(value.source),
|
||||
'Choose the original Fremen special-Karama window.');
|
||||
if (value.effect === 'karamaAtreidesSpecial') check(!!parseKaramaAtreidesSpecialSource(value.source),
|
||||
@@ -381,6 +383,11 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
check(binding && binding.turn === promise.turn && binding.owner === promise.player,
|
||||
'Retain the respondent’s original Emperor special Karama Revival window.');
|
||||
}
|
||||
for (const source of windowSources.get('karamaTleilaxuSpecial') ?? []) {
|
||||
const binding = parseKaramaTleilaxuSpecialSource(source);
|
||||
check(binding && binding.turn === promise.turn && binding.owner === promise.player,
|
||||
'Retain the respondent’s original Tleilaxu special Karama Revival window.');
|
||||
}
|
||||
for (const source of windowSources.get('karamaFremenSpecial') ?? []) {
|
||||
const parsed = parseKaramaFremenSpecialSource(source);
|
||||
check(parsed && parsed.turn === promise.turn && parsed.owner === promise.player,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { harvesterAvailable } from './cards';
|
||||
|
||||
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial', 'karamaFremenSpecial', 'thumper', 'amal'] as const;
|
||||
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial', 'karamaFremenSpecial', 'karamaTleilaxuSpecial', 'thumper', 'amal'] as const;
|
||||
export type WindowCardEffect = typeof WINDOW_CARD_EFFECTS[number];
|
||||
export function windowCardNativeEffect(effect: WindowCardEffect): 'weather' | 'atomics' | 'harvester' | 'karama' | 'thumper' | 'amal' {
|
||||
return effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' || effect === 'karamaHarkonnenSpecial' || effect === 'karamaEmperorSpecial' || effect === 'karamaAtreidesSpecial' || effect === 'karamaFremenSpecial' ? 'karama' : effect;
|
||||
return effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' || effect === 'karamaHarkonnenSpecial' || effect === 'karamaEmperorSpecial' || effect === 'karamaAtreidesSpecial' || effect === 'karamaFremenSpecial' || effect === 'karamaTleilaxuSpecial' ? 'karama' : effect;
|
||||
}
|
||||
export type KaramaHarkonnenSpecialSource = { turn: number; owner: string };
|
||||
export type KaramaEmperorSpecialSource = { turn: number; owner: string };
|
||||
export type KaramaTleilaxuSpecialSource = { turn: number; owner: string };
|
||||
export type KaramaFremenSpecialSource = { turn: number; owner: string };
|
||||
export type KaramaAtreidesSpecialSource = { turn: number; owner: string; battle: string; territory: string };
|
||||
export type KaramaBattleCancelSource = {
|
||||
@@ -90,6 +91,16 @@ export function parseKaramaEmperorSpecialSource(source: unknown): KaramaEmperorS
|
||||
return { turn: tuple[1], owner: tuple[2] };
|
||||
}
|
||||
|
||||
/** Decode the public owner and turn even after the special power is spent. */
|
||||
export function parseKaramaTleilaxuSpecialSource(source: unknown): KaramaTleilaxuSpecialSource | null {
|
||||
if (!identifier(source)) return null;
|
||||
let tuple: unknown;
|
||||
try { tuple = JSON.parse(source); } catch { return null; }
|
||||
if (!Array.isArray(tuple) || tuple.length !== 3 || tuple[0] !== 'karamaTleilaxuSpecial' ||
|
||||
!whole(tuple[1]) || tuple[1] < 1 || !identifier(tuple[2]) || JSON.stringify(tuple) !== source) return null;
|
||||
return { turn: tuple[1], owner: tuple[2] };
|
||||
}
|
||||
|
||||
/** Decode the public phase window even after the special power is spent. */
|
||||
export function parseKaramaFremenSpecialSource(source: unknown): KaramaFremenSpecialSource | null {
|
||||
if (!identifier(source)) return null;
|
||||
@@ -225,6 +236,17 @@ function globalWindowCardOpportunity(game: WindowCardGame, effect: WindowCardEff
|
||||
if (owners.length !== 1 || owners[0].specialKaramaUsed === true) return null;
|
||||
return { effect, turn: game.turn, source: JSON.stringify([effect, game.turn, owners[0].id]) };
|
||||
}
|
||||
if (effect === 'karamaTleilaxuSpecial') {
|
||||
const players = game.players;
|
||||
if (game.advanced !== true || game.phase !== 4 || !Array.isArray(players) || !players.length ||
|
||||
!players.every(player => object(player) && identifier(player.id) &&
|
||||
typeof player.faction === 'string' && (player.specialKaramaUsed === undefined ||
|
||||
typeof player.specialKaramaUsed === 'boolean')) ||
|
||||
new Set(players.map(player => player.id)).size !== players.length) return null;
|
||||
const owners = players.filter(player => player.faction === 'tleilaxu');
|
||||
if (owners.length !== 1 || owners[0].specialKaramaUsed === true) return null;
|
||||
return { effect, turn: game.turn, source: JSON.stringify([effect, game.turn, owners[0].id]) };
|
||||
}
|
||||
if (effect === 'karamaFremenSpecial') {
|
||||
const players = game.players;
|
||||
if (game.advanced !== true || game.phase !== 1 || !Array.isArray(players) || !players.length ||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { createTleilaxuSpecialKaramaFixture, actTleilaxuSpecialKarama as act, tleilaxuSpecialKaramaPlayer as player } from './fixture-tleilaxu-special-karama';
|
||||
|
||||
void test('Tleilaxu cannot prevent their own revival while committed to reviving forces and using special Karama', () => {
|
||||
const f = createTleilaxuSpecialKaramaFixture(), game = structuredClone(f.game), owner = player(game, f.owner), asker = f.observer;
|
||||
// Controlled casualty; conserve Tleilaxu's physical force counters.
|
||||
assert.ok(owner.reserves > 0); owner.reserves--; owner.tanks++;
|
||||
const hand = player(game, asker).hand;
|
||||
if (!hand.some(card => card.effect === 'truthtrance')) {
|
||||
const zones = [game.deck, game.discard, player(game, f.target).hand];
|
||||
const zone = zones.find(cards => cards.some(card => card.effect === 'truthtrance')); assert.ok(zone);
|
||||
const index = zone.findIndex(card => card.effect === 'truthtrance');
|
||||
[hand[0], zone[index]] = [zone[index], hand[0]];
|
||||
}
|
||||
const truth = hand.find(card => card.effect === 'truthtrance'); assert.ok(truth);
|
||||
let state = act(game, asker, { type: 'card', card: truth.id });
|
||||
for (let count = 0; state.truthtrance?.stage === 'priority' && count < state.players.length; count++) {
|
||||
const actor = state.players.find(seat => !state.truthtrance!.passed.includes(seat.id)); assert.ok(actor);
|
||||
state = act(state, actor.id, { type: 'truthPass' });
|
||||
}
|
||||
state = act(state, asker, { type: 'truthAsk', question: { kind: 'turnActions', target: f.owner, turn: state.turn,
|
||||
mixed: { kind: 'and', terms: [
|
||||
{ kind: 'action', action: { kind: 'forces', counter: 'total', minimum: 1 } },
|
||||
{ kind: 'action', action: { kind: 'windowCardPlay', effect: 'karamaTleilaxuSpecial', source: JSON.stringify(['karamaTleilaxuSpecial', state.turn, f.owner]) } },
|
||||
] } } });
|
||||
assert.ok(viewGame(state, f.owner).truthTurnActionAnswers?.includes('yes'));
|
||||
state = act(state, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const before = structuredClone(state);
|
||||
assert.throws(() => act(state, f.owner, { ...f.special, target: f.owner }));
|
||||
assert.deepEqual(state, before);
|
||||
state = act(state, f.owner, f.special);
|
||||
assert.deepEqual(state.revivalPrevention, { player: f.target, turn: state.turn });
|
||||
assert.ok(state.turnActionPromises!.some(promise => promise.player === f.owner && !promise.fulfilled && !promise.released));
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { windowCardOpportunity } from '../game/window-card-play';
|
||||
import {
|
||||
createTleilaxuSpecialKaramaFixture,
|
||||
actTleilaxuSpecialKarama as act,
|
||||
tleilaxuSpecialKaramaPlayer as player,
|
||||
tleilaxuSpecialKaramaInventory as inventory,
|
||||
} from './fixture-tleilaxu-special-karama';
|
||||
|
||||
function fixture() {
|
||||
const f = createTleilaxuSpecialKaramaFixture();
|
||||
const game = structuredClone(f.game), asker = f.observer;
|
||||
const hand = player(game, asker).hand;
|
||||
// Stage one original Truthtrance by exchanging physical cards with an
|
||||
// existing card zone. Neither Karama nor Ghola is displaced or duplicated.
|
||||
let truth = hand.find(card => card.effect === 'truthtrance');
|
||||
if (!truth) {
|
||||
const zones = [game.deck, game.discard, player(game, f.target).hand];
|
||||
const zone = zones.find(cards => cards.some(card => card.effect === 'truthtrance'));
|
||||
assert.ok(zone);
|
||||
const index = zone.findIndex(card => card.effect === 'truthtrance');
|
||||
assert.ok(hand.length > 0);
|
||||
[hand[0], zone[index]] = [zone[index], hand[0]];
|
||||
truth = hand[0];
|
||||
}
|
||||
assert.deepEqual(inventory(game), f.inventory);
|
||||
const source = JSON.stringify(['karamaTleilaxuSpecial', game.turn, f.owner]);
|
||||
assert.equal(windowCardOpportunity(game, 'karamaTleilaxuSpecial')?.source, source);
|
||||
return { ...f, game, asker, truth: truth.id, windowSource: source };
|
||||
}
|
||||
type Fixture = ReturnType<typeof fixture>;
|
||||
function question(f: Fixture, target = f.owner, source = f.windowSource) {
|
||||
return { kind: 'turnActions', target, turn: f.game.turn, mixed: { kind: 'action', action: {
|
||||
kind: 'windowCardPlay', effect: 'karamaTleilaxuSpecial', source,
|
||||
} } };
|
||||
}
|
||||
function declare(f: Fixture) {
|
||||
let game = act(f.game, f.asker, { type: 'card', card: f.truth });
|
||||
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id));
|
||||
assert.ok(actor);
|
||||
game = act(game, actor.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return game;
|
||||
}
|
||||
function ask(f: Fixture) {
|
||||
return act(declare(f), f.asker, { type: 'truthAsk', question: question(f) });
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
void test('Tleilaxu special Karama Yes spends the physical card and prevents a real revival', () => {
|
||||
const f = fixture(), asked = ask(f);
|
||||
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('yes'));
|
||||
assert.equal(viewGame(asked, f.target).truthTurnActionAnswers, null);
|
||||
let game = act(asked, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const guide = viewGame(game, f.owner).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'card');
|
||||
assert.equal(guide.action.mode, 'special');
|
||||
assert.equal(guide.action.card, f.karama);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const bot = viewGame(game, f.owner);
|
||||
bot.players.find(seat => seat.id === f.owner)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(bot)[0], guide.action);
|
||||
}
|
||||
game = act(game, f.owner, f.special);
|
||||
assert.equal(player(game, f.owner).specialKaramaUsed, true);
|
||||
assert.equal(player(game, f.owner).hand.some(card => card.id === f.karama), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.deepEqual(game.revivalPrevention, { player: f.target, turn: game.turn });
|
||||
const promise = game.turnActionPromises!.find(row => row.player === f.owner);
|
||||
assert.equal(promise?.windowCards?.karamaTleilaxuSpecial?.played, true);
|
||||
assert.equal(promise?.fulfilled, true);
|
||||
reject(game, f.target, f.ordinaryRevival);
|
||||
reject(game, f.target, f.gholaAction);
|
||||
assert.equal(viewGame(game, f.asker).players.find(seat => seat.id === f.owner)!.hand, undefined);
|
||||
assert.deepEqual(viewGame(game, f.asker).turnActionPromises, []);
|
||||
assert.deepEqual(inventory(game), f.inventory);
|
||||
});
|
||||
|
||||
void test('Tleilaxu special Karama No prevents its use and preserves the card', () => {
|
||||
const f = fixture(), asked = ask(f);
|
||||
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('no'));
|
||||
const game = act(asked, f.owner, { type: 'truthAnswer', answer: 'no' });
|
||||
reject(game, f.owner, f.special);
|
||||
reject(game, f.owner, { ...f.special, target: f.owner });
|
||||
assert.ok(player(game, f.owner).hand.some(card => card.id === f.karama));
|
||||
assert.notEqual(player(game, f.owner).specialKaramaUsed, true);
|
||||
assert.deepEqual(inventory(game), f.inventory);
|
||||
});
|
||||
|
||||
void test('Tleilaxu may fulfill Yes by preventing its own revival', () => {
|
||||
const f = fixture();
|
||||
let game = act(ask(f), f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = act(game, f.owner, { ...f.special, target: f.owner });
|
||||
assert.deepEqual(game.revivalPrevention, { player: f.owner, turn: game.turn });
|
||||
assert.equal(game.turnActionPromises!.find(row => row.player === f.owner)?.fulfilled, true);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.deepEqual(inventory(game), f.inventory);
|
||||
});
|
||||
|
||||
void test('Tleilaxu special source rejects another respondent and forged turns or owners', () => {
|
||||
const f = fixture(), game = declare(f);
|
||||
reject(game, f.asker, { type: 'truthAsk', question: question(f, f.target) });
|
||||
reject(game, f.asker, { type: 'truthAsk', question: question(f, f.owner,
|
||||
JSON.stringify(['karamaTleilaxuSpecial', game.turn + 1, f.owner])) });
|
||||
reject(game, f.asker, { type: 'truthAsk', question: question(f, f.owner,
|
||||
JSON.stringify(['karamaTleilaxuSpecial', game.turn, f.target])) });
|
||||
});
|
||||
@@ -2196,6 +2196,42 @@ void test('Emperor special Karama guide forwards only a current legal force pack
|
||||
owner.tanks = 3; owner.specialKaramaUsed = true; press(button); assert.equal(sent.length, 1);
|
||||
});
|
||||
|
||||
void test('Tleilaxu special Karama authoring uses only the public Revival opportunity', () => {
|
||||
const game = projected(4);
|
||||
game.advanced = true; game.expansions = ['ix'];
|
||||
const owner = game.players.find(player => player.id === 'p')!;
|
||||
owner.faction = 'tleilaxu'; owner.specialKaramaUsed = false;
|
||||
const source = JSON.stringify(['karamaTleilaxuSpecial', game.turn, 'p']);
|
||||
const initial = windowClaim('karamaTleilaxuSpecial', source), e = editor(initial, false, game);
|
||||
Object.defineProperty(owner, 'hand', { get() { throw Error('Question authoring read private hand'); }, configurable: true });
|
||||
assert.equal(e.error(), null);
|
||||
assert.match(e.html(), /Use Tleilaxu’s special Karama during Revival/);
|
||||
assert.match(e.html(), /Turn 2 · Tleilaxu special Karama during Revival/);
|
||||
assert.ok(turnActionInputError(initial, game, 'r'));
|
||||
owner.specialKaramaUsed = true; assert.ok(e.error());
|
||||
owner.specialKaramaUsed = false; game.phase = 5; assert.ok(e.error());
|
||||
});
|
||||
|
||||
void test('Tleilaxu special Karama guide requires the current quoted physical card and target', () => {
|
||||
const source = JSON.stringify(['karamaTleilaxuSpecial', 2, 'p']);
|
||||
const game = projected(4, { type: 'card', mode: 'special', card: 'controlled-printed-karama', target: 'r' });
|
||||
game.advanced = true; game.expansions = ['ix'];
|
||||
const owner = game.players.find(player => player.id === 'p')!;
|
||||
owner.faction = 'tleilaxu'; owner.specialKaramaUsed = false;
|
||||
game.tleilaxuSpecialKarama = { blocked: null, cards: ['controlled-printed-karama'],
|
||||
targets: [{ player: 'r', blocked: null }], pending: false };
|
||||
game.turnActionPromises[0].mixed = windowClaim('karamaTleilaxuSpecial', source);
|
||||
const sent: Action[] = [], props = { game, busy: false, act(action: Action) { sent.push(action); } };
|
||||
const button = guide(props); assert.equal(button.props.disabled, false);
|
||||
assert.match(renderToStaticMarkup(button), /Tleilaxu’s special Karama/);
|
||||
press(button); assert.deepEqual(sent, [game.turnActionGuide!.action]);
|
||||
game.tleilaxuSpecialKarama.targets[0].blocked = 'Unavailable'; press(button); assert.equal(sent.length, 1);
|
||||
game.tleilaxuSpecialKarama.targets[0].blocked = null;
|
||||
game.tleilaxuSpecialKarama.cards.length = 0; press(button); assert.equal(sent.length, 1);
|
||||
game.tleilaxuSpecialKarama.cards.push('controlled-printed-karama');
|
||||
owner.hand = []; press(button); assert.equal(sent.length, 1);
|
||||
});
|
||||
|
||||
void test('Atreides special Karama authoring uses the public battle window and owner', () => {
|
||||
const game = combat(false);
|
||||
game.advanced = true;
|
||||
|
||||
@@ -63,7 +63,8 @@ const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expressi
|
||||
const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment'
|
||||
? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaHarkonnenSpecial'
|
||||
? JSON.stringify(['karamaHarkonnenSpecial', 1, 'respondent']) : effect === 'karamaEmperorSpecial'
|
||||
? JSON.stringify(['karamaEmperorSpecial', 1, 'respondent']) : effect === 'karamaAtreidesSpecial'
|
||||
? JSON.stringify(['karamaEmperorSpecial', 1, 'respondent']) : effect === 'karamaTleilaxuSpecial'
|
||||
? JSON.stringify(['karamaTleilaxuSpecial', 1, 'respondent']) : effect === 'karamaAtreidesSpecial'
|
||||
? JSON.stringify(['karamaAtreidesSpecial', 1, 'respondent', 'battle:1', 'carthag']) : effect === 'karamaFremenSpecial'
|
||||
? JSON.stringify(['karamaFremenSpecial', 1, 'respondent']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
|
||||
effect === 'amal' ? 'phase-opening:1:2' : effect === 'thumper' ? 'first-spice-reveal:1' :
|
||||
@@ -1937,6 +1938,41 @@ void test('Emperor special No and source binding retain exact responder, turn an
|
||||
});
|
||||
|
||||
|
||||
void test('Tleilaxu special Karama window records only the original owner and phase after the answer', () => {
|
||||
const source = windowSource('karamaTleilaxuSpecial');
|
||||
const question = windowed(group('and', windowCard('karamaTleilaxuSpecial'), pay()));
|
||||
const signed = { kind: 'windowCardPlay', effect: 'karamaTleilaxuSpecial', source, answer: true };
|
||||
assert.deepEqual(branches(question), [[signed, signedPay()]]);
|
||||
const otherOwner = recordTurnActionWindowCardPlay([question], 'other', 1, 'karamaTleilaxuSpecial', source);
|
||||
assert.equal(otherOwner[0], question);
|
||||
const actual = recordTurnActionWindowCardPlay([question], 'respondent', 1, 'karamaTleilaxuSpecial', source);
|
||||
assert.deepEqual(branches(actual[0]), [[signedPay()]]);
|
||||
assert.equal(recordTurnActionPayment(actual, 'respondent', 1, payment())[0].fulfilled, true);
|
||||
assert.equal(question.windowCards!.karamaTleilaxuSpecial!.played, false);
|
||||
validateTurnActionPromises(actual, seats);
|
||||
const closed = closeTurnActionWindowCardPlay([question], 'respondent', 1, 'karamaTleilaxuSpecial', source);
|
||||
assert.equal(closed[0].windowCards!.karamaTleilaxuSpecial!.closed, true);
|
||||
assert.deepEqual(branches(closed[0]), []);
|
||||
});
|
||||
|
||||
void test('Tleilaxu special No and source binding retain exact responder, turn and old receipts', () => {
|
||||
const source = windowSource('karamaTleilaxuSpecial');
|
||||
const negative = windowed(windowCard('karamaTleilaxuSpecial'), false);
|
||||
assert.throws(() => recordTurnActionWindowCardPlay([negative], 'respondent', 1, 'karamaTleilaxuSpecial', source), TurnActionError);
|
||||
const passed = closeTurnActionWindowCardPlay([negative], 'respondent', 1, 'karamaTleilaxuSpecial', source);
|
||||
assert.equal(passed[0].fulfilled, true);
|
||||
for (const bad of ['', 'storm:1', JSON.stringify(['karamaTleilaxuSpecial', 0, 'respondent']),
|
||||
JSON.stringify(['karamaTleilaxuSpecial', 1, '']), JSON.stringify(['karamaTleilaxuSpecial', 1, 'respondent', 'extra'])])
|
||||
assert.throws(() => parseTurnActionLeaf({ kind: 'windowCardPlay', effect: 'karamaTleilaxuSpecial', source: bad }), TurnActionError);
|
||||
for (const bad of [JSON.stringify(['karamaTleilaxuSpecial', 2, 'respondent']),
|
||||
JSON.stringify(['karamaTleilaxuSpecial', 1, 'other'])])
|
||||
assert.throws(() => turnActionValue(windowed(windowCard('karamaTleilaxuSpecial', bad))), TurnActionError);
|
||||
const old = windowed(windowCard('karamaAuction'));
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(old)), old);
|
||||
assert.doesNotThrow(() => validateTurnActionPromises([old], seats));
|
||||
});
|
||||
|
||||
|
||||
void test('Fremen special Karama window records only the original owner and phase after the answer', () => {
|
||||
const source = windowSource('karamaFremenSpecial');
|
||||
const question = windowed(group('and', windowCard('karamaFremenSpecial'), pay()));
|
||||
|
||||
@@ -23,8 +23,8 @@ function shipments(): WindowCardGame {
|
||||
}
|
||||
|
||||
void test('window roles expose a shared exhaustive tuple and exact native printed-card mapping', () => {
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial', 'karamaFremenSpecial', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'karama', 'karama', 'karama', 'karama', 'karama', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial', 'karamaFremenSpecial', 'karamaTleilaxuSpecial', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'karama', 'karama', 'karama', 'karama', 'karama', 'karama', 'thumper', 'amal']);
|
||||
});
|
||||
|
||||
void test('Amal names each original phase opening with only its public turn and phase identity', () => {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseKaramaTleilaxuSpecialSource as parse, windowCardNativeEffect,
|
||||
windowCardOpportunity, type WindowCardGame } from '../game/window-card-play';
|
||||
|
||||
const source = (): WindowCardGame => ({ status: 'playing', advanced: true, phase: 4, turn: 2,
|
||||
shieldWallDestroyed: false, players: [{ id: 'h', faction: 'tleilaxu', specialKaramaUsed: false },
|
||||
{ id: 'a', faction: 'atreides', specialKaramaUsed: false }] });
|
||||
const event = JSON.stringify(['karamaTleilaxuSpecial', 2, 'h']);
|
||||
|
||||
void test('Tleilaxu special window is public, turn-bound and independent of private custody', () => {
|
||||
const game = source(), before = structuredClone(game);
|
||||
assert.deepEqual(windowCardOpportunity(game, 'karamaTleilaxuSpecial'),
|
||||
{ effect: 'karamaTleilaxuSpecial', source: event, turn: 2 });
|
||||
assert.deepEqual(parse(event), { turn: 2, owner: 'h' });
|
||||
assert.equal(windowCardNativeEffect('karamaTleilaxuSpecial'), 'karama');
|
||||
assert.equal(windowCardOpportunity({ ...game, response: { kind: 'voice' }, decision: { kind: 'other' } },
|
||||
'karamaTleilaxuSpecial')?.source, event);
|
||||
assert.equal(windowCardOpportunity(game, 'karamaTleilaxuSpecial', event)?.source, event);
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('special opportunity ends with use, turn or phase; malformed sources reject', () => {
|
||||
const game = source();
|
||||
for (const changes of [{ status: 'lobby' }, { advanced: false }, { phase: 3 }, { turn: 0 },
|
||||
{ players: [{ id: 'h', faction: 'tleilaxu', specialKaramaUsed: true }] },
|
||||
{ players: [{ id: 'h', faction: 'atreides' }] },
|
||||
{ players: [{ id: 'h', faction: 'tleilaxu' }, { id: 'h', faction: 'atreides' }] },
|
||||
{ players: [{ id: 'h', faction: 'tleilaxu' }, { id: 'h2', faction: 'tleilaxu' }] }])
|
||||
assert.equal(windowCardOpportunity({ ...game, ...changes } as WindowCardGame, 'karamaTleilaxuSpecial'), null);
|
||||
for (const value of ['', '[]', '["karamaTleilaxuSpecial",2," h"]',
|
||||
'["karamaTleilaxuSpecial", 2,"h"]', '["karamaTleilaxuSpecial",0,"h"]',
|
||||
'["karamaTleilaxuSpecial",2,"h","extra"]']) assert.equal(parse(value), null);
|
||||
assert.equal(windowCardOpportunity(game, 'karamaTleilaxuSpecial', JSON.stringify(['karamaTleilaxuSpecial', 2, 'a'])), null);
|
||||
assert.equal(windowCardOpportunity({ ...game, turn: 3 }, 'karamaTleilaxuSpecial', event), null);
|
||||
assert.ok(windowCardOpportunity({ ...game, players: [{ id: 'h', faction: 'tleilaxu' }] }, 'karamaTleilaxuSpecial'));
|
||||
});
|
||||
Reference in new issue
Block a user