Support Emperor special Karama truth commitments
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
b448144c52
commit
add8e1d51a
14 files changed
+345
-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, WINDOW_CARD_EFFECTS, windowCardOpportunity, type WindowCardEffect } from '@/game/window-card-play';
|
||||
import { karamaBattleCancelOpportunity, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, 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';
|
||||
@@ -56,6 +56,7 @@ const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Co
|
||||
karamaShipment: 'Spend a printed Karama for the current shipment-price opportunity',
|
||||
karamaBattleCancel: 'Spend a printed Karama to cancel this Voice or Prescience',
|
||||
karamaHarkonnenSpecial: 'Use Harkonnen’s special Karama during Bidding',
|
||||
karamaEmperorSpecial: 'Use Emperor’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. */
|
||||
@@ -64,6 +65,8 @@ const windowOpportunity = (game: GameView, target: string, effect: WindowCardEff
|
||||
const opportunity = windowCardOpportunity(game, effect);
|
||||
if (effect === 'karamaHarkonnenSpecial' &&
|
||||
(!opportunity || parseKaramaHarkonnenSpecialSource(opportunity.source)?.owner !== target)) return null;
|
||||
if (effect === 'karamaEmperorSpecial' &&
|
||||
(!opportunity || parseKaramaEmperorSpecialSource(opportunity.source)?.owner !== target)) return null;
|
||||
return opportunity;
|
||||
};
|
||||
const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
@@ -269,11 +272,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 === 'thumper' || effect === 'amal')
|
||||
if (effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' || effect === 'karamaHarkonnenSpecial' || effect === 'karamaEmperorSpecial' || 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') || !!windowOpportunity(game, target, effect) || value.effect === effect)
|
||||
{WINDOW_CARD_EFFECTS.filter(effect => (effect !== 'karamaBattleCancel' && effect !== 'karamaHarkonnenSpecial' && effect !== 'karamaEmperorSpecial') || !!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>
|
||||
@@ -290,6 +293,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
: 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 === 'karamaHarkonnenSpecial' ? `Turn ${currentWindow.turn} · Harkonnen special Karama during Bidding`
|
||||
: value.effect === 'karamaEmperorSpecial' ? `Turn ${currentWindow.turn} · Emperor 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'> =>
|
||||
const isWindowEffect = (effect: unknown): effect is Exclude<WindowCardEffect, 'karamaAuction' | 'karamaShipment' | 'karamaBattleCancel' | 'karamaHarkonnenSpecial' | 'karamaEmperorSpecial'> =>
|
||||
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', thumper: 'Thumper', amal: 'Amal' };
|
||||
karamaHarkonnenSpecial: 'Harkonnen special Karama', karamaEmperorSpecial: 'Emperor 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));
|
||||
@@ -129,6 +129,10 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
const harkonnenRoleNamed = !!windowPromise && !!currentHarkonnenWindow &&
|
||||
namesWindow(windowPromise.mixed, 'karamaHarkonnenSpecial', currentHarkonnenWindow.source);
|
||||
const printedHarkonnenAction = action.type === 'card' && action.mode === 'special' && harkonnenRoleNamed;
|
||||
const currentEmperorWindow = windowCardOpportunity(game, 'karamaEmperorSpecial');
|
||||
const emperorRoleNamed = !!windowPromise && !!currentEmperorWindow &&
|
||||
namesWindow(windowPromise.mixed, 'karamaEmperorSpecial', currentEmperorWindow.source);
|
||||
const printedEmperorAction = action.type === 'card' && action.mode === 'special' && emperorRoleNamed;
|
||||
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);
|
||||
@@ -137,7 +141,8 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
? 'karamaAuction' : printedShipmentAction && heldCard?.effect === windowCardNativeEffect('karamaShipment')
|
||||
? 'karamaShipment' : printedBattleCancelAction && heldCard?.effect === windowCardNativeEffect('karamaBattleCancel')
|
||||
? 'karamaBattleCancel' : printedHarkonnenAction && heldCard?.effect === windowCardNativeEffect('karamaHarkonnenSpecial')
|
||||
? 'karamaHarkonnenSpecial' : isWindowEffect(heldCard?.effect) ? heldCard.effect : null;
|
||||
? 'karamaHarkonnenSpecial' : printedEmperorAction && heldCard?.effect === windowCardNativeEffect('karamaEmperorSpecial')
|
||||
? 'karamaEmperorSpecial' : 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({
|
||||
@@ -207,6 +212,12 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
return JSON.stringify(target && { id: target.id, handCount: target.handCount });
|
||||
};
|
||||
const harkonnenTargetSignature = printedHarkonnenAction ? harkonnenTargetSource(game) : null;
|
||||
const emperorRevivalSource = (current: GameView) => {
|
||||
const owner = current.players.find(player => player.id === actor);
|
||||
return JSON.stringify({ tanks: owner?.tanks, elites: owner?.elites?.tanks, leaders: owner?.leaders,
|
||||
revival: current.revival, used: owner?.specialKaramaUsed });
|
||||
};
|
||||
const emperorRevivalSignature = printedEmperorAction ? emperorRevivalSource(game) : null;
|
||||
const battleCancelControls = game.responseControls, battleCancelControlsSignature = JSON.stringify(battleCancelControls);
|
||||
const battleCancelResponse = game.response, battleCancelResponseSignature = JSON.stringify(battleCancelResponse);
|
||||
const thumperCards = game.thumperCards, thumperCardsSignature = JSON.stringify(thumperCards);
|
||||
@@ -273,7 +284,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'] : [])].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'] : [])].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 &&
|
||||
@@ -300,6 +311,16 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
typeof harkonnenTarget.handCount === 'number' && Number.isSafeInteger(harkonnenTarget.handCount) && harkonnenTarget.handCount >= 1 &&
|
||||
Number.isSafeInteger(action.amount) && action.amount === 1 &&
|
||||
harkonnenTargetSource(current) === harkonnenTargetSignature;
|
||||
const emperorLeader = typeof action.leader === 'string' && action.amount === undefined && action.elite === undefined &&
|
||||
!!me?.leaders.some(leader => leader.id === action.leader && leader.dead && !leader.capturedBy && !leader.gholaBy);
|
||||
const emperorForces = action.leader === undefined && typeof action.amount === 'number' && Number.isSafeInteger(action.amount) &&
|
||||
action.amount >= 1 && action.amount <= 3 && !!me && action.amount <= me.tanks &&
|
||||
typeof action.elite === 'number' && Number.isSafeInteger(action.elite) && action.elite >= 0 &&
|
||||
action.elite <= (current.revival.sardaukarFreeBlock ? 0 : Math.min(action.amount, me.elites?.tanks ?? 0)) &&
|
||||
action.elite >= Math.max(0, action.amount - (me.tanks - (me.elites?.tanks ?? 0)));
|
||||
const nativeEmperorCard = windowEffect === 'karamaEmperorSpecial' && !!me && me.faction === 'emperor' &&
|
||||
me.specialKaramaUsed === false && phase === 4 && action.mode === 'special' && !current.revival.prevented &&
|
||||
!current.revival.pending && emperorRevivalSource(current) === emperorRevivalSignature && (emperorLeader || emperorForces);
|
||||
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);
|
||||
@@ -314,6 +335,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
: windowEffect === 'karamaShipment' ? action.mode === 'shipment' && nativeShipmentCard
|
||||
: windowEffect === 'karamaBattleCancel' ? action.mode === 'cancel' && nativeBattleCancelCard
|
||||
: windowEffect === 'karamaHarkonnenSpecial' ? nativeHarkonnenCard
|
||||
: windowEffect === 'karamaEmperorSpecial' ? nativeEmperorCard
|
||||
: 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);
|
||||
@@ -360,7 +382,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 && quotedBoxCard &&
|
||||
const genericCard = ownCard && action.type === 'card' && !auctionPurchaseAction && !printedShipmentAction && !printedBattleCancelAction && !printedHarkonnenAction && !printedEmperorAction && 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);
|
||||
@@ -475,7 +497,7 @@ 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) && (!sectorShipmentGuide || currentSectorShipment) &&
|
||||
(!printedBattleCancelAction || printedWindow) && (!printedHarkonnenAction || printedWindow) && (!printedEmperorAction || printedWindow) && (!sectorShipmentGuide || currentSectorShipment) &&
|
||||
current.turnActionPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
|
||||
!promise.fulfilled && !promise.released) &&
|
||||
(!amalReadyAction || openingReady) &&
|
||||
@@ -509,6 +531,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
: 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' && windowEffect === 'karamaHarkonnenSpecial' ? `Use Harkonnen’s special Karama on ${recipient} while honoring your whole answer`
|
||||
: 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' && 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,14 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Emperor special Karama Truthtrance — 29 October. Prototyped / Partial.**
|
||||
In clean Advanced Revival, a whole-turn question can bind the Emperor's actual
|
||||
special Karama use. The shared planner uses the real physical force or leader
|
||||
return and preserves return-before-card-cost timing. Seven related files pass
|
||||
218 cases; the leader fixture stages a death. Kull, Semuta, broader interactions
|
||||
and deployed verification remain open.
|
||||
[Contract](TRUTHTRANCE_TURN_ACTIONS.md#emperor-special-karama-during-revival--29-october-2026).
|
||||
|
||||
**Harkonnen special Karama Truthtrance — 29 October. Prototyped / Partial.**
|
||||
In clean Advanced Bidding, a whole-turn question can bind Harkonnen's actual
|
||||
once-per-game special Karama use. The shared witness pays the physical card
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# Truthtrance combined current-turn actions
|
||||
|
||||
## Emperor special Karama during Revival — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** A whole-turn question may name the Emperor's
|
||||
once-per-game special Karama use in a quiet Advanced Revival window. The
|
||||
public source binds the Emperor and turn, not a force or leader choice. Only
|
||||
a successful use of an actual printed Karama in this special role after the
|
||||
answer counts; ordinary Karama roles, prevented activations and earlier uses
|
||||
do not. A Yes must retain an actual legal free return of eligible Emperor
|
||||
forces or a dead leader, the physical card and any prior commitments; No
|
||||
forbids the named role without forbidding other Karama roles.
|
||||
|
||||
The detached witness uses the real selected native revival and its shared
|
||||
Tanks, leader custody, limits and resources. The native Emperor return precedes
|
||||
its cost disposal; this order and any income/response continuation must not be
|
||||
replayed. The initial bounded profile excludes Kull and Semuta. The question
|
||||
is a provisional online composition of general current-turn Truthtrance and
|
||||
native special-Karama rules, not a dedicated publisher ruling. Seven related files pass 218 cases. The native force fixture uses dealt cards
|
||||
and Storm casualties; the leader fixture stages a dead leader. Broader
|
||||
interactions, saved-game assurance and deployed verification remain open.
|
||||
|
||||
## Harkonnen special Karama during Bidding — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** In a quiet Advanced Bidding window, a whole-turn
|
||||
|
||||
+29
-3
@@ -19000,7 +19000,8 @@ function karamaSpendingBlock(g: Game, p: Player, card: Card, purpose?: KullPurpo
|
||||
required.effect === 'karamaShipment' && purpose === 'shipment-price' ||
|
||||
required.effect === 'karamaBattleCancel' && !!g.response &&
|
||||
purpose === karamaUsePurpose(g, { kind: 'cancel', response: g.response }) ||
|
||||
required.effect === 'karamaHarkonnenSpecial' && purpose === 'special:harkonnen') &&
|
||||
required.effect === 'karamaHarkonnenSpecial' && purpose === 'special:harkonnen' ||
|
||||
required.effect === 'karamaEmperorSpecial' && purpose === 'special:emperor') &&
|
||||
windowCardOpportunity(g, required.effect)?.source === required.source))
|
||||
return 'Retain this opportunity without a forbidden printed Karama cost.';
|
||||
const kullBlock = kullKaramaBlock(g, p, purpose);
|
||||
@@ -20492,8 +20493,10 @@ 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 => {
|
||||
const stage = row.windowCards?.karamaHarkonnenSpecial;
|
||||
return stage && !stage.closed && windowCardOpportunity(g, 'karamaHarkonnenSpecial', stage.source)?.source === stage.source;
|
||||
return (['karamaHarkonnenSpecial', 'karamaEmperorSpecial'] as const).some(effect => {
|
||||
const stage = row.windowCards?.[effect];
|
||||
return stage && !stage.closed && windowCardOpportunity(g, effect, stage.source)?.source === stage.source;
|
||||
});
|
||||
})) guardTurnActionPrintedCardCost(g, p, used);
|
||||
if (auctionWindow && auctionRows.some(row => row.windowCards?.karamaAuction?.source === auctionWindow))
|
||||
guardTurnActionAuctionClosure(g, p.id, auctionRows);
|
||||
@@ -22384,6 +22387,24 @@ function quotedAmalReady(g: Game, actor: string): boolean {
|
||||
function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffect): Generator<Action> {
|
||||
if (!windowCardOpportunity(g, effect)) return;
|
||||
const player = getPlayer(g, actor);
|
||||
if (effect === 'karamaEmperorSpecial') {
|
||||
if (player.faction !== 'emperor' || g.kullPreview || g.pendingKull || g.semutaPreview ||
|
||||
g.semutaRecruitsPreview || g.semutaAdvancedOrdinaryPreview || g.truthtrance ||
|
||||
g.response || g.decision || g.phaseOpening || g.pendingKarama ||
|
||||
g.pendingTreacheryDiscard || g.pendingNullentropy) return;
|
||||
for (const card of player.hand.filter(card => card.kind === 'special' && card.effect === 'karama')) {
|
||||
const actions: Action[] = player.leaders.filter(leader => leader.dead && !leader.capturedBy && !leader.gholaBy)
|
||||
.map(leader => ({ type: 'card', mode: 'special', card: card.id, leader: leader.id }));
|
||||
for (let amount = 1; amount <= Math.min(3, player.tanks); amount++)
|
||||
for (let elite = 0; elite <= Math.min(amount, player.elites?.tanks ?? 0); elite++)
|
||||
actions.push({ type: 'card', mode: 'special', card: card.id, amount, elite });
|
||||
for (const action of actions) try {
|
||||
const intent = prepareSpecialKaramaIntent(g, actor, action);
|
||||
if (intent.kind === 'emperorForces' || intent.kind === 'emperorLeader') yield action;
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (effect === 'karamaHarkonnenSpecial') {
|
||||
const opportunity = windowCardOpportunity(g, effect);
|
||||
// The public source identifies the unique native owner; eligibility and
|
||||
@@ -42816,8 +42837,13 @@ function resolveSpecialKaramaEffect(g: Game, p: Player, card: Card, intent: Spec
|
||||
log(g, `${p.name} used special Karama to revive ${n} forces for free.`);
|
||||
}
|
||||
techIncome(g, 'axlotl', p);
|
||||
const opportunity = windowCardOpportunity(g, 'karamaEmperorSpecial');
|
||||
if (opportunity && card.kind === 'special' && card.effect === 'karama')
|
||||
recordNativeTurnActionWindowCard(g, p.id, 'karamaEmperorSpecial', opportunity.source);
|
||||
discard(g, p, card.id);
|
||||
p.specialKaramaUsed = true;
|
||||
if (liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length)
|
||||
guardTurnActionPrintedCardCost(g, p, card);
|
||||
stageSpecialKaramaCost(g, intent, card, true);
|
||||
} else if (intent.kind === 'harkonnen') {
|
||||
const target = getPlayer(g, intent.target),
|
||||
|
||||
+2
-1
@@ -3337,7 +3337,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
id: 'truthtrance-turn-actions',
|
||||
title: 'Truthtrance: combined card plays, battle and turn actions',
|
||||
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
|
||||
summary: 'Combine printed Karama shipment pricing and current-lot costs, Advanced Harkonnen special Karama, original-window card plays, native powers and other current-turn actions in one answer, with optional frozen facts.',
|
||||
summary: 'Combine printed Karama shipment pricing and current-lot costs, selected Advanced special Karama, original-window card plays, native powers and other current-turn actions in one answer, with optional frozen facts.',
|
||||
steps: [
|
||||
'In a supported quiet Storm, Spice Blow, Charity, Bidding, Revival or Shipment and Movement window, ask whether a player will use a named native leader in their first later battle this turn, if one occurs. Storm must have a pending movement after the dials; Spice Blow must be before the draw. The answer waits for an actual battle; if none occurs, it expires. This separate provisional question supports Basic factions and Advanced without Ecaz, excluding optional modules. In a Basic Ecaz combined battle, only the selected lead and opponent bind; the nonleading ally waits for its own later battle. Answers use only currently available leaders.',
|
||||
'Choose Combine printed card plays, native battle powers and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of original-window card plays, native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
|
||||
@@ -3365,6 +3365,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'These original-window conditions describe playing the card, with the normal chosen Weather distance, Atomics qualifying forces/casualties and Harvester arithmetic. They do not describe a future selected distance or amount. Accepting the window must preserve the whole answer; a later payment alternative can remain. No caps last through actual source closure, rather than ending at your own Ready.',
|
||||
'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 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.',
|
||||
'A Thumper condition counts an actual printed play after answering before this turn’s original first Spice reveal. Its cost counts even when the existing first-turn rule ignores the encounter or waiting Sandtrout suppresses it. Own acceptance must retain the whole answer. Known card effects enter the quote; the next hidden Spice Card does not. A forced later draw can release genuine impossibility, while choosing known casualties cannot evade an earlier answer.',
|
||||
'A printed Amal clause names the original phase opening and counts a future printed play after this answer. The actual available-spice reduction occurs before ordinary payments become possible; known Mentat bribe receipts arrive afterward. Definite answers reopen your own opening pass. Own cost or acceptance must retain a completion, while a foreign Amal can release genuine impossibility before another player takes the last pass. Later openings keep separate intervals.',
|
||||
|
||||
+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, karamaBattleCancelOpportunity, windowCardOpportunity } from './window-card-play';
|
||||
import { parseKaramaShipmentSource, parseKaramaBattleCancelSource, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, 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,
|
||||
@@ -507,6 +507,10 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
check(parseKaramaHarkonnenSpecialSource(action.source)?.owner === respondent.id &&
|
||||
!g.kullPreview && !g.semutaPreview && !g.semutaRecruitsPreview && !g.semutaAdvancedOrdinaryPreview,
|
||||
'Choose Harkonnen’s own special Karama in a supported clean Bidding profile.');
|
||||
if (action.effect === 'karamaEmperorSpecial')
|
||||
check(parseKaramaEmperorSpecialSource(action.source)?.owner === respondent.id &&
|
||||
!g.kullPreview && !g.semutaPreview && !g.semutaRecruitsPreview && !g.semutaAdvancedOrdinaryPreview,
|
||||
'Choose Emperor’s own special Karama in a supported clean Revival profile.');
|
||||
}
|
||||
if (action.kind === 'battlePlan' || action.kind === 'traitorCall' || action.kind === 'battlePower') {
|
||||
const battle = g.battle;
|
||||
@@ -909,6 +913,8 @@ export function truthQuestionText(
|
||||
if (action.kind === 'windowCardPlay') {
|
||||
if (action.effect === 'karamaHarkonnenSpecial')
|
||||
return 'you use Harkonnen’s special Karama after this answer during Bidding';
|
||||
if (action.effect === 'karamaEmperorSpecial')
|
||||
return 'you use Emperor’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, type WindowCardEffect } from './window-card-play';
|
||||
import { WINDOW_CARD_EFFECTS, parseKaramaHarkonnenSpecialSource, parseKaramaEmperorSpecialSource, 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,
|
||||
@@ -169,6 +169,8 @@ export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
|
||||
'Choose one original phase-opening, Storm, first-reveal, fresh-blow, ordinary-lot or shipment-price printed-card play after this answer.');
|
||||
if (value.effect === 'karamaHarkonnenSpecial') check(!!parseKaramaHarkonnenSpecialSource(value.source),
|
||||
'Retain the original Harkonnen special Karama Bidding window.');
|
||||
if (value.effect === 'karamaEmperorSpecial') check(!!parseKaramaEmperorSpecialSource(value.source),
|
||||
'Retain the original Emperor special Karama Revival window.');
|
||||
return { kind: 'windowCardPlay', effect: value.effect, source: value.source };
|
||||
}
|
||||
if (value.kind === 'battlePower') {
|
||||
@@ -370,6 +372,11 @@ function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnAction
|
||||
check(binding && binding.turn === promise.turn && binding.owner === promise.player,
|
||||
'Retain the respondent’s original Harkonnen special Karama Bidding window.');
|
||||
}
|
||||
for (const source of windowSources.get('karamaEmperorSpecial') ?? []) {
|
||||
const binding = parseKaramaEmperorSpecialSource(source);
|
||||
check(binding && binding.turn === promise.turn && binding.owner === promise.player,
|
||||
'Retain the respondent’s original Emperor special Karama Revival window.');
|
||||
}
|
||||
if ('windowCards' in promise) {
|
||||
check(object(promise.windowCards) && keys(promise.windowCards, [...windowSources.keys()].sort().join(',')),
|
||||
'Retain exactly the original named window-card effects.');
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { harvesterAvailable } from './cards';
|
||||
|
||||
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'thumper', 'amal'] as const;
|
||||
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', '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' ? 'karama' : effect;
|
||||
return effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' || effect === 'karamaHarkonnenSpecial' || effect === 'karamaEmperorSpecial' ? 'karama' : effect;
|
||||
}
|
||||
export type KaramaHarkonnenSpecialSource = { turn: number; owner: string };
|
||||
export type KaramaEmperorSpecialSource = { turn: number; owner: string };
|
||||
export type KaramaBattleCancelSource = {
|
||||
turn: number;
|
||||
battle: string;
|
||||
@@ -77,6 +78,16 @@ export function parseKaramaHarkonnenSpecialSource(source: unknown): KaramaHarkon
|
||||
return { turn: tuple[1], owner: tuple[2] };
|
||||
}
|
||||
|
||||
/** Decode the public owner and turn even after the special power is spent. */
|
||||
export function parseKaramaEmperorSpecialSource(source: unknown): KaramaEmperorSpecialSource | 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] !== 'karamaEmperorSpecial' ||
|
||||
!whole(tuple[1]) || tuple[1] < 1 || !identifier(tuple[2]) || JSON.stringify(tuple) !== source) return null;
|
||||
return { turn: tuple[1], owner: tuple[2] };
|
||||
}
|
||||
|
||||
/** Source decoding remains available after the actual grant closes its window. */
|
||||
export function parseKaramaShipmentSource(source: unknown): KaramaShipmentSource | null {
|
||||
if (!identifier(source)) return null;
|
||||
@@ -180,6 +191,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 === 'karamaEmperorSpecial') {
|
||||
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 === 'emperor');
|
||||
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 === 'karamaBattleCancel') {
|
||||
const battle = game.battle, response = game.response, players = game.players;
|
||||
if (game.advanced !== false || game.phase !== 6 || !battle || !identifier(battle.event) ||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { windowCardOpportunity } from '../game/window-card-play';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { actTruthRevival as act, createTruthRevivalFixture, declareTruthRevival,
|
||||
settleTruthRevival as settle, truthRevivalInventory as inventory,
|
||||
truthRevivalPlayer as player } from './fixture-truth-revival';
|
||||
|
||||
/** Native Advanced Revival after Emperor shipped and lost three forces in the
|
||||
* original Storm; physical Truthtrance and Karama come from the real deal. */
|
||||
function fixture() {
|
||||
const f = createTruthRevivalFixture({ advanced: true, targetKarama: true });
|
||||
assert.ok(f.auctionKarama);
|
||||
const source = JSON.stringify(['karamaEmperorSpecial', f.game.turn, f.target]);
|
||||
assert.equal(windowCardOpportunity(f.game, 'karamaEmperorSpecial')?.source, source);
|
||||
return { ...f, source, karamaCard: f.auctionKarama };
|
||||
}
|
||||
function question(f: ReturnType<typeof fixture>, target = f.target) {
|
||||
return { kind: 'turnActions', target, turn: f.game.turn, mixed: { kind: 'action', action: {
|
||||
kind: 'windowCardPlay', effect: 'karamaEmperorSpecial', source: f.source,
|
||||
} } };
|
||||
}
|
||||
function ask(f: ReturnType<typeof fixture>, state = f.game) {
|
||||
const declared = declareTruthRevival(f, state);
|
||||
return act(declared.game, 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('Advanced Emperor special Karama Yes uses a physical card for a real free force return', () => {
|
||||
const f = fixture(), asked = ask(f);
|
||||
assert.ok(viewGame(asked, f.target).truthTurnActionAnswers?.includes('yes'));
|
||||
assert.equal(viewGame(asked, f.opponent).truthTurnActionAnswers, null);
|
||||
let game = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'card');
|
||||
assert.equal(guide.action.mode, 'special');
|
||||
assert.equal(guide.action.card, f.karamaCard);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const bot = viewGame(game, f.target);
|
||||
bot.players.find(seat => seat.id === f.target)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(bot)[0], guide.action);
|
||||
}
|
||||
const before = structuredClone(game), priorTanks = player(game, f.target).tanks;
|
||||
const priorReserves = player(game, f.target).reserves, priorSpice = player(game, f.target).spice;
|
||||
// A voluntary ordinary return using every tank counter cannot eliminate the
|
||||
// only native special-return witness for the accepted Yes.
|
||||
reject(game, f.target, { type: 'revive', amount: 3, elite: 1 });
|
||||
game = act(game, f.target, guide.action);
|
||||
assert.deepEqual(before.turnActionPromises?.[0].windowCards?.karamaEmperorSpecial?.played, false);
|
||||
assert.equal(player(game, f.target).specialKaramaUsed, true);
|
||||
assert.equal(game.turnActionPromises!.find(row => row.player === f.target)!.windowCards!.karamaEmperorSpecial!.played, true);
|
||||
assert.equal(game.turnActionPromises!.find(row => row.player === f.target)!.fulfilled, true);
|
||||
const returned = priorTanks - player(game, f.target).tanks;
|
||||
assert.ok(returned >= 1 && returned <= 3);
|
||||
assert.equal(player(game, f.target).reserves, priorReserves + returned);
|
||||
assert.equal(player(game, f.target).spice, priorSpice);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karamaCard).length, 1);
|
||||
assert.equal(viewGame(game, f.asker).players.find(seat => seat.id === f.target)!.hand, undefined);
|
||||
assert.deepEqual(viewGame(game, f.asker).turnActionPromises, []);
|
||||
game = settle(game);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('Advanced Emperor special Karama No blocks special use without spending the card', () => {
|
||||
const f = fixture(), asked = ask(f);
|
||||
assert.ok(viewGame(asked, f.target).truthTurnActionAnswers?.includes('no'));
|
||||
const game = act(asked, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
reject(game, f.target, { type: 'card', card: f.karamaCard, mode: 'special', amount: 1, elite: 0 });
|
||||
assert.ok(player(game, f.target).hand.some(card => card.id === f.karamaCard));
|
||||
assert.notEqual(player(game, f.target).specialKaramaUsed, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('Advanced Emperor can use special Karama to revive a dead leader after Yes', () => {
|
||||
const f = fixture(), staged = structuredClone(f.game), emperor = player(staged, f.target);
|
||||
// This separate branch stages one existing Emperor leader's death, without
|
||||
// creating a leader or changing any card or force custody.
|
||||
const leader = emperor.leaders[0]; assert.ok(leader && !leader.dead);
|
||||
leader.dead = true; leader.deaths = 1; delete leader.usedAt;
|
||||
const asked = ask(f, staged);
|
||||
assert.ok(viewGame(asked, f.target).truthTurnActionAnswers?.includes('yes'));
|
||||
let game = act(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = act(game, f.target, { type: 'card', card: f.karamaCard, mode: 'special', leader: leader.id });
|
||||
assert.equal(player(game, f.target).leaders.find(row => row.id === leader.id)!.dead, false);
|
||||
assert.equal(game.turnActionPromises!.find(row => row.player === f.target)!.windowCards!.karamaEmperorSpecial!.played, true);
|
||||
assert.equal(game.turnActionPromises!.find(row => row.player === f.target)!.fulfilled, true);
|
||||
game = settle(game);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('the Emperor special source rejects other respondents and forged turns', () => {
|
||||
const f = fixture(), game = declareTruthRevival(f).game;
|
||||
reject(game, f.asker, { type: 'truthAsk', question: question(f, f.opponent) });
|
||||
const forged = question(f);
|
||||
forged.mixed.action.source = JSON.stringify(['karamaEmperorSpecial', game.turn + 1, f.target]);
|
||||
reject(game, f.asker, { type: 'truthAsk', question: forged });
|
||||
});
|
||||
@@ -2162,3 +2162,36 @@ void test('Harkonnen special Karama guide forwards only the exact live printed-c
|
||||
target.handCount = 0; press(button); assert.equal(sent.length, 1);
|
||||
target.handCount = 1; owner.specialKaramaUsed = true; press(button); assert.equal(sent.length, 1);
|
||||
});
|
||||
|
||||
void test('Emperor special Karama authoring uses only the public Revival opportunity', () => {
|
||||
const game = projected(4);
|
||||
game.advanced = true;
|
||||
const emperor = game.players.find(player => player.id === 'p')!;
|
||||
emperor.faction = 'emperor'; emperor.specialKaramaUsed = false;
|
||||
const source = JSON.stringify(['karamaEmperorSpecial', game.turn, 'p']);
|
||||
const initial = windowClaim('karamaEmperorSpecial', source), e = editor(initial, false, game);
|
||||
Object.defineProperty(emperor, 'hand', { get() { throw Error('Question authoring read private hand'); }, configurable: true });
|
||||
assert.equal(e.error(), null);
|
||||
assert.match(e.html(), /Use Emperor’s special Karama during Revival/);
|
||||
assert.match(e.html(), /Turn 2 · Emperor special Karama during Revival/);
|
||||
assert.ok(turnActionInputError(initial, game, 'r'));
|
||||
emperor.specialKaramaUsed = true; assert.ok(e.error());
|
||||
emperor.specialKaramaUsed = false; game.phase = 5; assert.ok(e.error());
|
||||
});
|
||||
|
||||
void test('Emperor special Karama guide forwards only a current legal force packet', () => {
|
||||
const source = JSON.stringify(['karamaEmperorSpecial', 2, 'p']);
|
||||
const game = projected(4, { type:'card', mode:'special', card:'controlled-printed-karama', amount:2, elite:1 });
|
||||
game.advanced = true;
|
||||
const owner = game.players.find(player => player.id === 'p')!;
|
||||
owner.faction = 'emperor'; owner.specialKaramaUsed = false; owner.tanks = 3;
|
||||
owner.elites = { reserves: 3, tanks: 1, forces: {}, revived: 0 };
|
||||
game.revival.prevented = false; game.revival.pending = false; game.revival.sardaukarFreeBlock = null;
|
||||
game.turnActionPromises[0].mixed = windowClaim('karamaEmperorSpecial', 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), /Emperor’s special Karama/);
|
||||
press(button); assert.deepEqual(sent, [game.turnActionGuide!.action]);
|
||||
owner.tanks = 1; press(button); assert.equal(sent.length, 1);
|
||||
owner.tanks = 3; owner.specialKaramaUsed = true; press(button); assert.equal(sent.length, 1);
|
||||
});
|
||||
@@ -62,7 +62,8 @@ const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expressi
|
||||
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
|
||||
const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment'
|
||||
? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaHarkonnenSpecial'
|
||||
? JSON.stringify(['karamaHarkonnenSpecial', 1, 'respondent']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
|
||||
? JSON.stringify(['karamaHarkonnenSpecial', 1, 'respondent']) : effect === 'karamaEmperorSpecial'
|
||||
? JSON.stringify(['karamaEmperorSpecial', 1, 'respondent']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
|
||||
effect === 'amal' ? 'phase-opening:1:2' : effect === 'thumper' ? 'first-spice-reveal:1' :
|
||||
effect === 'harvester' ? 'spice-blow:1' : 'storm:1';
|
||||
const windowCard = (effect: WindowCardEffect = 'weather', source = windowSource(effect)): Expression =>
|
||||
@@ -1898,3 +1899,37 @@ void test('Harkonnen special No and source binding retain exact responder, turn
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(old)), old);
|
||||
assert.doesNotThrow(() => validateTurnActionPromises([old], seats));
|
||||
});
|
||||
|
||||
void test('Emperor special Karama window records only the original owner and phase after the answer', () => {
|
||||
const source = windowSource('karamaEmperorSpecial');
|
||||
const question = windowed(group('and', windowCard('karamaEmperorSpecial'), pay()));
|
||||
const signed = { kind: 'windowCardPlay', effect: 'karamaEmperorSpecial', source, answer: true };
|
||||
assert.deepEqual(branches(question), [[signed, signedPay()]]);
|
||||
const otherOwner = recordTurnActionWindowCardPlay([question], 'other', 1, 'karamaEmperorSpecial', source);
|
||||
assert.equal(otherOwner[0], question);
|
||||
const actual = recordTurnActionWindowCardPlay([question], 'respondent', 1, 'karamaEmperorSpecial', source);
|
||||
assert.deepEqual(branches(actual[0]), [[signedPay()]]);
|
||||
assert.equal(recordTurnActionPayment(actual, 'respondent', 1, payment())[0].fulfilled, true);
|
||||
assert.equal(question.windowCards!.karamaEmperorSpecial!.played, false);
|
||||
validateTurnActionPromises(actual, seats);
|
||||
const closed = closeTurnActionWindowCardPlay([question], 'respondent', 1, 'karamaEmperorSpecial', source);
|
||||
assert.equal(closed[0].windowCards!.karamaEmperorSpecial!.closed, true);
|
||||
assert.deepEqual(branches(closed[0]), []);
|
||||
});
|
||||
|
||||
void test('Emperor special No and source binding retain exact responder, turn and old receipts', () => {
|
||||
const source = windowSource('karamaEmperorSpecial');
|
||||
const negative = windowed(windowCard('karamaEmperorSpecial'), false);
|
||||
assert.throws(() => recordTurnActionWindowCardPlay([negative], 'respondent', 1, 'karamaEmperorSpecial', source), TurnActionError);
|
||||
const passed = closeTurnActionWindowCardPlay([negative], 'respondent', 1, 'karamaEmperorSpecial', source);
|
||||
assert.equal(passed[0].fulfilled, true);
|
||||
for (const bad of ['', 'storm:1', JSON.stringify(['karamaEmperorSpecial', 0, 'respondent']),
|
||||
JSON.stringify(['karamaEmperorSpecial', 1, '']), JSON.stringify(['karamaEmperorSpecial', 1, 'respondent', 'extra'])])
|
||||
assert.throws(() => parseTurnActionLeaf({ kind: 'windowCardPlay', effect: 'karamaEmperorSpecial', source: bad }), TurnActionError);
|
||||
for (const bad of [JSON.stringify(['karamaEmperorSpecial', 2, 'respondent']),
|
||||
JSON.stringify(['karamaEmperorSpecial', 1, 'other'])])
|
||||
assert.throws(() => turnActionValue(windowed(windowCard('karamaEmperorSpecial', bad))), TurnActionError);
|
||||
const old = windowed(windowCard('karamaAuction'));
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(old)), old);
|
||||
assert.doesNotThrow(() => validateTurnActionPromises([old], seats));
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseKaramaEmperorSpecialSource 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: 'emperor', specialKaramaUsed: false },
|
||||
{ id: 'a', faction: 'atreides', specialKaramaUsed: false }] });
|
||||
const event = JSON.stringify(['karamaEmperorSpecial', 2, 'h']);
|
||||
|
||||
void test('Emperor special window is public, turn-bound and independent of private custody', () => {
|
||||
const game = source(), before = structuredClone(game);
|
||||
assert.deepEqual(windowCardOpportunity(game, 'karamaEmperorSpecial'),
|
||||
{ effect: 'karamaEmperorSpecial', source: event, turn: 2 });
|
||||
assert.deepEqual(parse(event), { turn: 2, owner: 'h' });
|
||||
assert.equal(windowCardNativeEffect('karamaEmperorSpecial'), 'karama');
|
||||
assert.equal(windowCardOpportunity({ ...game, response: { kind: 'voice' }, decision: { kind: 'other' } },
|
||||
'karamaEmperorSpecial')?.source, event);
|
||||
assert.equal(windowCardOpportunity(game, 'karamaEmperorSpecial', 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: 'emperor', specialKaramaUsed: true }] },
|
||||
{ players: [{ id: 'h', faction: 'atreides' }] },
|
||||
{ players: [{ id: 'h', faction: 'emperor' }, { id: 'h', faction: 'atreides' }] },
|
||||
{ players: [{ id: 'h', faction: 'emperor' }, { id: 'h2', faction: 'emperor' }] }])
|
||||
assert.equal(windowCardOpportunity({ ...game, ...changes } as WindowCardGame, 'karamaEmperorSpecial'), null);
|
||||
for (const value of ['', '[]', '["karamaEmperorSpecial",2," h"]',
|
||||
'["karamaEmperorSpecial", 2,"h"]', '["karamaEmperorSpecial",0,"h"]',
|
||||
'["karamaEmperorSpecial",2,"h","extra"]']) assert.equal(parse(value), null);
|
||||
assert.equal(windowCardOpportunity(game, 'karamaEmperorSpecial', JSON.stringify(['karamaEmperorSpecial', 2, 'a'])), null);
|
||||
assert.equal(windowCardOpportunity({ ...game, turn: 3 }, 'karamaEmperorSpecial', event), null);
|
||||
assert.ok(windowCardOpportunity({ ...game, players: [{ id: 'h', faction: 'emperor' }] }, 'karamaEmperorSpecial'));
|
||||
});
|
||||
@@ -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', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'karama', 'karama', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'karama', 'karama', 'karama', 'thumper', 'amal']);
|
||||
});
|
||||
|
||||
void test('Amal names each original phase opening with only its public turn and phase identity', () => {
|
||||
|
||||
Reference in new issue
Block a user